diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel index bd4d8801b0d4e..d334d7979ed59 100644 --- a/WORKSPACE.bazel +++ b/WORKSPACE.bazel @@ -10,15 +10,15 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") # Fetch Node.js rules http_archive( name = "build_bazel_rules_nodejs", - sha256 = "dd7ea7efda7655c218ca707f55c3e1b9c68055a70c31a98f264b3445bc8f4cb1", - urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.2.3/rules_nodejs-3.2.3.tar.gz"], + sha256 = "65067dcad93a61deb593be7d3d9a32a4577d09665536d8da536d731da5cd15e2", + urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.4.2/rules_nodejs-3.4.2.tar.gz"], ) # Now that we have the rules let's import from them to complete the work load("@build_bazel_rules_nodejs//:index.bzl", "check_rules_nodejs_version", "node_repositories", "yarn_install") # Assure we have at least a given rules_nodejs version -check_rules_nodejs_version(minimum_version_string = "3.2.3") +check_rules_nodejs_version(minimum_version_string = "3.4.2") # Setup the Node.js toolchain for the architectures we want to support # diff --git a/api_docs/security_solution.json b/api_docs/security_solution.json index aea50fdbfecaa..1e932a807d7d6 100644 --- a/api_docs/security_solution.json +++ b/api_docs/security_solution.json @@ -207,7 +207,7 @@ "description": [], "source": { "path": "x-pack/plugins/security_solution/public/plugin.tsx", - "lineNumber": 353 + "lineNumber": 346 } }, { @@ -221,7 +221,7 @@ "description": [], "source": { "path": "x-pack/plugins/security_solution/public/plugin.tsx", - "lineNumber": 353 + "lineNumber": 346 } } ], @@ -229,7 +229,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/security_solution/public/plugin.tsx", - "lineNumber": 353 + "lineNumber": 346 } }, { @@ -245,7 +245,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/security_solution/public/plugin.tsx", - "lineNumber": 398 + "lineNumber": 391 } } ], @@ -276,7 +276,7 @@ "description": [], "source": { "path": "x-pack/plugins/security_solution/public/types.ts", - "lineNumber": 69 + "lineNumber": 68 }, "signature": [ "() => Promise<", @@ -287,7 +287,7 @@ ], "source": { "path": "x-pack/plugins/security_solution/public/types.ts", - "lineNumber": 68 + "lineNumber": 67 }, "lifecycle": "setup", "initialIsOpen": true @@ -301,7 +301,7 @@ "children": [], "source": { "path": "x-pack/plugins/security_solution/public/types.ts", - "lineNumber": 72 + "lineNumber": 71 }, "lifecycle": "start", "initialIsOpen": true @@ -453,7 +453,7 @@ "description": [], "source": { "path": "x-pack/plugins/security_solution/server/plugin.ts", - "lineNumber": 147 + "lineNumber": 145 } } ], @@ -461,7 +461,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/security_solution/server/plugin.ts", - "lineNumber": 147 + "lineNumber": 145 } }, { @@ -521,7 +521,7 @@ "description": [], "source": { "path": "x-pack/plugins/security_solution/server/plugin.ts", - "lineNumber": 159 + "lineNumber": 157 } }, { @@ -535,7 +535,7 @@ "description": [], "source": { "path": "x-pack/plugins/security_solution/server/plugin.ts", - "lineNumber": 159 + "lineNumber": 157 } } ], @@ -543,7 +543,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/security_solution/server/plugin.ts", - "lineNumber": 159 + "lineNumber": 157 } }, { @@ -582,7 +582,7 @@ "description": [], "source": { "path": "x-pack/plugins/security_solution/server/plugin.ts", - "lineNumber": 341 + "lineNumber": 338 } }, { @@ -596,7 +596,7 @@ "description": [], "source": { "path": "x-pack/plugins/security_solution/server/plugin.ts", - "lineNumber": 341 + "lineNumber": 338 } } ], @@ -604,7 +604,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/security_solution/server/plugin.ts", - "lineNumber": 341 + "lineNumber": 338 } }, { @@ -620,13 +620,13 @@ "returnComment": [], "source": { "path": "x-pack/plugins/security_solution/server/plugin.ts", - "lineNumber": 423 + "lineNumber": 412 } } ], "source": { "path": "x-pack/plugins/security_solution/server/plugin.ts", - "lineNumber": 131 + "lineNumber": 129 }, "initialIsOpen": false } @@ -1484,7 +1484,7 @@ "children": [], "source": { "path": "x-pack/plugins/security_solution/server/plugin.ts", - "lineNumber": 107 + "lineNumber": 105 }, "lifecycle": "setup", "initialIsOpen": true @@ -1498,7 +1498,7 @@ "children": [], "source": { "path": "x-pack/plugins/security_solution/server/plugin.ts", - "lineNumber": 110 + "lineNumber": 108 }, "lifecycle": "start", "initialIsOpen": true diff --git a/api_docs/telemetry.json b/api_docs/telemetry.json index bfb19a79bdb1e..61b984aad4882 100644 --- a/api_docs/telemetry.json +++ b/api_docs/telemetry.json @@ -1,1127 +1,415 @@ { "id": "telemetry", "client": { - "classes": [ + "classes": [], + "functions": [], + "interfaces": [ { - "id": "def-public.TelemetryNotifications", - "type": "Class", + "id": "def-public.TelemetryPluginConfig", + "type": "Interface", + "label": "TelemetryPluginConfig", + "description": [ + "\nPublic-exposed configuration" + ], "tags": [], - "label": "TelemetryNotifications", - "description": [], "children": [ { - "id": "def-public.TelemetryNotifications.Unnamed", - "type": "Function", - "label": "Constructor", - "signature": [ - "any" - ], - "description": [], - "children": [ - { - "id": "def-public.TelemetryNotifications.Unnamed.$1", - "type": "Object", - "label": "{ http, overlays, telemetryService }", - "isRequired": true, - "signature": [ - "TelemetryNotificationsConstructor" - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", - "lineNumber": 27 - } - } - ], "tags": [], - "returnComment": [], - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", - "lineNumber": 27 - } - }, - { - "id": "def-public.TelemetryNotifications.shouldShowOptedInNoticeBanner", - "type": "Function", - "children": [], - "signature": [ - "() => boolean" + "id": "def-public.TelemetryPluginConfig.enabled", + "type": "boolean", + "label": "enabled", + "description": [ + "Is the plugin enabled?" ], - "description": [], - "label": "shouldShowOptedInNoticeBanner", "source": { - "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", - "lineNumber": 33 - }, - "tags": [], - "returnComment": [] + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 84 + } }, { - "id": "def-public.TelemetryNotifications.renderOptedInNoticeBanner", - "type": "Function", - "children": [], - "signature": [ - "() => void" - ], - "description": [], - "label": "renderOptedInNoticeBanner", - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", - "lineNumber": 39 - }, "tags": [], - "returnComment": [] - }, - { - "id": "def-public.TelemetryNotifications.shouldShowOptInBanner", - "type": "Function", - "children": [], - "signature": [ - "() => boolean" + "id": "def-public.TelemetryPluginConfig.url", + "type": "string", + "label": "url", + "description": [ + "Remote telemetry service's URL" ], - "description": [], - "label": "shouldShowOptInBanner", "source": { - "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", - "lineNumber": 49 - }, - "tags": [], - "returnComment": [] + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 86 + } }, { - "id": "def-public.TelemetryNotifications.renderOptInBanner", - "type": "Function", - "children": [], - "signature": [ - "() => void" - ], - "description": [], - "label": "renderOptInBanner", - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", - "lineNumber": 55 - }, "tags": [], - "returnComment": [] - }, - { - "id": "def-public.TelemetryNotifications.setOptedInNoticeSeen", - "type": "Function", - "children": [], - "signature": [ - "() => Promise" + "id": "def-public.TelemetryPluginConfig.banner", + "type": "boolean", + "label": "banner", + "description": [ + "The banner is expected to be shown when needed" ], - "description": [], - "label": "setOptedInNoticeSeen", - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", - "lineNumber": 73 - }, - "tags": [], - "returnComment": [] - } - ], - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", - "lineNumber": 20 - }, - "initialIsOpen": false - }, - { - "id": "def-public.TelemetryService", - "type": "Class", - "tags": [], - "label": "TelemetryService", - "description": [], - "children": [ - { - "tags": [], - "id": "def-public.TelemetryService.currentKibanaVersion", - "type": "string", - "label": "currentKibanaVersion", - "description": [], "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 28 + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 88 } }, { - "id": "def-public.TelemetryService.Unnamed", - "type": "Function", - "label": "Constructor", - "signature": [ - "any" - ], - "description": [], - "children": [ - { - "id": "def-public.TelemetryService.Unnamed.$1", - "type": "Object", - "label": "{\n config,\n http,\n notifications,\n currentKibanaVersion,\n reportOptInStatusChange = true,\n }", - "isRequired": true, - "signature": [ - "TelemetryServiceConstructor" - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 30 - } - } - ], "tags": [], - "returnComment": [], + "id": "def-public.TelemetryPluginConfig.allowChangingOptInStatus", + "type": "boolean", + "label": "allowChangingOptInStatus", + "description": [ + "Does the cluster allow changing the opt-in/out status via the UI?" + ], "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 30 + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 90 } }, { - "id": "def-public.TelemetryService.config", - "type": "Object", - "label": "config", "tags": [], - "description": [], + "id": "def-public.TelemetryPluginConfig.optIn", + "type": "CompoundType", + "label": "optIn", + "description": [ + "Is the cluster opted-in?" + ], "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 44 + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 92 }, "signature": [ - { - "pluginId": "telemetry", - "scope": "public", - "docId": "kibTelemetryPluginApi", - "section": "def-public.TelemetryPluginConfig", - "text": "TelemetryPluginConfig" - } + "boolean | null" ] }, { - "id": "def-public.TelemetryService.config", - "type": "Object", - "label": "config", "tags": [], - "description": [], + "id": "def-public.TelemetryPluginConfig.optInStatusUrl", + "type": "string", + "label": "optInStatusUrl", + "description": [ + "Opt-in/out notification URL" + ], "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 48 - }, - "signature": [ - { - "pluginId": "telemetry", - "scope": "public", - "docId": "kibTelemetryPluginApi", - "section": "def-public.TelemetryPluginConfig", - "text": "TelemetryPluginConfig" - } - ] + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 94 + } }, { - "id": "def-public.TelemetryService.isOptedIn", - "type": "CompoundType", - "label": "isOptedIn", "tags": [], - "description": [], - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 52 - }, - "signature": [ - "boolean | null" - ] - }, - { - "id": "def-public.TelemetryService.isOptedIn", + "id": "def-public.TelemetryPluginConfig.sendUsageFrom", "type": "CompoundType", - "label": "isOptedIn", - "tags": [], - "description": [], + "label": "sendUsageFrom", + "description": [ + "Should the telemetry payloads be sent from the server or the browser?" + ], "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 56 + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 96 }, "signature": [ - "boolean | null" + "\"browser\" | \"server\"" ] }, { - "id": "def-public.TelemetryService.userHasSeenOptedInNotice", - "type": "CompoundType", - "label": "userHasSeenOptedInNotice", "tags": [], - "description": [], + "id": "def-public.TelemetryPluginConfig.telemetryNotifyUserAboutOptInDefault", + "type": "CompoundType", + "label": "telemetryNotifyUserAboutOptInDefault", + "description": [ + "Should notify the user about the opt-in status?" + ], "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 60 + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 98 }, "signature": [ "boolean | undefined" ] }, { - "id": "def-public.TelemetryService.userHasSeenOptedInNotice", - "type": "CompoundType", - "label": "userHasSeenOptedInNotice", "tags": [], - "description": [], + "id": "def-public.TelemetryPluginConfig.userCanChangeSettings", + "type": "CompoundType", + "label": "userCanChangeSettings", + "description": [ + "Does the user have enough privileges to change the settings?" + ], "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 64 + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 100 }, "signature": [ "boolean | undefined" ] - }, - { - "id": "def-public.TelemetryService.getCanChangeOptInStatus", - "type": "Function", - "children": [], - "signature": [ - "() => boolean" - ], - "description": [], - "label": "getCanChangeOptInStatus", - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 68 - }, - "tags": [], - "returnComment": [] - }, + } + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 82 + }, + "initialIsOpen": false + }, + { + "id": "def-public.TelemetryServicePublicApis", + "type": "Interface", + "label": "TelemetryServicePublicApis", + "description": [ + "\nPublicly exposed APIs from the Telemetry Service" + ], + "tags": [], + "children": [ { - "id": "def-public.TelemetryService.getOptInStatusUrl", - "type": "Function", - "children": [], - "signature": [ - "() => string" - ], - "description": [], - "label": "getOptInStatusUrl", - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 73 - }, "tags": [], - "returnComment": [] - }, - { - "id": "def-public.TelemetryService.getTelemetryUrl", + "id": "def-public.TelemetryServicePublicApis.getIsOptedIn", "type": "Function", - "children": [], - "signature": [ - "() => string" + "label": "getIsOptedIn", + "description": [ + "Is the cluster opted-in to telemetry?" ], - "description": [], - "label": "getTelemetryUrl", "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 78 + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 38 }, - "tags": [], - "returnComment": [] - }, - { - "id": "def-public.TelemetryService.getUserShouldSeeOptInNotice", - "type": "Function", - "label": "getUserShouldSeeOptInNotice", "signature": [ - "() => boolean" - ], - "description": [ - "\nReturns if an user should be shown the notice about Opt-In/Out telemetry.\nThe decision is made based on whether any user has already dismissed the message or\nthe user can't actually change the settings (in which case, there's no point on bothering them)" - ], - "children": [], - "tags": [], - "returnComment": [], - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 88 - } + "() => boolean | null" + ] }, { - "id": "def-public.TelemetryService.userCanChangeSettings", - "type": "boolean", - "label": "userCanChangeSettings", "tags": [], - "description": [], - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 95 - } - }, - { - "id": "def-public.TelemetryService.userCanChangeSettings", + "id": "def-public.TelemetryServicePublicApis.userCanChangeSettings", "type": "boolean", "label": "userCanChangeSettings", - "tags": [], - "description": [], + "description": [ + "Is the user allowed to change the opt-in/out status?" + ], "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 99 + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 40 } }, { - "id": "def-public.TelemetryService.getIsOptedIn", - "type": "Function", - "children": [], - "signature": [ - "() => boolean | null" - ], - "description": [], - "label": "getIsOptedIn", - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 103 - }, "tags": [], - "returnComment": [] - }, - { - "id": "def-public.TelemetryService.fetchExample", + "id": "def-public.TelemetryServicePublicApis.getCanChangeOptInStatus", "type": "Function", - "children": [], - "signature": [ - "() => Promise" + "label": "getCanChangeOptInStatus", + "description": [ + "Is the cluster allowed to change the opt-in/out status?" ], - "description": [], - "label": "fetchExample", "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 107 + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 42 }, - "tags": [], - "returnComment": [] - }, - { - "id": "def-public.TelemetryService.fetchTelemetry", - "type": "Function", - "children": [ - { - "id": "def-public.TelemetryService.fetchTelemetry.$1", - "type": "Object", - "label": "{ unencrypted = false }", - "isRequired": true, - "signature": [ - "{ unencrypted?: boolean | undefined; }" - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 111 - } - } - ], "signature": [ - "({ unencrypted }?: { unencrypted?: boolean | undefined; }) => Promise" - ], - "description": [], - "label": "fetchTelemetry", - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 111 - }, - "tags": [], - "returnComment": [] + "() => boolean" + ] }, { - "id": "def-public.TelemetryService.setOptIn", - "type": "Function", - "children": [ - { - "id": "def-public.TelemetryService.setOptIn.$1", - "type": "boolean", - "label": "optedIn", - "isRequired": true, - "signature": [ - "boolean" - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 119 - } - } - ], - "signature": [ - "(optedIn: boolean) => Promise" - ], - "description": [], - "label": "setOptIn", - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 119 - }, "tags": [], - "returnComment": [] - }, - { - "id": "def-public.TelemetryService.setUserHasSeenNotice", + "id": "def-public.TelemetryServicePublicApis.fetchExample", "type": "Function", - "children": [], - "signature": [ - "() => Promise" + "label": "fetchExample", + "description": [ + "Fetches an unencrypted telemetry payload so we can show it to the user" ], - "description": [], - "label": "setUserHasSeenNotice", - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 153 - }, - "tags": [], - "returnComment": [] - } - ], - "source": { - "path": "src/plugins/telemetry/public/services/telemetry_service.ts", - "lineNumber": 21 - }, - "initialIsOpen": false - } - ], - "functions": [], - "interfaces": [ - { - "id": "def-public.TelemetryPluginConfig", - "type": "Interface", - "label": "TelemetryPluginConfig", - "description": [], - "tags": [], - "children": [ - { - "tags": [], - "id": "def-public.TelemetryPluginConfig.enabled", - "type": "boolean", - "label": "enabled", - "description": [], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 46 - } - }, - { - "tags": [], - "id": "def-public.TelemetryPluginConfig.url", - "type": "string", - "label": "url", - "description": [], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 47 - } - }, - { - "tags": [], - "id": "def-public.TelemetryPluginConfig.banner", - "type": "boolean", - "label": "banner", - "description": [], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 48 - } - }, - { - "tags": [], - "id": "def-public.TelemetryPluginConfig.allowChangingOptInStatus", - "type": "boolean", - "label": "allowChangingOptInStatus", - "description": [], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 49 - } - }, - { - "tags": [], - "id": "def-public.TelemetryPluginConfig.optIn", - "type": "CompoundType", - "label": "optIn", - "description": [], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 50 - }, - "signature": [ - "boolean | null" - ] - }, - { - "tags": [], - "id": "def-public.TelemetryPluginConfig.optInStatusUrl", - "type": "string", - "label": "optInStatusUrl", - "description": [], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 51 - } - }, - { - "tags": [], - "id": "def-public.TelemetryPluginConfig.sendUsageFrom", - "type": "CompoundType", - "label": "sendUsageFrom", - "description": [], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 52 - }, - "signature": [ - "\"browser\" | \"server\"" - ] - }, - { - "tags": [], - "id": "def-public.TelemetryPluginConfig.telemetryNotifyUserAboutOptInDefault", - "type": "CompoundType", - "label": "telemetryNotifyUserAboutOptInDefault", - "description": [], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 53 - }, - "signature": [ - "boolean | undefined" - ] - }, - { - "tags": [], - "id": "def-public.TelemetryPluginConfig.userCanChangeSettings", - "type": "CompoundType", - "label": "userCanChangeSettings", - "description": [], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 54 - }, - "signature": [ - "boolean | undefined" - ] - } - ], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 45 - }, - "initialIsOpen": false - } - ], - "enums": [], - "misc": [], - "objects": [], - "start": { - "id": "def-public.TelemetryPluginStart", - "type": "Interface", - "label": "TelemetryPluginStart", - "description": [], - "tags": [], - "children": [ - { - "tags": [], - "id": "def-public.TelemetryPluginStart.telemetryService", - "type": "Object", - "label": "telemetryService", - "description": [], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 38 - }, - "signature": [ - { - "pluginId": "telemetry", - "scope": "public", - "docId": "kibTelemetryPluginApi", - "section": "def-public.TelemetryService", - "text": "TelemetryService" - } - ] - }, - { - "tags": [], - "id": "def-public.TelemetryPluginStart.telemetryNotifications", - "type": "Object", - "label": "telemetryNotifications", - "description": [], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 39 - }, - "signature": [ - { - "pluginId": "telemetry", - "scope": "public", - "docId": "kibTelemetryPluginApi", - "section": "def-public.TelemetryNotifications", - "text": "TelemetryNotifications" - } - ] - }, - { - "tags": [], - "id": "def-public.TelemetryPluginStart.telemetryConstants", - "type": "Object", - "label": "telemetryConstants", - "description": [], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 40 - }, - "signature": [ - "{ getPrivacyStatementUrl: () => string; }" - ] - } - ], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 37 - }, - "lifecycle": "start", - "initialIsOpen": true - }, - "setup": { - "id": "def-public.TelemetryPluginSetup", - "type": "Interface", - "label": "TelemetryPluginSetup", - "description": [], - "tags": [], - "children": [ - { - "tags": [], - "id": "def-public.TelemetryPluginSetup.telemetryService", - "type": "Object", - "label": "telemetryService", - "description": [], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 34 - }, - "signature": [ - { - "pluginId": "telemetry", - "scope": "public", - "docId": "kibTelemetryPluginApi", - "section": "def-public.TelemetryService", - "text": "TelemetryService" - } - ] - } - ], - "source": { - "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 33 - }, - "lifecycle": "setup", - "initialIsOpen": true - } - }, - "server": { - "classes": [], - "functions": [ - { - "id": "def-server.buildDataTelemetryPayload", - "type": "Function", - "label": "buildDataTelemetryPayload", - "signature": [ - "(indices: ", - { - "pluginId": "telemetry", - "scope": "server", - "docId": "kibTelemetryPluginApi", - "section": "def-server.DataTelemetryIndex", - "text": "DataTelemetryIndex" - }, - "[]) => ", - { - "pluginId": "telemetry", - "scope": "server", - "docId": "kibTelemetryPluginApi", - "section": "def-server.DataTelemetryPayload", - "text": "DataTelemetryPayload" - } - ], - "description": [], - "children": [ - { - "id": "def-server.buildDataTelemetryPayload.$1", - "type": "Array", - "label": "indices", - "isRequired": true, - "signature": [ - { - "pluginId": "telemetry", - "scope": "server", - "docId": "kibTelemetryPluginApi", - "section": "def-server.DataTelemetryIndex", - "text": "DataTelemetryIndex" - }, - "[]" - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 122 - } - } - ], - "tags": [], - "returnComment": [], - "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 122 - }, - "initialIsOpen": false - }, - { - "id": "def-server.getClusterUuids", - "type": "Function", - "children": [ - { - "id": "def-server.getClusterUuids.$1", - "type": "Object", - "label": "{ esClient }", - "isRequired": true, - "signature": [ - { - "pluginId": "telemetryCollectionManager", - "scope": "server", - "docId": "kibTelemetryCollectionManagerPluginApi", - "section": "def-server.StatsCollectionConfig", - "text": "StatsCollectionConfig" - } - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts", - "lineNumber": 25 - } - } - ], - "signature": [ - "({ esClient }: ", - { - "pluginId": "telemetryCollectionManager", - "scope": "server", - "docId": "kibTelemetryCollectionManagerPluginApi", - "section": "def-server.StatsCollectionConfig", - "text": "StatsCollectionConfig" - }, - ") => Promise<{ clusterUuid: string; }[]>" - ], - "description": [ - "\nGet the cluster uuids from the connected cluster." - ], - "label": "getClusterUuids", - "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts", - "lineNumber": 25 - }, - "tags": [], - "returnComment": [], - "initialIsOpen": false - }, - { - "id": "def-server.getLocalStats", - "type": "Function", - "children": [ - { - "id": "def-server.getLocalStats.$1", - "type": "Array", - "label": "clustersDetails", - "isRequired": true, - "signature": [ - { - "pluginId": "telemetryCollectionManager", - "scope": "server", - "docId": "kibTelemetryCollectionManagerPluginApi", - "section": "def-server.ClusterDetails", - "text": "ClusterDetails" - }, - "[]" - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts", - "lineNumber": 60 - } - }, - { - "id": "def-server.getLocalStats.$2", - "type": "Object", - "label": "config", - "isRequired": true, - "signature": [ - { - "pluginId": "telemetryCollectionManager", - "scope": "server", - "docId": "kibTelemetryCollectionManagerPluginApi", - "section": "def-server.StatsCollectionConfig", - "text": "StatsCollectionConfig" - } - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts", - "lineNumber": 61 - } - }, - { - "id": "def-server.getLocalStats.$3", - "type": "Object", - "label": "context", - "isRequired": true, - "signature": [ - { - "pluginId": "telemetryCollectionManager", - "scope": "server", - "docId": "kibTelemetryCollectionManagerPluginApi", - "section": "def-server.StatsCollectionContext", - "text": "StatsCollectionContext" - } - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts", - "lineNumber": 62 - } - } - ], - "signature": [ - "(clustersDetails: ", - { - "pluginId": "telemetryCollectionManager", - "scope": "server", - "docId": "kibTelemetryCollectionManagerPluginApi", - "section": "def-server.ClusterDetails", - "text": "ClusterDetails" - }, - "[], config: ", - { - "pluginId": "telemetryCollectionManager", - "scope": "server", - "docId": "kibTelemetryCollectionManagerPluginApi", - "section": "def-server.StatsCollectionConfig", - "text": "StatsCollectionConfig" - }, - ", context: ", - { - "pluginId": "telemetryCollectionManager", - "scope": "server", - "docId": "kibTelemetryCollectionManagerPluginApi", - "section": "def-server.StatsCollectionContext", - "text": "StatsCollectionContext" - }, - ") => Promise<{ timestamp: string; cluster_uuid: string; cluster_name: string; version: string; cluster_stats: Pick<{ nodes: { usage: { nodes: ", - { - "pluginId": "telemetry", - "scope": "server", - "docId": "kibTelemetryPluginApi", - "section": "def-server.NodeUsage", - "text": "NodeUsage" - }, - "[] | {}[]; }; count: ", - "ClusterNodeCount" - ], - "description": [ - "\nGet statistics for all products joined by Elasticsearch cluster." - ], - "label": "getLocalStats", - "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts", - "lineNumber": 59 - }, - "tags": [], - "returnComment": [], - "initialIsOpen": false - }, - { - "id": "def-server.handleOldSettings", - "type": "Function", - "label": "handleOldSettings", - "signature": [ - "(savedObjectsClient: Pick<", - { - "pluginId": "core", - "scope": "server", - "docId": "kibCoreSavedObjectsPluginApi", - "section": "def-server.SavedObjectsClient", - "text": "SavedObjectsClient" - }, - ", \"get\" | \"delete\" | \"create\" | \"bulkCreate\" | \"checkConflicts\" | \"find\" | \"bulkGet\" | \"resolve\" | \"update\" | \"addToNamespaces\" | \"deleteFromNamespaces\" | \"bulkUpdate\" | \"removeReferencesTo\" | \"openPointInTimeForType\" | \"closePointInTime\" | \"createPointInTimeFinder\" | \"errors\">, uiSettingsClient: ", - { - "pluginId": "core", - "scope": "server", - "docId": "kibCorePluginApi", - "section": "def-server.IUiSettingsClient", - "text": "IUiSettingsClient" - }, - ") => Promise" - ], - "description": [], - "children": [ - { - "id": "def-server.handleOldSettings.$1", - "type": "Object", - "label": "savedObjectsClient", - "isRequired": true, - "signature": [ - "Pick<", - { - "pluginId": "core", - "scope": "server", - "docId": "kibCoreSavedObjectsPluginApi", - "section": "def-server.SavedObjectsClient", - "text": "SavedObjectsClient" - }, - ", \"get\" | \"delete\" | \"create\" | \"bulkCreate\" | \"checkConflicts\" | \"find\" | \"bulkGet\" | \"resolve\" | \"update\" | \"addToNamespaces\" | \"deleteFromNamespaces\" | \"bulkUpdate\" | \"removeReferencesTo\" | \"openPointInTimeForType\" | \"closePointInTime\" | \"createPointInTimeFinder\" | \"errors\">" - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/server/handle_old_settings/handle_old_settings.ts", - "lineNumber": 25 - } - }, - { - "id": "def-server.handleOldSettings.$2", - "type": "Object", - "label": "uiSettingsClient", - "isRequired": true, - "signature": [ - { - "pluginId": "core", - "scope": "server", - "docId": "kibCorePluginApi", - "section": "def-server.IUiSettingsClient", - "text": "IUiSettingsClient" - } - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/server/handle_old_settings/handle_old_settings.ts", - "lineNumber": 26 - } - } - ], - "tags": [], - "returnComment": [], - "source": { - "path": "src/plugins/telemetry/server/handle_old_settings/handle_old_settings.ts", - "lineNumber": 24 - }, - "initialIsOpen": false - } - ], - "interfaces": [ - { - "id": "def-server.DataTelemetryIndex", - "type": "Interface", - "label": "DataTelemetryIndex", - "description": [], - "tags": [], - "children": [ - { - "tags": [], - "id": "def-server.DataTelemetryIndex.name", - "type": "string", - "label": "name", - "description": [], - "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 39 - } - }, - { - "tags": [], - "id": "def-server.DataTelemetryIndex.packageName", - "type": "string", - "label": "packageName", - "description": [], - "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 40 - }, - "signature": [ - "string | undefined" - ] - }, - { - "tags": [], - "id": "def-server.DataTelemetryIndex.managedBy", - "type": "string", - "label": "managedBy", - "description": [], - "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 41 - }, - "signature": [ - "string | undefined" - ] - }, - { - "tags": [], - "id": "def-server.DataTelemetryIndex.dataStreamDataset", - "type": "string", - "label": "dataStreamDataset", - "description": [], "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 42 + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 44 }, "signature": [ - "string | undefined" + "() => Promise" ] }, { "tags": [], - "id": "def-server.DataTelemetryIndex.dataStreamType", - "type": "string", - "label": "dataStreamType", - "description": [], + "id": "def-public.TelemetryServicePublicApis.setOptIn", + "type": "Function", + "label": "setOptIn", + "description": [ + "\nOverwrite the opt-in status.\nIt will send a final request to the remote telemetry cluster to report about the opt-in/out change." + ], "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 43 + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 50 }, "signature": [ - "string | undefined" + "(optedIn: boolean) => Promise" ] + } + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 36 + }, + "initialIsOpen": false + } + ], + "enums": [], + "misc": [], + "objects": [], + "start": { + "id": "def-public.TelemetryPluginStart", + "type": "Interface", + "label": "TelemetryPluginStart", + "description": [ + "\nPublic's start exposed APIs by the telemetry plugin" + ], + "tags": [], + "children": [ + { + "tags": [], + "id": "def-public.TelemetryPluginStart.telemetryService", + "type": "Object", + "label": "telemetryService", + "description": [ + "{@link TelemetryServicePublicApis}" + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 66 + }, + "signature": [ + { + "pluginId": "telemetry", + "scope": "public", + "docId": "kibTelemetryPluginApi", + "section": "def-public.TelemetryServicePublicApis", + "text": "TelemetryServicePublicApis" + } + ] + }, + { + "tags": [], + "id": "def-public.TelemetryPluginStart.telemetryNotifications", + "type": "Object", + "label": "telemetryNotifications", + "description": [ + "Notification helpers" + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 68 + }, + "signature": [ + "{ setOptedInNoticeSeen: () => Promise; }" + ] + }, + { + "tags": [], + "id": "def-public.TelemetryPluginStart.telemetryConstants", + "type": "Object", + "label": "telemetryConstants", + "description": [ + "Set of publicly exposed telemetry constants" + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 73 + }, + "signature": [ + "{ getPrivacyStatementUrl: () => string; }" + ] + } + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 64 + }, + "lifecycle": "start", + "initialIsOpen": true + }, + "setup": { + "id": "def-public.TelemetryPluginSetup", + "type": "Interface", + "label": "TelemetryPluginSetup", + "description": [ + "\nPublic's setup exposed APIs by the telemetry plugin" + ], + "tags": [], + "children": [ + { + "tags": [], + "id": "def-public.TelemetryPluginSetup.telemetryService", + "type": "Object", + "label": "telemetryService", + "description": [ + "{@link TelemetryService}" + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 58 }, + "signature": [ + { + "pluginId": "telemetry", + "scope": "public", + "docId": "kibTelemetryPluginApi", + "section": "def-public.TelemetryServicePublicApis", + "text": "TelemetryServicePublicApis" + } + ] + } + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 56 + }, + "lifecycle": "setup", + "initialIsOpen": true + } + }, + "server": { + "classes": [], + "functions": [], + "interfaces": [ + { + "id": "def-server.DataTelemetryBasePayload", + "type": "Interface", + "label": "DataTelemetryBasePayload", + "description": [ + "\nCommon counters for the {@link DataTelemetryDocument}s" + ], + "tags": [], + "children": [ { "tags": [], - "id": "def-server.DataTelemetryIndex.shipper", - "type": "string", - "label": "shipper", - "description": [], + "id": "def-server.DataTelemetryBasePayload.index_count", + "type": "number", + "label": "index_count", + "description": [ + "How many indices match the declared pattern" + ], "source": { "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 44 - }, - "signature": [ - "string | undefined" - ] + "lineNumber": 22 + } }, { "tags": [], - "id": "def-server.DataTelemetryIndex.isECS", - "type": "CompoundType", - "label": "isECS", - "description": [], + "id": "def-server.DataTelemetryBasePayload.ecs_index_count", + "type": "number", + "label": "ecs_index_count", + "description": [ + "How many indices match the declared pattern follow ECS conventions" + ], "source": { "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 45 + "lineNumber": 24 }, "signature": [ - "boolean | undefined" + "number | undefined" ] }, { "tags": [], - "id": "def-server.DataTelemetryIndex.docCount", + "id": "def-server.DataTelemetryBasePayload.doc_count", "type": "number", - "label": "docCount", - "description": [], + "label": "doc_count", + "description": [ + "How many documents are among all the identified indices" + ], "source": { "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 49 + "lineNumber": 26 }, "signature": [ "number | undefined" @@ -1129,13 +417,15 @@ }, { "tags": [], - "id": "def-server.DataTelemetryIndex.sizeInBytes", + "id": "def-server.DataTelemetryBasePayload.size_in_bytes", "type": "number", - "label": "sizeInBytes", - "description": [], + "label": "size_in_bytes", + "description": [ + "Total size in bytes among all the identified indices" + ], "source": { "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 50 + "lineNumber": 28 }, "signature": [ "number | undefined" @@ -1144,154 +434,161 @@ ], "source": { "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 38 + "lineNumber": 20 }, "initialIsOpen": false }, { - "id": "def-server.NodeUsage", + "id": "def-server.DataTelemetryDocument", "type": "Interface", - "label": "NodeUsage", - "description": [], + "label": "DataTelemetryDocument", + "signature": [ + { + "pluginId": "telemetry", + "scope": "server", + "docId": "kibTelemetryPluginApi", + "section": "def-server.DataTelemetryDocument", + "text": "DataTelemetryDocument" + }, + " extends ", + { + "pluginId": "telemetry", + "scope": "server", + "docId": "kibTelemetryPluginApi", + "section": "def-server.DataTelemetryBasePayload", + "text": "DataTelemetryBasePayload" + } + ], + "description": [ + "\nDepending on the type of index, we'll populate different keys as we identify them." + ], "tags": [], "children": [ { "tags": [], - "id": "def-server.NodeUsage.node_id", - "type": "string", - "label": "node_id", - "description": [], + "id": "def-server.DataTelemetryDocument.data_stream", + "type": "Object", + "label": "data_stream", + "description": [ + "For data-stream indices. Reporting their details" + ], "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", - "lineNumber": 18 + "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", + "lineNumber": 36 }, "signature": [ - "string | undefined" + "{ dataset?: string | undefined; type?: string | undefined; } | undefined" ] }, { "tags": [], - "id": "def-server.NodeUsage.timestamp", - "type": "CompoundType", - "label": "timestamp", - "description": [], + "id": "def-server.DataTelemetryDocument.package", + "type": "Object", + "label": "package", + "description": [ + "When available, reporting the package details" + ], "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", - "lineNumber": 19 + "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", + "lineNumber": 43 }, "signature": [ - "React.ReactText" + "{ name: string; } | undefined" ] }, { "tags": [], - "id": "def-server.NodeUsage.since", - "type": "number", - "label": "since", - "description": [], - "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", - "lineNumber": 20 - } - }, - { - "tags": [], - "id": "def-server.NodeUsage.rest_actions", - "type": "Object", - "label": "rest_actions", - "description": [], + "id": "def-server.DataTelemetryDocument.shipper", + "type": "string", + "label": "shipper", + "description": [ + "What's the process indexing the data? (i.e.: \"beats\", \"logstash\")" + ], "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", - "lineNumber": 21 + "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", + "lineNumber": 48 }, "signature": [ - "{ [key: string]: number; }" + "string | undefined" ] }, { "tags": [], - "id": "def-server.NodeUsage.aggregations", - "type": "Object", - "label": "aggregations", - "description": [], + "id": "def-server.DataTelemetryDocument.pattern_name", + "type": "CompoundType", + "label": "pattern_name", + "description": [ + "When the data comes from a matching index-pattern, the name of the pattern" + ], "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", - "lineNumber": 24 + "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", + "lineNumber": 50 }, "signature": [ - "{ [key: string]: ", - { - "pluginId": "telemetry", - "scope": "server", - "docId": "kibTelemetryPluginApi", - "section": "def-server.NodeUsageAggregation", - "text": "NodeUsageAggregation" - }, - "; } | undefined" + "\"search\" | \"logstash\" | \"enterprise-search\" | \"app-search\" | \"magento2\" | \"magento\" | \"shopify\" | \"wordpress\" | \"drupal\" | \"joomla\" | \"sharepoint\" | \"squarespace\" | \"sitecore\" | \"weebly\" | \"acquia\" | \"filebeat\" | \"metricbeat\" | \"apm\" | \"functionbeat\" | \"heartbeat\" | \"fluentd\" | \"telegraf\" | \"prometheusbeat\" | \"fluentbit\" | \"nginx\" | \"apache\" | \"endgame\" | \"logs-endpoint\" | \"metrics-endpoint\" | \"siem-signals\" | \"auditbeat\" | \"winlogbeat\" | \"packetbeat\" | \"tomcat\" | \"artifactory\" | \"aruba\" | \"barracuda\" | \"bluecoat\" | \"arcsight\" | \"checkpoint\" | \"cisco\" | \"citrix\" | \"cyberark\" | \"cylance\" | \"fireeye\" | \"fortinet\" | \"infoblox\" | \"kaspersky\" | \"mcafee\" | \"paloaltonetworks\" | \"rsa\" | \"snort\" | \"sonicwall\" | \"sophos\" | \"squid\" | \"symantec\" | \"tippingpoint\" | \"trendmicro\" | \"tripwire\" | \"zscaler\" | \"zeek\" | \"sigma_doc\" | \"ecs-corelight\" | \"suricata\" | \"wazuh\" | \"meow\" | undefined" ] } ], "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", - "lineNumber": 17 + "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", + "lineNumber": 34 }, "initialIsOpen": false }, { - "id": "def-server.NodeUsageAggregation", + "id": "def-server.NodeUsage", "type": "Interface", - "label": "NodeUsageAggregation", - "description": [], + "label": "NodeUsage", + "signature": [ + { + "pluginId": "telemetry", + "scope": "server", + "docId": "kibTelemetryPluginApi", + "section": "def-server.NodeUsage", + "text": "NodeUsage" + }, + " extends ", + "NodeUsageInformation" + ], + "description": [ + "\nData returned by GET /_nodes/usage, but flattened as an array of {@link estypes.NodeUsageInformation}\nwith the node ID set in the field `node_id`." + ], "tags": [], "children": [ { - "id": "def-server.NodeUsageAggregation.Unnamed", - "type": "Any", - "label": "Unnamed", "tags": [], - "description": [], + "id": "def-server.NodeUsage.node_id", + "type": "string", + "label": "node_id", + "description": [ + "\nThe Node ID as reported by ES" + ], "source": { "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", - "lineNumber": 13 - }, - "signature": [ - "any" - ] + "lineNumber": 21 + } } ], "source": { "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", - "lineNumber": 12 + "lineNumber": 17 }, "initialIsOpen": false } ], "enums": [], "misc": [ - { - "tags": [], - "id": "def-server.DATA_TELEMETRY_ID", - "type": "string", - "label": "DATA_TELEMETRY_ID", - "description": [], - "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/constants.ts", - "lineNumber": 9 - }, - "signature": [ - "\"data\"" - ], - "initialIsOpen": false - }, { "id": "def-server.DataTelemetryPayload", "type": "Type", "label": "DataTelemetryPayload", "tags": [], - "description": [], + "description": [ + "\nThe Data Telemetry is reported as an array of {@link DataTelemetryDocument}" + ], "source": { "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 36 + "lineNumber": 56 }, "signature": [ "DataTelemetryDocument[]" @@ -1303,10 +600,12 @@ "type": "Type", "label": "TelemetryLocalStats", "tags": [], - "description": [], + "description": [ + "\nThe payload structure as composed by the OSS telemetry collection mechanism." + ], "source": { "path": "src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts", - "lineNumber": 51 + "lineNumber": 54 }, "signature": [ "{ timestamp: string; cluster_uuid: string; cluster_name: string; version: string; cluster_stats: Pick; collection: string; stack_stats: { data: DataTelemetryPayload | undefined; kibana: { count: number; indices: number; os: {}; versions: { version: string; count: number; }[]; plugins: { [plugin: string]: Record; }; } | undefined; }; }" @@ -1319,7 +618,9 @@ "id": "def-server.TelemetryPluginSetup", "type": "Interface", "label": "TelemetryPluginSetup", - "description": [], + "description": [ + "\nServer's setup exposed APIs by the telemetry plugin" + ], "tags": [], "children": [ { @@ -1332,7 +633,7 @@ ], "source": { "path": "src/plugins/telemetry/server/plugin.ts", - "lineNumber": 53 + "lineNumber": 56 }, "signature": [ "() => Promise<", @@ -1343,7 +644,7 @@ ], "source": { "path": "src/plugins/telemetry/server/plugin.ts", - "lineNumber": 48 + "lineNumber": 51 }, "lifecycle": "setup", "initialIsOpen": true @@ -1352,7 +653,9 @@ "id": "def-server.TelemetryPluginStart", "type": "Interface", "label": "TelemetryPluginStart", - "description": [], + "description": [ + "\nServer's start exposed APIs by the telemetry plugin" + ], "tags": [], "children": [ { @@ -1365,7 +668,7 @@ ], "source": { "path": "src/plugins/telemetry/server/plugin.ts", - "lineNumber": 62 + "lineNumber": 68 }, "signature": [ "() => Promise" @@ -1374,7 +677,7 @@ ], "source": { "path": "src/plugins/telemetry/server/plugin.ts", - "lineNumber": 56 + "lineNumber": 62 }, "lifecycle": "start", "initialIsOpen": true diff --git a/api_docs/telemetry.mdx b/api_docs/telemetry.mdx index f9a58d29ebd86..995c9b22e268a 100644 --- a/api_docs/telemetry.mdx +++ b/api_docs/telemetry.mdx @@ -19,9 +19,6 @@ import telemetryObj from './telemetry.json'; ### Start -### Classes - - ### Interfaces @@ -33,9 +30,6 @@ import telemetryObj from './telemetry.json'; ### Start -### Functions - - ### Interfaces diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 6f54e924769b8..ad58cd040ff35 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -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] diff --git a/docs/discover/context.asciidoc b/docs/discover/context.asciidoc deleted file mode 100644 index 9131c81781fc8..0000000000000 --- a/docs/discover/context.asciidoc +++ /dev/null @@ -1,60 +0,0 @@ -[[discover-document-context]] -== View surrounding documents - -Once you've narrowed your search to a specific event in *Discover*, -you can inspect the documents that occurred -immediately before and after the event. -To view the surrounding documents, your index pattern must contain time-based events. - -. In the document table, click the expand icon (>). -. Click *View surrounding documents.* -+ -In the context view, documents are sorted by the time field specified in the index pattern -and displayed using the same set of columns as the *Discover* view from which -the context was opened. The anchor document is highlighted in blue. -+ -[role="screenshot"] -image::images/discover-context.png[Image showing context view feature, with anchor documents highlighted in blue] -+ -The filters you applied in *Discover* are carried over to the context view. Pinned -filters remain active, while normal filters are copied in a disabled state. - -+ -[role="screenshot"] -image::images/discover-context-filters-inactive.png[Filter in context view] - -. To find the documents of interest, add filters. - -. To increase the number of documents that surround the anchor document, click *Load*. -By default, five documents are added with each click. -+ -[role="screenshot"] -image::images/discover-context-load-newer-documents.png[Load button and the number of documents to load] - - -[float] -[[configure-context-ContextView]] -=== Configure the context view - -Configure the appearance and behavior in *Advanced Settings*. - -. Open the main menu, then click *Stack Management > Advanced Settings*. -. Search for `context`, then edit the settings. -+ -[horizontal] -`context:defaultSize`:: The number of documents to display by default. -`context:step`:: The default number of documents to load with each button click. The default is 5. -`context:tieBreakerFields`:: The field to use for tiebreaking in case of equal time field values. -The default is the `_doc` field. -+ -You can enter a comma-separated list of field -names, which is checked in sequence for suitability when a context is -displayed. The first suitable field is used as the tiebreaking -field. A field is suitable if the field exists and is sortable in the index -pattern the context is based on. -+ -Although not required, it is recommended to only -use fields that have {ref}/doc-values.html[doc values] enabled to achieve -good performance and avoid unnecessary {ref}/modules-fielddata.html[field -data] usage. Common examples for suitable fields include log line numbers, -monotonically increasing counters and high-precision timestamps. \ No newline at end of file diff --git a/docs/discover/images/discover-context-load-newer-documents.png b/docs/discover/images/discover-context-load-newer-documents.png index 9c4a74d39b3c9..65d168f5ca4af 100644 Binary files a/docs/discover/images/discover-context-load-newer-documents.png and b/docs/discover/images/discover-context-load-newer-documents.png differ diff --git a/docs/discover/images/discover-view-single-document.png b/docs/discover/images/discover-view-single-document.png new file mode 100644 index 0000000000000..d803acc49ce24 Binary files /dev/null and b/docs/discover/images/discover-view-single-document.png differ diff --git a/docs/discover/images/expand-icon.png b/docs/discover/images/expand-icon.png new file mode 100644 index 0000000000000..5ee60d12598e2 Binary files /dev/null and b/docs/discover/images/expand-icon.png differ diff --git a/docs/discover/view-document.asciidoc b/docs/discover/view-document.asciidoc new file mode 100644 index 0000000000000..b471e238c1a0f --- /dev/null +++ b/docs/discover/view-document.asciidoc @@ -0,0 +1,56 @@ +[[discover-view-document]] +== View a document + +Once you've found a document of interest in *Discover*, you have two more ways to +view it: in a view by itself or in context with surrounding documents. + +[float] +[[discover-view-single-document]] +=== View a single document + +Access a single document so you can bookmark it and share the link. + +. In the document table, click the expand icon (>). +. In the expanded view, click **View single document**. ++ +You can view the document in two ways. The **Table** view displays the document fields row-by-row. +The **JSON** (JavaScript Object Notation) view allows you to look at how {es} returns the document. ++ +[role="screenshot"] +image::images/discover-view-single-document.png[Discover single document view] ++ +The link is valid for the time the document is available in Elasticsearch. To create a customized view of the document, +you can create <>. + +[float] +[[discover-view-surrounding-documents]] +=== View surrounding documents + +To inspect the documents that occurred immediately before and after a document, +your index pattern must contain time-based events. + +. In the document table, click the expand icon (>). +. In the expanded view, click **View surrounding documents**. ++ +Documents are displayed using the same set of columns as the *Discover* view from which +the context was opened. The anchor document is highlighted in blue. ++ +[role="screenshot"] +image::images/discover-context.png[Image showing context view feature, with anchor documents highlighted in blue] ++ +The filters you applied in *Discover* are carried over to the context view. Pinned +filters remain active, while normal filters are copied in a disabled state. ++ +[role="screenshot"] +image::images/discover-context-filters-inactive.png[Filter in context view] + +. To find the documents of interest, add filters. + +. To increase the number of documents that surround the anchor document, click *Load*. +By default, five documents are added with each click. ++ +[role="screenshot"] +image::images/discover-context-load-newer-documents.png[Load button and the number of documents to load] +. To configure the number of documents to display and +the number of documents to load with each button click, go to *Stack Management > Advanced Settings* +and edit the <>. diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index 15b353223452a..4aedb0f516b20 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -297,3 +297,8 @@ This content has moved. refer to <>. == Search your data This content has moved. refer to <>. + +[role="exclude",id="discover-document-context"] +== View surrounding documents + +This content has moved. refer to <>. diff --git a/docs/user/discover.asciidoc b/docs/user/discover.asciidoc index 4565f7c9616c3..0a8fefa3c0693 100644 --- a/docs/user/discover.asciidoc +++ b/docs/user/discover.asciidoc @@ -173,9 +173,9 @@ image:images/document-table-expanded.png[Table view with document expanded] hover of its name for filters and other controls. . To view documents that occurred before or after the event you are looking at, click -<>. +<>. -. For direct access to a particular document, click **View single document**. +. For direct access to a particular document, click **<>**. + You can bookmark this document and share the link. @@ -243,7 +243,7 @@ the table columns that display by default, and more. -- -include::{kib-repo-dir}/discover/context.asciidoc[] +include::{kib-repo-dir}/discover/view-document.asciidoc[] include::{kib-repo-dir}/discover/search-for-relevance.asciidoc[] diff --git a/package.json b/package.json index 7cd0c273ee543..6be19669d25e1 100644 --- a/package.json +++ b/package.json @@ -426,7 +426,7 @@ "@babel/traverse": "^7.12.12", "@babel/types": "^7.12.12", "@bazel/ibazel": "^0.15.10", - "@bazel/typescript": "^3.2.3", + "@bazel/typescript": "^3.4.2", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.6.0", "@elastic/apm-rum": "^5.6.1", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 95bf3f8f251b7..0bb4594244a75 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -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 diff --git a/rfcs/text/0013_saved_object_migrations.md b/rfcs/text/0013_saved_object_migrations.md index 88879e5e706eb..2f7ed796bf0e6 100644 --- a/rfcs/text/0013_saved_object_migrations.md +++ b/rfcs/text/0013_saved_object_migrations.md @@ -265,12 +265,12 @@ Note: 3. If the clone operation fails because the target index already exist, ignore the error and wait for the target index to become green before proceeding. 4. (The `001` postfix in the target index name isn't used by Kibana, but allows for re-indexing an index should this be required by an Elasticsearch upgrade. E.g. re-index `.kibana_7.10.0_001` into `.kibana_7.10.0_002` and point the `.kibana_7.10.0` alias to `.kibana_7.10.0_002`.) 9. Transform documents by reading batches of outdated documents from the target index then transforming and updating them with optimistic concurrency control. - 1. Ignore any version conflict errors. - 2. If a document transform throws an exception, add the document to a failure list and continue trying to transform all other documents. If any failures occured, log the complete list of documents that failed to transform. Fail the migration. + 1. Ignore any version conflict errors. + 2. If a document transform throws an exception, add the document to a failure list and continue trying to transform all other documents. If any failures occured, log the complete list of documents that failed to transform. Fail the migration. 10. Update the mappings of the target index - 1. Retrieve the existing mappings including the `migrationMappingPropertyHashes` metadata. - 2. Update the mappings with `PUT /.kibana_7.10.0_001/_mapping`. The API deeply merges any updates so this won't remove the mappings of any plugins that are disabled on this instance but have been enabled on another instance that also migrated this index. - 3. Ensure that fields are correctly indexed using the target index's latest mappings `POST /.kibana_7.10.0_001/_update_by_query?conflicts=proceed`. In the future we could optimize this query by only targeting documents: + 1. Retrieve the existing mappings including the `migrationMappingPropertyHashes` metadata. + 2. Update the mappings with `PUT /.kibana_7.10.0_001/_mapping`. The API deeply merges any updates so this won't remove the mappings of any plugins that are disabled on this instance but have been enabled on another instance that also migrated this index. + 3. Ensure that fields are correctly indexed using the target index's latest mappings `POST /.kibana_7.10.0_001/_update_by_query?conflicts=proceed`. In the future we could optimize this query by only targeting documents: 1. That belong to a known saved object type. 11. Mark the migration as complete. This is done as a single atomic operation (requires https://github.com/elastic/elasticsearch/pull/58100) @@ -278,12 +278,12 @@ Note: migration in parallel, only one version will win. E.g. if 7.11 and 7.12 are started in parallel and migrate from a 7.9 index, either 7.11 or 7.12 should succeed and accept writes, but not both. - 1. Check that `.kibana` alias is still pointing to the source index - 2. Point the `.kibana_7.10.0` and `.kibana` aliases to the target index. - 3. Remove the temporary index `.kibana_7.10.0_reindex_temp` - 4. If this fails with a "required alias [.kibana] does not exist" error or "index_not_found_exception" for the temporary index, fetch `.kibana` again: - 1. If `.kibana` is _not_ pointing to our target index fail the migration. - 2. If `.kibana` is pointing to our target index the migration has succeeded and we can proceed to step (12). + 1. Check that `.kibana` alias is still pointing to the source index + 2. Point the `.kibana_7.10.0` and `.kibana` aliases to the target index. + 3. Remove the temporary index `.kibana_7.10.0_reindex_temp` + 4. If this fails with a "required alias [.kibana] does not exist" error or "index_not_found_exception" for the temporary index, fetch `.kibana` again: + 1. If `.kibana` is _not_ pointing to our target index fail the migration. + 2. If `.kibana` is pointing to our target index the migration has succeeded and we can proceed to step (12). 12. Start serving traffic. All saved object reads/writes happen through the version-specific alias `.kibana_7.10.0`. @@ -821,4 +821,4 @@ to enumarate some scenarios and their worst case impact: until we re-index. Is it sufficient to only re-index every major? How do we track the field count as it grows over every upgrade? 2. More generally, how do we deal with the growing field count approaching the - default limit of 1000? + default limit of 1000? \ No newline at end of file diff --git a/src/cli/serve/integration_tests/invalid_config.test.ts b/src/cli/serve/integration_tests/invalid_config.test.ts index 517c8aa946590..b593aa9a73196 100644 --- a/src/cli/serve/integration_tests/invalid_config.test.ts +++ b/src/cli/serve/integration_tests/invalid_config.test.ts @@ -18,7 +18,8 @@ interface LogEntry { type: string; } -describe('cli invalid config support', function () { +// FLAKY: https://github.com/elastic/kibana/issues/32240 +describe.skip('cli invalid config support', function () { it( 'exits with statusCode 64 and logs a single line when config is invalid', function () { diff --git a/src/core/server/saved_objects/migrationsv2/README.md b/src/core/server/saved_objects/migrationsv2/README.md index fcfff14ec98be..c92a5245e6c91 100644 --- a/src/core/server/saved_objects/migrationsv2/README.md +++ b/src/core/server/saved_objects/migrationsv2/README.md @@ -1,17 +1,358 @@ -## TODO - - [ ] Should we adopt the naming convention of event log `.kibana-event-log-8.0.0-000001`? - - [ ] Can we detect and throw if there's an auto-created `.kibana` index - with inferred mappings? If we detect this we cannot assume that `.kibana` - contains all the latest documents. Our algorithm might also fail because we - clone the `.kibana` index with it's faulty mappings which can prevent us - from updating the mappings to the correct ones. We can ask users to verify - their indices to identify where the most up to date documents are located - (e.g. in `.kibana`, `.kibana_N` or perhaps a combination of both). We can - prepare a `.kibana_7.11.0_001` index and ask users to manually reindex - documents into this index. - -## Manual QA Test Plan -### 1. Legacy pre-migration +- [Introduction](#introduction) +- [Algorithm steps](#algorithm-steps) + - [INIT](#init) + - [CREATE_NEW_TARGET](#create_new_target) + - [LEGACY_SET_WRITE_BLOCK](#legacy_set_write_block) + - [LEGACY_CREATE_REINDEX_TARGET](#legacy_create_reindex_target) + - [LEGACY_REINDEX](#legacy_reindex) + - [LEGACY_REINDEX_WAIT_FOR_TASK](#legacy_reindex_wait_for_task) + - [LEGACY_DELETE](#legacy_delete) + - [WAIT_FOR_YELLOW_SOURCE](#wait_for_yellow_source) + - [SET_SOURCE_WRITE_BLOCK](#set_source_write_block) + - [CREATE_REINDEX_TEMP](#create_reindex_temp) + - [REINDEX_SOURCE_TO_TEMP_OPEN_PIT](#reindex_source_to_temp_open_pit) + - [REINDEX_SOURCE_TO_TEMP_READ](#reindex_source_to_temp_read) + - [REINDEX_SOURCE_TO_TEMP_INDEX](#reindex_source_to_temp_index) + - [REINDEX_SOURCE_TO_TEMP_CLOSE_PIT](#reindex_source_to_temp_close_pit) + - [SET_TEMP_WRITE_BLOCK](#set_temp_write_block) + - [CLONE_TEMP_TO_TARGET](#clone_temp_to_target) + - [OUTDATED_DOCUMENTS_SEARCH](#outdated_documents_search) + - [OUTDATED_DOCUMENTS_TRANSFORM](#outdated_documents_transform) + - [UPDATE_TARGET_MAPPINGS](#update_target_mappings) + - [UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK](#update_target_mappings_wait_for_task) + - [MARK_VERSION_INDEX_READY_CONFLICT](#mark_version_index_ready_conflict) +- [Manual QA Test Plan](#manual-qa-test-plan) + - [1. Legacy pre-migration](#1-legacy-pre-migration) + - [2. Plugins enabled/disabled](#2-plugins-enableddisabled) + - [Test scenario 1 (enable a plugin after migration):](#test-scenario-1-enable-a-plugin-after-migration) + - [Test scenario 2 (disable a plugin after migration):](#test-scenario-2-disable-a-plugin-after-migration) + - [Test scenario 3 (multiple instances, enable a plugin after migration):](#test-scenario-3-multiple-instances-enable-a-plugin-after-migration) + - [Test scenario 4 (multiple instances, mixed plugin enabled configs):](#test-scenario-4-multiple-instances-mixed-plugin-enabled-configs) + +# Introduction +In the past, the risk of downtime caused by Kibana's saved object upgrade +migrations have discouraged users from adopting the latest features. v2 +migrations aims to solve this problem by minimizing the operational impact on +our users. + +To achieve this it uses a new migration algorithm where every step of the +algorithm is idempotent. No matter at which step a Kibana instance gets +interrupted, it can always restart the migration from the beginning and repeat +all the steps without requiring any user intervention. This doesn't mean +migrations will never fail, but when they fail for intermittent reasons like +an Elasticsearch cluster running out of heap, Kibana will automatically be +able to successfully complete the migration once the cluster has enough heap. + +For more background information on the problem see the [saved object +migrations +RFC](https://github.com/elastic/kibana/blob/master/rfcs/text/0013_saved_object_migrations.md). + +# Algorithm steps +The design goals for the algorithm was to keep downtime below 10 minutes for +100k saved objects while guaranteeing no data loss and keeping steps as simple +and explicit as possible. + +The algorithm is implemented as a state-action machine based on https://www.microsoft.com/en-us/research/uploads/prod/2016/12/Computation-and-State-Machines.pdf + +The state-action machine defines it's behaviour in steps. Each step is a +transition from a control state s_i to the contral state s_i+1 caused by an +action a_i. + +``` +s_i -> a_i -> s_i+1 +s_i+1 -> a_i+1 -> s_i+2 +``` + +Given a control state s1, `next(s1)` returns the next action to execute. +Actions are asynchronous, once the action resolves, we can use the action +response to determine the next state to transition to as defined by the +function `model(state, response)`. + +We can then loosely define a step as: +``` +s_i+1 = model(s_i, await next(s_i)()) +``` + +When there are no more actions returned by `next` the state-action machine +terminates such as in the DONE and FATAL control states. + +What follows is a list of all control states. For each control state the +following is described: + - _next action_: the next action triggered by the current control state + - _new control state_: based on the action response, the possible new control states that the machine will transition to + +Since the algorithm runs once for each saved object index the steps below +always reference a single saved object index `.kibana`. When Kibana starts up, +all the steps are also repeated for the `.kibana_task_manager` index but this +is left out of the description for brevity. + +## INIT +### Next action +`fetchIndices` + +Fetch the saved object indices, mappings and aliases to find the source index +and determine whether we’re migrating from a legacy index or a v1 migrations +index. + +### New control state +1. If `.kibana` and the version specific aliases both exists and are pointing +to the same index. This version's migration has already been completed. Since +the same version could have plugins enabled at any time that would introduce +new transforms or mappings. + → `OUTDATED_DOCUMENTS_SEARCH` + +2. If `.kibana` is pointing to an index that belongs to a later version of +Kibana .e.g. a 7.11.0 instance found the `.kibana` alias pointing to +`.kibana_7.12.0_001` fail the migration + → `FATAL` + +3. If the `.kibana` alias exists we’re migrating from either a v1 or v2 index +and the migration source index is the index the `.kibana` alias points to. + → `WAIT_FOR_YELLOW_SOURCE` + +4. If `.kibana` is a concrete index, we’re migrating from a legacy index + → `LEGACY_SET_WRITE_BLOCK` + +5. If there are no `.kibana` indices, this is a fresh deployment. Initialize a + new saved objects index + → `CREATE_NEW_TARGET` + +## CREATE_NEW_TARGET +### Next action +`createIndex` + +Create the target index. This operation is idempotent, if the index already exist, we wait until its status turns yellow + +### New control state + → `MARK_VERSION_INDEX_READY` + +## LEGACY_SET_WRITE_BLOCK +### Next action +`setWriteBlock` + +Set a write block on the legacy index to prevent any older Kibana instances +from writing to the index while the migration is in progress which could cause +lost acknowledged writes. + +This is the first of a series of `LEGACY_*` control states that will: + - reindex the concrete legacy `.kibana` index into a `.kibana_pre6.5.0_001` index + - delete the concrete `.kibana` _index_ so that we're able to create a `.kibana` _alias_ + +### New control state +1. If the write block was successfully added + → `LEGACY_CREATE_REINDEX_TARGET` +2. If the write block failed because the index doesn't exist, it means another instance already completed the legacy pre-migration. Proceed to the next step. + → `LEGACY_CREATE_REINDEX_TARGET` + +## LEGACY_CREATE_REINDEX_TARGET +### Next action +`createIndex` + +Create a new `.kibana_pre6.5.0_001` index into which we can reindex the legacy +index. (Since the task manager index was converted from a data index into a +saved objects index in 7.4 it will be reindexed into `.kibana_pre7.4.0_001`) +### New control state + → `LEGACY_REINDEX` + +## LEGACY_REINDEX +### Next action +`reindex` + +Let Elasticsearch reindex the legacy index into `.kibana_pre6.5.0_001`. (For +the task manager index we specify a `preMigrationScript` to convert the +original task manager documents into valid saved objects) +### New control state + → `LEGACY_REINDEX_WAIT_FOR_TASK` + + +## LEGACY_REINDEX_WAIT_FOR_TASK +### Next action +`waitForReindexTask` + +Wait for up to 60s for the reindex task to complete. +### New control state +1. If the reindex task completed + → `LEGACY_DELETE` +2. If the reindex task failed with a `target_index_had_write_block` or + `index_not_found_exception` another instance already completed this step + → `LEGACY_DELETE` +3. If the reindex task is still in progress + → `LEGACY_REINDEX_WAIT_FOR_TASK` + +## LEGACY_DELETE +### Next action +`updateAliases` + +Use the updateAliases API to atomically remove the legacy index and create a +new `.kibana` alias that points to `.kibana_pre6.5.0_001`. +### New control state +1. If the action succeeds + → `SET_SOURCE_WRITE_BLOCK` +2. If the action fails with `remove_index_not_a_concrete_index` or + `index_not_found_exception` another instance has already completed this step. + → `SET_SOURCE_WRITE_BLOCK` + +## WAIT_FOR_YELLOW_SOURCE +### Next action +`waitForIndexStatusYellow` + +Wait for the Elasticsearch cluster to be in "yellow" state. It means the index's primary shard is allocated and the index is ready for searching/indexing documents, but ES wasn't able to allocate the replicas. +We don't have as much data redundancy as we could have, but it's enough to start the migration. + +### New control state + → `SET_SOURCE_WRITE_BLOCK` + +## SET_SOURCE_WRITE_BLOCK +### Next action +`setWriteBlock` + +Set a write block on the source index to prevent any older Kibana instances from writing to the index while the migration is in progress which could cause lost acknowledged writes. + +### New control state + → `CREATE_REINDEX_TEMP` + +## CREATE_REINDEX_TEMP +### Next action +`createIndex` + +This operation is idempotent, if the index already exist, we wait until its status turns yellow. + +- Because we will be transforming documents before writing them into this index, we can already set the mappings to the target mappings for this version. The source index might contain documents belonging to a disabled plugin. So set `dynamic: false` mappings for any unknown saved object types. +- (Since we never query the temporary index we can potentially disable refresh to speed up indexing performance. Profile to see if gains justify complexity) + +### New control state + → `REINDEX_SOURCE_TO_TEMP_OPEN_PIT` + +## REINDEX_SOURCE_TO_TEMP_OPEN_PIT +### Next action +`openPIT` + +Open a PIT. Since there is a write block on the source index there is basically no overhead to keeping the PIT so we can lean towards a larger `keep_alive` value like 10 minutes. +### New control state + → `REINDEX_SOURCE_TO_TEMP_READ` + +## REINDEX_SOURCE_TO_TEMP_READ +### Next action +`readNextBatchOfSourceDocuments` + +Read the next batch of outdated documents from the source index by using search after with our PIT. + +### New control state +1. If the batch contained > 0 documents + → `REINDEX_SOURCE_TO_TEMP_INDEX` +2. If there are no more documents returned + → `REINDEX_SOURCE_TO_TEMP_CLOSE_PIT` + +## REINDEX_SOURCE_TO_TEMP_INDEX +### Next action +`transformRawDocs` + `bulkIndexTransformedDocuments` + +1. Transform the current batch of documents +2. Use the bulk API create action to write a batch of up-to-date documents. The create action ensures that there will be only one write per reindexed document even if multiple Kibana instances are performing this step. Ignore any create errors because of documents that already exist in the temporary index. Use `refresh=false` to speed up the create actions, the `UPDATE_TARGET_MAPPINGS` step will ensure that the index is refreshed before we start serving traffic. + +In order to support sharing saved objects to multiple spaces in 8.0, the +transforms will also regenerate document `_id`'s. To ensure that this step +remains idempotent, the new `_id` is deterministically generated using UUIDv5 +ensuring that each Kibana instance generates the same new `_id` for the same document. +### New control state + → `REINDEX_SOURCE_TO_TEMP_READ` + +## REINDEX_SOURCE_TO_TEMP_CLOSE_PIT +### Next action +`closePIT` + +### New control state + → `SET_TEMP_WRITE_BLOCK` + +## SET_TEMP_WRITE_BLOCK +### Next action +`setWriteBlock` + +Set a write block on the temporary index so that we can clone it. +### New control state + → `CLONE_TEMP_TO_TARGET` + +## CLONE_TEMP_TO_TARGET +### Next action +`cloneIndex` + +Ask elasticsearch to clone the temporary index into the target index. If the target index already exists (because another node already started the clone operation), wait until the clone is complete by waiting for a yellow index status. + +We can’t use the temporary index as our target index because one instance can complete the migration, delete a document, and then a second instance starts the reindex operation and re-creates the deleted document. By cloning the temporary index and only accepting writes/deletes from the cloned target index, we prevent lost acknowledged deletes. + +### New control state + → `OUTDATED_DOCUMENTS_SEARCH` + +## OUTDATED_DOCUMENTS_SEARCH +### Next action +`searchForOutdatedDocuments` + +Search for outdated saved object documents. Will return one batch of +documents. + +If another instance has a disabled plugin it will reindex that plugin's +documents without transforming them. Because this instance doesn't know which +plugins were disabled by the instance that performed the +`REINDEX_SOURCE_TO_TEMP_INDEX` step, we need to search for outdated documents +and transform them to ensure that everything is up to date. + +### New control state +1. Found outdated documents? + → `OUTDATED_DOCUMENTS_TRANSFORM` +2. All documents up to date + → `UPDATE_TARGET_MAPPINGS` + +## OUTDATED_DOCUMENTS_TRANSFORM +### Next action +`transformRawDocs` + `bulkOverwriteTransformedDocuments` + +Once transformed we use an index operation to overwrite the outdated document with the up-to-date version. Optimistic concurrency control ensures that we only overwrite the document once so that any updates/writes by another instance which already completed the migration aren’t overwritten and lost. + +### New control state + → `OUTDATED_DOCUMENTS_SEARCH` + +## UPDATE_TARGET_MAPPINGS +### Next action +`updateAndPickupMappings` + +If another instance has some plugins disabled it will disable the mappings of that plugin's types when creating the temporary index. This action will +update the mappings and then use an update_by_query to ensure that all fields are “picked-up” and ready to be searched over. + +### New control state + → `UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK` + +## UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK +### Next action +`updateAliases` + +Atomically apply the `versionIndexReadyActions` using the _alias actions API. By performing the following actions we guarantee that if multiple versions of Kibana started the upgrade in parallel, only one version will succeed. + +1. verify that the current alias is still pointing to the source index +2. Point the version alias and the current alias to the target index. +3. Remove the temporary index + +### New control state +1. If all the actions succeed we’re ready to serve traffic + → `DONE` +2. If action (1) fails with alias_not_found_exception or action (3) fails with index_not_found_exception another instance already completed the migration + → `MARK_VERSION_INDEX_READY_CONFLICT` + +## MARK_VERSION_INDEX_READY_CONFLICT +### Next action +`fetchIndices` + +Fetch the saved object indices + +### New control state +If another instance completed a migration from the same source we need to verify that it is running the same version. + +1. If the current and version aliases are pointing to the same index the instance that completed the migration was on the same version and it’s safe to start serving traffic. + → `DONE` +2. If the other instance was running a different version we fail the migration. Once we restart one of two things can happen: the other instance is an older version and we will restart the migration, or, it’s a newer version and we will refuse to start up. + → `FATAL` + +# Manual QA Test Plan +## 1. Legacy pre-migration When upgrading from a legacy index additional steps are required before the regular migration process can start. @@ -45,7 +386,7 @@ Test plan: get restarted. Given enough time, it should always be able to successfully complete the migration. -For a successful migration the following behaviour should be observed: +For a successful migration the following behaviour should be observed: 1. The `.kibana` index should be reindexed into a `.kibana_pre6.5.0` index 2. The `.kibana` index should be deleted 3. The `.kibana_index_template` should be deleted @@ -54,12 +395,12 @@ For a successful migration the following behaviour should be observed: 6. Once migration has completed, the `.kibana_current` and `.kibana_7.11.0` aliases should point to the `.kibana_7.11.0_001` index. -### 2. Plugins enabled/disabled +## 2. Plugins enabled/disabled Kibana plugins can be disabled/enabled at any point in time. We need to ensure that Saved Object documents are migrated for all the possible sequences of enabling, disabling, before or after a version upgrade. -#### Test scenario 1 (enable a plugin after migration): +### Test scenario 1 (enable a plugin after migration): 1. Start an old version of Kibana (< 7.11) 2. Create a document that we know will be migrated in a later version (i.e. create a `dashboard`) @@ -70,7 +411,7 @@ enabling, disabling, before or after a version upgrade. 7. Ensure that the document from step (2) has been migrated (`migrationVersion` contains 7.11.0) -#### Test scenario 2 (disable a plugin after migration): +### Test scenario 2 (disable a plugin after migration): 1. Start an old version of Kibana (< 7.11) 2. Create a document that we know will be migrated in a later version (i.e. create a `dashboard`) @@ -80,11 +421,11 @@ enabling, disabling, before or after a version upgrade. 7. Ensure that Kibana logs a warning, but continues to start even though there are saved object documents which don't belong to an enable plugin -#### Test scenario 2 (multiple instances, enable a plugin after migration): +### Test scenario 3 (multiple instances, enable a plugin after migration): Follow the steps from 'Test scenario 1', but perform the migration with multiple instances of Kibana -#### Test scenario 3 (multiple instances, mixed plugin enabled configs): +### Test scenario 4 (multiple instances, mixed plugin enabled configs): We don't support this upgrade scenario, but it's worth making sure we don't have data loss when there's a user error. 1. Start an old version of Kibana (< 7.11) @@ -97,4 +438,3 @@ have data loss when there's a user error. 5. Ensure that the document from step (2) has been migrated (`migrationVersion` contains 7.11.0) -### \ No newline at end of file diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index a3f043a5e2657..8e246b625706e 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -352,6 +352,13 @@ describe('SearchSource', () => { const request = searchSource.getSearchRequestBody(); expect(request.stored_fields).toEqual(['*']); }); + + test('_source is not set when using the fields API', async () => { + searchSource.setField('fields', ['*']); + const request = searchSource.getSearchRequestBody(); + expect(request.fields).toEqual(['*']); + expect(request._source).toEqual(false); + }); }); describe('source filters handling', () => { diff --git a/src/plugins/discover/public/application/helpers/update_search_source.test.ts b/src/plugins/discover/public/application/helpers/update_search_source.test.ts index 97e2de3541d35..d4e52c4e7d4fe 100644 --- a/src/plugins/discover/public/application/helpers/update_search_source.test.ts +++ b/src/plugins/discover/public/application/helpers/update_search_source.test.ts @@ -136,4 +136,34 @@ describe('updateSearchSource', () => { ]); expect(volatileSearchSourceMock.getField('fieldsFromSource')).toBe(undefined); }); + + test('does not explicitly request fieldsFromSource when not using fields API', async () => { + const persistentSearchSourceMock = createSearchSourceMock({}); + const volatileSearchSourceMock = createSearchSourceMock({}); + const sampleSize = 250; + updateSearchSource({ + persistentSearchSource: persistentSearchSourceMock, + volatileSearchSource: volatileSearchSourceMock, + indexPattern: indexPatternMock, + services: ({ + data: dataPluginMock.createStartContract(), + uiSettings: ({ + get: (key: string) => { + if (key === SAMPLE_SIZE_SETTING) { + return sampleSize; + } + return false; + }, + } as unknown) as IUiSettingsClient, + } as unknown) as DiscoverServices, + sort: [] as SortOrder[], + columns: [], + useNewFieldsApi: false, + showUnmappedFields: false, + }); + expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock); + expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize); + expect(volatileSearchSourceMock.getField('fields')).toEqual(undefined); + expect(volatileSearchSourceMock.getField('fieldsFromSource')).toBe(undefined); + }); }); diff --git a/src/plugins/discover/public/application/helpers/update_search_source.ts b/src/plugins/discover/public/application/helpers/update_search_source.ts index ba5ac0e822796..07529ac8cb0d6 100644 --- a/src/plugins/discover/public/application/helpers/update_search_source.ts +++ b/src/plugins/discover/public/application/helpers/update_search_source.ts @@ -65,8 +65,6 @@ export function updateSearchSource({ volatileSearchSource.setField('fields', [fields]); } else { volatileSearchSource.removeField('fields'); - const fieldNames = indexPattern.fields.map((field) => field.name); - volatileSearchSource.setField('fieldsFromSource', fieldNames); } } } diff --git a/src/plugins/telemetry/public/index.ts b/src/plugins/telemetry/public/index.ts index aef955e228dd3..8d1747d9c33f1 100644 --- a/src/plugins/telemetry/public/index.ts +++ b/src/plugins/telemetry/public/index.ts @@ -6,10 +6,15 @@ * Side Public License, v 1. */ -import { PluginInitializerContext } from 'kibana/public'; -import { TelemetryPlugin, TelemetryPluginConfig } from './plugin'; -export type { TelemetryPluginStart, TelemetryPluginSetup, TelemetryPluginConfig } from './plugin'; -export type { TelemetryNotifications, TelemetryService } from './services'; +import type { PluginInitializerContext } from 'src/core/public'; +import type { TelemetryPluginConfig } from './plugin'; +import { TelemetryPlugin } from './plugin'; +export type { + TelemetryPluginStart, + TelemetryPluginSetup, + TelemetryPluginConfig, + TelemetryServicePublicApis, +} from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { return new TelemetryPlugin(initializerContext); diff --git a/src/plugins/telemetry/public/plugin.ts b/src/plugins/telemetry/public/plugin.ts index f7af01f0190ae..5e85fa7ea2d51 100644 --- a/src/plugins/telemetry/public/plugin.ts +++ b/src/plugins/telemetry/public/plugin.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { +import type { Plugin, CoreStart, CoreSetup, @@ -15,10 +15,10 @@ import { SavedObjectsClientContract, SavedObjectsBatchResponse, ApplicationStart, -} from '../../../core/public'; +} from 'src/core/public'; import { TelemetrySender, TelemetryService, TelemetryNotifications } from './services'; -import { +import type { TelemetrySavedObjectAttributes, TelemetrySavedObject, } from '../common/telemetry_config/types'; @@ -30,27 +30,73 @@ import { import { getNotifyUserAboutOptInDefault } from '../common/telemetry_config/get_telemetry_notify_user_about_optin_default'; import { PRIVACY_STATEMENT_URL } from '../common/constants'; +/** + * Publicly exposed APIs from the Telemetry Service + */ +export interface TelemetryServicePublicApis { + /** Is the cluster opted-in to telemetry? **/ + getIsOptedIn: () => boolean | null; + /** Is the user allowed to change the opt-in/out status? **/ + userCanChangeSettings: boolean; + /** Is the cluster allowed to change the opt-in/out status? **/ + getCanChangeOptInStatus: () => boolean; + /** Fetches an unencrypted telemetry payload so we can show it to the user **/ + fetchExample: () => Promise; + /** + * Overwrite the opt-in status. + * It will send a final request to the remote telemetry cluster to report about the opt-in/out change. + * @param optedIn Whether the user is opting-in (`true`) or out (`false`). + */ + setOptIn: (optedIn: boolean) => Promise; +} + +/** + * Public's setup exposed APIs by the telemetry plugin + */ export interface TelemetryPluginSetup { - telemetryService: TelemetryService; + /** {@link TelemetryService} **/ + telemetryService: TelemetryServicePublicApis; } +/** + * Public's start exposed APIs by the telemetry plugin + */ export interface TelemetryPluginStart { - telemetryService: TelemetryService; - telemetryNotifications: TelemetryNotifications; + /** {@link TelemetryServicePublicApis} **/ + telemetryService: TelemetryServicePublicApis; + /** Notification helpers **/ + telemetryNotifications: { + /** Notify that the user has been presented with the opt-in/out notice. */ + setOptedInNoticeSeen: () => Promise; + }; + /** Set of publicly exposed telemetry constants **/ telemetryConstants: { + /** Elastic's privacy statement url **/ getPrivacyStatementUrl: () => string; }; } +/** + * Public-exposed configuration + */ export interface TelemetryPluginConfig { + /** Is the plugin enabled? **/ enabled: boolean; + /** Remote telemetry service's URL **/ url: string; + /** The banner is expected to be shown when needed **/ banner: boolean; + /** Does the cluster allow changing the opt-in/out status via the UI? **/ allowChangingOptInStatus: boolean; + /** Is the cluster opted-in? **/ optIn: boolean | null; + /** Opt-in/out notification URL **/ optInStatusUrl: string; + /** Should the telemetry payloads be sent from the server or the browser? **/ sendUsageFrom: 'browser' | 'server'; + /** Should notify the user about the opt-in status? **/ telemetryNotifyUserAboutOptInDefault?: boolean; + /** Does the user have enough privileges to change the settings? **/ userCanChangeSettings?: boolean; } @@ -80,7 +126,7 @@ export class TelemetryPlugin implements Plugin { const isUnauthenticated = this.getIsUnauthenticated(http); @@ -119,14 +166,27 @@ export class TelemetryPlugin implements Plugin telemetryNotifications.setOptedInNoticeSeen(), + }, telemetryConstants: { getPrivacyStatementUrl: () => PRIVACY_STATEMENT_URL, }, }; } + private getTelemetryServicePublicApis(): TelemetryServicePublicApis { + const telemetryService = this.telemetryService!; + return { + getIsOptedIn: () => telemetryService.getIsOptedIn(), + setOptIn: (optedIn) => telemetryService.setOptIn(optedIn), + userCanChangeSettings: telemetryService.userCanChangeSettings, + getCanChangeOptInStatus: () => telemetryService.getCanChangeOptInStatus(), + fetchExample: () => telemetryService.fetchExample(), + }; + } + /** * Can the user edit the saved objects? * This is a security feature, not included in the OSS build, so we need to fallback to `true` diff --git a/src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts b/src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts index 5caf68b1981ea..0070cf7452767 100644 --- a/src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts +++ b/src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts @@ -17,6 +17,9 @@ interface TelemetryNotificationsConstructor { telemetryService: TelemetryService; } +/** + * Helpers to the Telemetry banners spread through the code base in Welcome and Home landing pages. + */ export class TelemetryNotifications { private readonly http: CoreStart['http']; private readonly overlays: CoreStart['overlays']; @@ -30,12 +33,18 @@ export class TelemetryNotifications { this.overlays = overlays; } + /** + * Should the opted-in banner be shown to the user? + */ public shouldShowOptedInNoticeBanner = (): boolean => { const userShouldSeeOptInNotice = this.telemetryService.getUserShouldSeeOptInNotice(); const bannerOnScreen = typeof this.optedInNoticeBannerId !== 'undefined'; return !bannerOnScreen && userShouldSeeOptInNotice; }; + /** + * Renders the banner that claims the cluster is opted-in, and gives the option to opt-out. + */ public renderOptedInNoticeBanner = (): void => { const bannerId = renderOptedInNoticeBanner({ http: this.http, @@ -46,12 +55,18 @@ export class TelemetryNotifications { this.optedInNoticeBannerId = bannerId; }; + /** + * Should the banner to opt-in be shown to the user? + */ public shouldShowOptInBanner = (): boolean => { const isOptedIn = this.telemetryService.getIsOptedIn(); const bannerOnScreen = typeof this.optInBannerId !== 'undefined'; return !bannerOnScreen && isOptedIn === null; }; + /** + * Renders the banner that claims the cluster is opted-out, and gives the option to opt-in. + */ public renderOptInBanner = (): void => { const bannerId = renderOptInBanner({ setOptIn: this.onSetOptInClick, @@ -61,6 +76,10 @@ export class TelemetryNotifications { this.optInBannerId = bannerId; }; + /** + * Opt-in/out button handler + * @param isOptIn true/false whether the user opts-in/out + */ private onSetOptInClick = async (isOptIn: boolean) => { if (this.optInBannerId) { this.overlays.banners.remove(this.optInBannerId); @@ -70,6 +89,9 @@ export class TelemetryNotifications { await this.telemetryService.setOptIn(isOptIn); }; + /** + * Clears the banner and stores the user's dismissal of the banner. + */ public setOptedInNoticeSeen = async (): Promise => { if (this.optedInNoticeBannerId) { this.overlays.banners.remove(this.optedInNoticeBannerId); diff --git a/src/plugins/telemetry/public/services/telemetry_sender.test.ts b/src/plugins/telemetry/public/services/telemetry_sender.test.ts index 82dbdb49f38f5..4dd1fe37a7569 100644 --- a/src/plugins/telemetry/public/services/telemetry_sender.test.ts +++ b/src/plugins/telemetry/public/services/telemetry_sender.test.ts @@ -71,20 +71,20 @@ describe('TelemetrySender', () => { const telemetryService = mockTelemetryService(); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(false); const telemetrySender = new TelemetrySender(telemetryService); - const shouldSendRerpot = telemetrySender['shouldSendReport'](); + const shouldSendReport = telemetrySender['shouldSendReport'](); expect(telemetryService.getIsOptedIn).toBeCalledTimes(1); - expect(shouldSendRerpot).toBe(false); + expect(shouldSendReport).toBe(false); }); it('returns true if lastReported is undefined', () => { const telemetryService = mockTelemetryService(); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); const telemetrySender = new TelemetrySender(telemetryService); - const shouldSendRerpot = telemetrySender['shouldSendReport'](); + const shouldSendReport = telemetrySender['shouldSendReport'](); expect(telemetrySender['lastReported']).toBeUndefined(); - expect(shouldSendRerpot).toBe(true); + expect(shouldSendReport).toBe(true); }); it('returns true if lastReported passed REPORT_INTERVAL_MS', () => { @@ -94,8 +94,8 @@ describe('TelemetrySender', () => { telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); const telemetrySender = new TelemetrySender(telemetryService); telemetrySender['lastReported'] = `${lastReported}`; - const shouldSendRerpot = telemetrySender['shouldSendReport'](); - expect(shouldSendRerpot).toBe(true); + const shouldSendReport = telemetrySender['shouldSendReport'](); + expect(shouldSendReport).toBe(true); }); it('returns false if lastReported is within REPORT_INTERVAL_MS', () => { @@ -105,8 +105,8 @@ describe('TelemetrySender', () => { telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); const telemetrySender = new TelemetrySender(telemetryService); telemetrySender['lastReported'] = `${lastReported}`; - const shouldSendRerpot = telemetrySender['shouldSendReport'](); - expect(shouldSendRerpot).toBe(false); + const shouldSendReport = telemetrySender['shouldSendReport'](); + expect(shouldSendReport).toBe(false); }); it('returns true if lastReported is malformed', () => { @@ -114,8 +114,8 @@ describe('TelemetrySender', () => { telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); const telemetrySender = new TelemetrySender(telemetryService); telemetrySender['lastReported'] = `random_malformed_string`; - const shouldSendRerpot = telemetrySender['shouldSendReport'](); - expect(shouldSendRerpot).toBe(true); + const shouldSendReport = telemetrySender['shouldSendReport'](); + expect(shouldSendReport).toBe(true); }); describe('sendIfDue', () => { diff --git a/src/plugins/telemetry/public/services/telemetry_service.ts b/src/plugins/telemetry/public/services/telemetry_service.ts index a3232a42d6b73..4ae2956902092 100644 --- a/src/plugins/telemetry/public/services/telemetry_service.ts +++ b/src/plugins/telemetry/public/services/telemetry_service.ts @@ -18,6 +18,10 @@ interface TelemetryServiceConstructor { reportOptInStatusChange?: boolean; } +/** + * Handles caching telemetry config in the user's session and requests the + * backend to fetch telemetry payload requests or notify about config changes. + */ export class TelemetryService { private readonly http: CoreStart['http']; private readonly reportOptInStatusChange: boolean; @@ -25,6 +29,7 @@ export class TelemetryService { private readonly defaultConfig: TelemetryPluginConfig; private updatedConfig?: TelemetryPluginConfig; + /** Current version of Kibana */ public readonly currentKibanaVersion: string; constructor({ @@ -41,40 +46,54 @@ export class TelemetryService { this.http = http; } + /** + * Config setter to locally persist the updated configuration. + * Useful for caching the configuration throughout the users' session, + * so they don't need to refresh the page. + * @param updatedConfig + */ public set config(updatedConfig: TelemetryPluginConfig) { this.updatedConfig = updatedConfig; } + /** Returns the latest configuration **/ public get config() { return { ...this.defaultConfig, ...this.updatedConfig }; } + /** Is the cluster opted-in to telemetry **/ public get isOptedIn() { return this.config.optIn; } + /** Changes the opt-in status **/ public set isOptedIn(optIn) { this.config = { ...this.config, optIn }; } + /** true if the user has already seen the opt-in/out notice **/ public get userHasSeenOptedInNotice() { return this.config.telemetryNotifyUserAboutOptInDefault; } + /** Changes the notice visibility options **/ public set userHasSeenOptedInNotice(telemetryNotifyUserAboutOptInDefault) { this.config = { ...this.config, telemetryNotifyUserAboutOptInDefault }; } + /** Is the cluster allowed to change the opt-in/out status **/ public getCanChangeOptInStatus = () => { const allowChangingOptInStatus = this.config.allowChangingOptInStatus; return allowChangingOptInStatus; }; + /** Retrieve the opt-in/out notification URL **/ public getOptInStatusUrl = () => { const telemetryOptInStatusUrl = this.config.optInStatusUrl; return telemetryOptInStatusUrl; }; + /** Retrieve the URL to report telemetry **/ public getTelemetryUrl = () => { const telemetryUrl = this.config.url; return telemetryUrl; @@ -92,22 +111,30 @@ export class TelemetryService { ); } + /** Is the user allowed to change the opt-in/out status **/ public get userCanChangeSettings() { return this.config.userCanChangeSettings ?? false; } + /** Change the user's permissions to change the opt-in/out status **/ public set userCanChangeSettings(userCanChangeSettings: boolean) { this.config = { ...this.config, userCanChangeSettings }; } + /** Is the cluster opted-in to telemetry **/ public getIsOptedIn = () => { return this.isOptedIn; }; + /** Fetches an unencrypted telemetry payload so we can show it to the user **/ public fetchExample = async () => { return await this.fetchTelemetry({ unencrypted: true }); }; + /** + * Fetches telemetry payload + * @param unencrypted Default `false`. Whether the returned payload should be encrypted or not. + */ public fetchTelemetry = async ({ unencrypted = false } = {}) => { return this.http.post('/api/telemetry/v2/clusters/_stats', { body: JSON.stringify({ @@ -116,6 +143,11 @@ export class TelemetryService { }); }; + /** + * Overwrite the opt-in status. + * It will send a final request to the remote telemetry cluster to report about the opt-in/out change. + * @param optedIn Whether the user is opting-in (`true`) or out (`false`). + */ public setOptIn = async (optedIn: boolean): Promise => { const canChangeOptInStatus = this.getCanChangeOptInStatus(); if (!canChangeOptInStatus) { @@ -150,6 +182,9 @@ export class TelemetryService { return true; }; + /** + * Discards the notice about usage collection and stores it so we don't bother any other users. + */ public setUserHasSeenNotice = async (): Promise => { try { await this.http.put('/api/telemetry/v2/userHasSeenNotice'); diff --git a/src/plugins/telemetry/server/index.ts b/src/plugins/telemetry/server/index.ts index 005f50721e778..530f7c499c3f2 100644 --- a/src/plugins/telemetry/server/index.ts +++ b/src/plugins/telemetry/server/index.ts @@ -8,10 +8,8 @@ import { PluginInitializerContext, PluginConfigDescriptor } from 'kibana/server'; import { TelemetryPlugin } from './plugin'; -import * as constants from '../common/constants'; import { configSchema, TelemetryConfigType } from './config'; -export { handleOldSettings } from './handle_old_settings'; export type { TelemetryPluginSetup, TelemetryPluginStart } from './plugin'; export const config: PluginConfigDescriptor = { @@ -29,18 +27,12 @@ export const config: PluginConfigDescriptor = { export const plugin = (initializerContext: PluginInitializerContext) => new TelemetryPlugin(initializerContext); -export { constants }; -export { - getClusterUuids, - getLocalStats, - DATA_TELEMETRY_ID, - buildDataTelemetryPayload, -} from './telemetry_collection'; +export { getClusterUuids, getLocalStats } from './telemetry_collection'; export type { TelemetryLocalStats, - DataTelemetryIndex, DataTelemetryPayload, + DataTelemetryDocument, + DataTelemetryBasePayload, NodeUsage, - NodeUsageAggregation, } from './telemetry_collection'; diff --git a/src/plugins/telemetry/server/plugin.ts b/src/plugins/telemetry/server/plugin.ts index 46b7bc89ca6f9..40714bf4cf2be 100644 --- a/src/plugins/telemetry/server/plugin.ts +++ b/src/plugins/telemetry/server/plugin.ts @@ -45,6 +45,9 @@ interface TelemetryPluginsDepsStart { telemetryCollectionManager: TelemetryCollectionManagerPluginStart; } +/** + * Server's setup exposed APIs by the telemetry plugin + */ export interface TelemetryPluginSetup { /** * Resolves into the telemetry Url used to send telemetry. @@ -53,6 +56,9 @@ export interface TelemetryPluginSetup { getTelemetryUrl: () => Promise; } +/** + * Server's start exposed APIs by the telemetry plugin + */ export interface TelemetryPluginStart { /** * Resolves `true` if the user has opted into send Elastic usage data. diff --git a/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts b/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts index 122fee5667bdf..dd5f4f97c6b02 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts @@ -21,6 +21,8 @@ export async function getClusterStats(esClient: ElasticsearchClient) { /** * Get the cluster uuids from the connected cluster. + * @internal only used externally by the X-Pack Telemetry extension + * @param esClient Scoped Elasticsearch client */ export const getClusterUuids: ClusterDetailsGetter = async ({ esClient }) => { const { body } = await esClient.cluster.stats({ timeout: TIMEOUT }); diff --git a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts index c79c46072e11b..8a0b86cf3b0f0 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts @@ -14,25 +14,45 @@ import { DataTelemetryType, } from './constants'; +/** + * Common counters for the {@link DataTelemetryDocument}s + */ export interface DataTelemetryBasePayload { + /** How many indices match the declared pattern **/ index_count: number; + /** How many indices match the declared pattern follow ECS conventions **/ ecs_index_count?: number; + /** How many documents are among all the identified indices **/ doc_count?: number; + /** Total size in bytes among all the identified indices **/ size_in_bytes?: number; } +/** + * Depending on the type of index, we'll populate different keys as we identify them. + */ export interface DataTelemetryDocument extends DataTelemetryBasePayload { + /** For data-stream indices. Reporting their details **/ data_stream?: { + /** Name of the dataset in the data-stream **/ dataset?: string; + /** Type of the data-stream: "logs", "metrics", "traces" **/ type?: DataTelemetryType | string; // The union of types is to help autocompletion with some known `data_stream.type`s }; + /** When available, reporting the package details **/ package?: { + /** The package's name. Typically populated in the indices' _meta.package.name by Fleet. **/ name: string; }; + /** What's the process indexing the data? (i.e.: "beats", "logstash") **/ shipper?: string; + /** When the data comes from a matching index-pattern, the name of the pattern **/ pattern_name?: DataPatternName; } +/** + * The Data Telemetry is reported as an array of {@link DataTelemetryDocument} + */ export type DataTelemetryPayload = DataTelemetryDocument[]; export interface DataTelemetryIndex { diff --git a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/index.ts b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/index.ts index c93b7e872924b..c5219e419efe7 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/index.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/index.ts @@ -8,4 +8,8 @@ export { DATA_TELEMETRY_ID } from './constants'; export { getDataTelemetry, buildDataTelemetryPayload } from './get_data_telemetry'; -export type { DataTelemetryPayload, DataTelemetryIndex } from './get_data_telemetry'; +export type { + DataTelemetryPayload, + DataTelemetryDocument, + DataTelemetryBasePayload, +} from './get_data_telemetry'; diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts index 72f6ba855096c..7fdcb50b704af 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts @@ -48,13 +48,17 @@ export function handleLocalStats; /** * Get statistics for all products joined by Elasticsearch cluster. - * @param {Array} cluster uuids array of cluster uuid's - * @param {Object} config contains the usageCollection, callCluster (deprecated), the esClient and Saved Objects client scoped to the request or the internal repository, and the kibana request - * @param {Object} StatsCollectionContext contains logger and version (string) + * @internal only used externally by the X-Pack Telemetry extension + * @param clustersDetails uuids array of cluster uuid's + * @param config contains the usageCollection, callCluster (deprecated), the esClient and Saved Objects client scoped to the request or the internal repository, and the kibana request + * @param context contains logger and version (string) */ export const getLocalStats: StatsGetter = async ( clustersDetails, diff --git a/src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts b/src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts index 544142c8d742f..c35b8a3d24498 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts @@ -6,36 +6,22 @@ * Side Public License, v 1. */ -import { ElasticsearchClient } from 'src/core/server'; +import type { ElasticsearchClient } from 'src/core/server'; +import type { estypes } from '@elastic/elasticsearch'; import { TIMEOUT } from './constants'; -export interface NodeUsageAggregation { - [key: string]: number; -} - -// we set aggregations as an optional type because it was only added in v7.8.0 -export interface NodeUsage { - node_id?: string; - timestamp: number | string; - since: number; - rest_actions: { - [key: string]: number; - }; - aggregations?: { - [key: string]: NodeUsageAggregation; - }; -} - -export interface NodesFeatureUsageResponse { - cluster_name: string; - nodes: { - [key: string]: NodeUsage; - }; +/** + * Data returned by GET /_nodes/usage, but flattened as an array of {@link estypes.NodeUsageInformation} + * with the node ID set in the field `node_id`. + */ +export interface NodeUsage extends estypes.NodeUsageInformation { + /** + * The Node ID as reported by ES + */ + node_id: string; } -export type NodesUsageGetter = ( - esClient: ElasticsearchClient -) => Promise<{ nodes: NodeUsage[] | Array<{}> }>; +export type NodesUsageGetter = (esClient: ElasticsearchClient) => Promise<{ nodes: NodeUsage[] }>; /** * Get the nodes usage data from the connected cluster. * @@ -45,11 +31,10 @@ export type NodesUsageGetter = ( */ export async function fetchNodesUsage( esClient: ElasticsearchClient -): Promise { +): Promise { const { body } = await esClient.nodes.usage({ timeout: TIMEOUT, }); - // @ts-expect-error TODO: Does the client parse `timestamp` to a Date object? Expected a number return body; } @@ -61,7 +46,7 @@ export async function fetchNodesUsage( export const getNodesUsage: NodesUsageGetter = async (esClient) => { const result = await fetchNodesUsage(esClient); const transformedNodes = Object.entries(result?.nodes || {}).map(([key, value]) => ({ - ...(value as NodeUsage), + ...value, node_id: key, })); return { nodes: transformedNodes }; diff --git a/src/plugins/telemetry/server/telemetry_collection/index.ts b/src/plugins/telemetry/server/telemetry_collection/index.ts index f55147a0a083f..1126cbd1aa189 100644 --- a/src/plugins/telemetry/server/telemetry_collection/index.ts +++ b/src/plugins/telemetry/server/telemetry_collection/index.ts @@ -7,9 +7,13 @@ */ export { DATA_TELEMETRY_ID, buildDataTelemetryPayload } from './get_data_telemetry'; -export type { DataTelemetryIndex, DataTelemetryPayload } from './get_data_telemetry'; +export type { + DataTelemetryPayload, + DataTelemetryDocument, + DataTelemetryBasePayload, +} from './get_data_telemetry'; export { getLocalStats } from './get_local_stats'; export type { TelemetryLocalStats } from './get_local_stats'; -export type { NodeUsage, NodeUsageAggregation } from './get_nodes_usage'; +export type { NodeUsage } from './get_nodes_usage'; export { getClusterUuids } from './get_cluster_stats'; export { registerCollection } from './register_collection'; diff --git a/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts b/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts index d8ec46eba004f..8685ed3102fa6 100644 --- a/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts +++ b/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts @@ -10,13 +10,7 @@ import { get } from 'lodash'; import { getIndexPatterns } from './plugin_services'; import { TimelionFunctionArgs } from '../../common/types'; import { TimelionExpressionFunction, TimelionExpressionArgument } from '../../common/parser'; -import { - IndexPatternField, - indexPatterns as indexPatternsUtils, - KBN_FIELD_TYPES, -} from '../../../data/public'; - -const isRuntimeField = (field: IndexPatternField) => Boolean(field.runtimeField); +import { indexPatterns as indexPatternsUtils, KBN_FIELD_TYPES } from '../../../data/public'; export function getArgValueSuggestions() { const indexPatterns = getIndexPatterns(); @@ -77,7 +71,6 @@ export function getArgValueSuggestions() { .getByType(KBN_FIELD_TYPES.NUMBER) .filter( (field) => - !isRuntimeField(field) && field.aggregatable && containsFieldName(valueSplit[1], field) && !indexPatternsUtils.isNestedField(field) @@ -101,7 +94,6 @@ export function getArgValueSuggestions() { .getAll() .filter( (field) => - !isRuntimeField(field) && field.aggregatable && [ KBN_FIELD_TYPES.NUMBER, @@ -124,10 +116,7 @@ export function getArgValueSuggestions() { return indexPattern.fields .getByType(KBN_FIELD_TYPES.DATE) .filter( - (field) => - !isRuntimeField(field) && - containsFieldName(partial, field) && - !indexPatternsUtils.isNestedField(field) + (field) => containsFieldName(partial, field) && !indexPatternsUtils.isNestedField(field) ) .map((field) => ({ name: field.name, insertText: field.name })); }, diff --git a/src/plugins/vis_type_timelion/server/series_functions/es/es.test.js b/src/plugins/vis_type_timelion/server/series_functions/es/es.test.js index 3ace745604660..c2940c6d7731a 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/es/es.test.js +++ b/src/plugins/vis_type_timelion/server/series_functions/es/es.test.js @@ -120,7 +120,7 @@ describe('es', () => { }); describe('metric aggs', () => { - const emptyScriptedFields = []; + const emptyScriptFields = {}; test('adds a metric agg for each metric', () => { config.metric = [ @@ -133,7 +133,7 @@ describe('es', () => { 'percentiles:\\:bytes\\:123:20.0,50.0,100.0', 'percentiles:a:2', ]; - agg = createDateAgg(config, tlConfig, emptyScriptedFields); + agg = createDateAgg(config, tlConfig, emptyScriptFields); expect(agg.time_buckets.aggs['sum(beer)']).toEqual({ sum: { field: 'beer' } }); expect(agg.time_buckets.aggs['avg(bytes)']).toEqual({ avg: { field: 'bytes' } }); expect(agg.time_buckets.aggs['percentiles(bytes)']).toEqual({ @@ -156,14 +156,15 @@ describe('es', () => { test('adds a scripted metric agg for each scripted metric', () => { config.metric = ['avg:scriptedBytes']; - const scriptedFields = [ - { - name: 'scriptedBytes', - script: 'doc["bytes"].value', - lang: 'painless', + const scriptFields = { + scriptedBytes: { + script: { + source: 'doc["bytes"].value', + lang: 'painless', + }, }, - ]; - agg = createDateAgg(config, tlConfig, scriptedFields); + }; + agg = createDateAgg(config, tlConfig, scriptFields); expect(agg.time_buckets.aggs['avg(scriptedBytes)']).toEqual({ avg: { script: { @@ -176,14 +177,14 @@ describe('es', () => { test('has a special `count` metric that uses a script', () => { config.metric = ['count']; - agg = createDateAgg(config, tlConfig, emptyScriptedFields); + agg = createDateAgg(config, tlConfig, emptyScriptFields); expect(typeof agg.time_buckets.aggs.count.bucket_script).toBe('object'); expect(agg.time_buckets.aggs.count.bucket_script.buckets_path).toEqual('_count'); }); test('has a special `count` metric with redundant field which use a script', () => { config.metric = ['count:beer']; - agg = createDateAgg(config, tlConfig, emptyScriptedFields); + agg = createDateAgg(config, tlConfig, emptyScriptFields); expect(typeof agg.time_buckets.aggs.count.bucket_script).toBe('object'); expect(agg.time_buckets.aggs.count.bucket_script.buckets_path).toEqual('_count'); }); @@ -192,7 +193,7 @@ describe('es', () => { describe('buildRequest', () => { const fn = buildRequest; - const emptyScriptedFields = []; + const emptyScriptFields = {}; let tlConfig; let config; beforeEach(() => { @@ -206,20 +207,20 @@ describe('es', () => { test('sets the index on the request', () => { config.index = 'beer'; - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, emptyScriptFields); expect(request.params.index).toEqual('beer'); }); test('always sets body.size to 0', () => { - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, emptyScriptFields); expect(request.params.body.size).toEqual(0); }); test('creates a filters agg that contains each of the queries passed', () => { config.q = ['foo', 'bar']; - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, emptyScriptFields); expect(request.params.body.aggs.q.meta.type).toEqual('split'); @@ -231,14 +232,14 @@ describe('es', () => { describe('timeouts', () => { test('sets the timeout on the request', () => { config.index = 'beer'; - const request = fn(config, tlConfig, emptyScriptedFields, 30000); + const request = fn(config, tlConfig, emptyScriptFields, {}, 30000); expect(request.params.timeout).toEqual('30000ms'); }); test('sets no timeout if elasticsearch.shardTimeout is set to 0', () => { config.index = 'beer'; - const request = fn(config, tlConfig, emptyScriptedFields, 0); + const request = fn(config, tlConfig, emptyScriptFields, {}, 0); expect(request.params).not.toHaveProperty('timeout'); }); @@ -258,7 +259,7 @@ describe('es', () => { test('sets ignore_throttled=true on the request', () => { config.index = 'beer'; tlConfig.settings[UI_SETTINGS.SEARCH_INCLUDE_FROZEN] = false; - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, emptyScriptFields); expect(request.params.ignore_throttled).toEqual(true); }); @@ -266,7 +267,7 @@ describe('es', () => { test('sets no timeout if elasticsearch.shardTimeout is set to 0', () => { tlConfig.settings[UI_SETTINGS.SEARCH_INCLUDE_FROZEN] = true; config.index = 'beer'; - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, emptyScriptFields); expect(request.params.ignore_throttled).toEqual(false); }); @@ -301,7 +302,7 @@ describe('es', () => { test('adds the contents of body.extended.es.filter to a filter clause of the bool', () => { config.kibana = true; - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, emptyScriptFields); const filter = request.params.body.query.bool.filter.bool; expect(filter.must.length).toEqual(1); expect(filter.must_not.length).toEqual(2); @@ -309,12 +310,12 @@ describe('es', () => { test('does not include filters if config.kibana = false', () => { config.kibana = false; - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, emptyScriptFields); expect(request.params.body.query.bool.filter).toEqual(undefined); }); test('adds a time filter to the bool querys must clause', () => { - let request = fn(config, tlConfig, emptyScriptedFields); + let request = fn(config, tlConfig, emptyScriptFields); expect(request.params.body.query.bool.must.length).toEqual(1); expect(request.params.body.query.bool.must[0]).toEqual({ range: { @@ -327,7 +328,7 @@ describe('es', () => { }); config.kibana = true; - request = fn(config, tlConfig, emptyScriptedFields); + request = fn(config, tlConfig, emptyScriptFields); expect(request.params.body.query.bool.must.length).toEqual(1); }); }); @@ -335,7 +336,7 @@ describe('es', () => { describe('config.split', () => { test('adds terms aggs, in order, under the filters agg', () => { config.split = ['beer:5', 'wine:10', ':lemo:nade::15', ':jui:ce:723::45']; - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, {}); let aggs = request.params.body.aggs.q.aggs; @@ -362,19 +363,21 @@ describe('es', () => { test('adds scripted terms aggs, in order, under the filters agg', () => { config.split = ['scriptedBeer:5', 'scriptedWine:10']; - const scriptedFields = [ - { - name: 'scriptedBeer', - script: 'doc["beer"].value', - lang: 'painless', + const scriptFields = { + scriptedBeer: { + script: { + source: 'doc["beer"].value', + lang: 'painless', + }, }, - { - name: 'scriptedWine', - script: 'doc["wine"].value', - lang: 'painless', + scriptedWine: { + script: { + source: 'doc["wine"].value', + lang: 'painless', + }, }, - ]; - const request = fn(config, tlConfig, scriptedFields); + }; + const request = fn(config, tlConfig, scriptFields); const aggs = request.params.body.aggs.q.aggs; diff --git a/src/plugins/vis_type_timelion/server/series_functions/es/index.js b/src/plugins/vis_type_timelion/server/series_functions/es/index.js index 75b16fa25c9cd..663d7714774c2 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/es/index.js +++ b/src/plugins/vis_type_timelion/server/series_functions/es/index.js @@ -101,11 +101,10 @@ export default new Datasource('es', { (index) => index.title === config.index ); - const scriptedFields = indexPatternSpec?.getScriptedFields() ?? []; - + const { scriptFields = {}, runtimeFields = {} } = indexPatternSpec?.getComputedFields() ?? {}; const esShardTimeout = tlConfig.esShardTimeout; - const body = buildRequest(config, tlConfig, scriptedFields, esShardTimeout); + const body = buildRequest(config, tlConfig, scriptFields, runtimeFields, esShardTimeout); const resp = await tlConfig.context.search .search( diff --git a/src/plugins/vis_type_timelion/server/series_functions/es/lib/agg_body.js b/src/plugins/vis_type_timelion/server/series_functions/es/lib/agg_body.js index cbdc834dd6611..db66cd1efc012 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/es/lib/agg_body.js +++ b/src/plugins/vis_type_timelion/server/series_functions/es/lib/agg_body.js @@ -6,21 +6,7 @@ * Side Public License, v 1. */ -export function buildAggBody(fieldName, scriptedFields) { - const scriptedField = scriptedFields.find((field) => { - return field.name === fieldName; - }); - - if (scriptedField) { - return { - script: { - source: scriptedField.script, - lang: scriptedField.lang, - }, - }; - } - - return { +export const buildAggBody = (fieldName, scriptFields) => + scriptFields[fieldName] ?? { field: fieldName, }; -} diff --git a/src/plugins/vis_type_timelion/server/series_functions/es/lib/build_request.js b/src/plugins/vis_type_timelion/server/series_functions/es/lib/build_request.js index a30b197e46067..7d55a772c7fc1 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/es/lib/build_request.js +++ b/src/plugins/vis_type_timelion/server/series_functions/es/lib/build_request.js @@ -12,7 +12,7 @@ import { buildAggBody } from './agg_body'; import createDateAgg from './create_date_agg'; import { UI_SETTINGS } from '../../../../../data/server'; -export default function buildRequest(config, tlConfig, scriptedFields, timeout) { +export default function buildRequest(config, tlConfig, scriptFields, runtimeFields, timeout) { const bool = { must: [] }; const timeFilter = { @@ -51,7 +51,7 @@ export default function buildRequest(config, tlConfig, scriptedFields, timeout) (config.split || []).forEach((clause) => { const [field, arg] = clause.split(/:(\d+$)/); if (field && arg) { - const termsAgg = buildAggBody(field, scriptedFields); + const termsAgg = buildAggBody(field, scriptFields); termsAgg.size = parseInt(arg, 10); aggCursor[field] = { meta: { type: 'split' }, @@ -64,7 +64,7 @@ export default function buildRequest(config, tlConfig, scriptedFields, timeout) } }); - _.assign(aggCursor, createDateAgg(config, tlConfig, scriptedFields)); + _.assign(aggCursor, createDateAgg(config, tlConfig, scriptFields)); const request = { index: config.index, @@ -75,6 +75,7 @@ export default function buildRequest(config, tlConfig, scriptedFields, timeout) }, aggs: aggs, size: 0, + runtime_mappings: runtimeFields, }, }; diff --git a/src/plugins/vis_type_timelion/server/series_functions/es/lib/create_date_agg.js b/src/plugins/vis_type_timelion/server/series_functions/es/lib/create_date_agg.js index 55538fbff4e79..bd6cf8a4b7c5e 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/es/lib/create_date_agg.js +++ b/src/plugins/vis_type_timelion/server/series_functions/es/lib/create_date_agg.js @@ -11,7 +11,7 @@ import { search, METRIC_TYPES } from '../../../../../data/server'; const { dateHistogramInterval } = search.aggs; -export default function createDateAgg(config, tlConfig, scriptedFields) { +export default function createDateAgg(config, tlConfig, scriptFields) { const dateAgg = { time_buckets: { meta: { type: 'time_buckets' }, @@ -47,7 +47,7 @@ export default function createDateAgg(config, tlConfig, scriptedFields) { const percentArgs = splittedArgs[1]; const metricKey = metricName + '(' + field + ')'; - metricBody[metricKey] = { [metricName]: buildAggBody(field, scriptedFields) }; + metricBody[metricKey] = { [metricName]: buildAggBody(field, scriptFields) }; if (metricName === METRIC_TYPES.PERCENTILES && percentArgs) { let percentList = percentArgs.split(','); diff --git a/tsconfig.json b/tsconfig.json index ac15fe14b4d2c..87ee067002109 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" }, diff --git a/tsconfig.refs.json b/tsconfig.refs.json index f13455a14b4df..b5e73e50f8b81 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.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" }, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts index 5a019f2dd9a2b..3ffa20de55aaf 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts @@ -17,6 +17,7 @@ import { EmbeddableExpression, } from '../../expression_types'; import { getFunctionHelp } from '../../../i18n'; +import { SavedObjectReference } from '../../../../../../src/core/types'; interface Arguments { id: string; @@ -90,5 +91,30 @@ export function savedLens(): ExpressionFunctionDefinition< generatedAt: Date.now(), }; }, + extract(state) { + const refName = 'savedLens.id'; + const references: SavedObjectReference[] = [ + { + name: refName, + type: 'lens', + id: state.id[0] as string, + }, + ]; + return { + state: { + ...state, + id: [refName], + }, + references, + }; + }, + + inject(state, references) { + const reference = references.find((ref) => ref.name === 'savedLens.id'); + if (reference) { + state.id[0] = reference.id; + } + return state; + }, }; } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_map.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_map.ts index 1c17929c704c8..395c6e112f753 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_map.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_map.ts @@ -15,6 +15,7 @@ import { } from '../../expression_types'; import { getFunctionHelp } from '../../../i18n'; import { MapEmbeddableInput } from '../../../../../plugins/maps/public/embeddable'; +import { SavedObjectReference } from '../../../../../../src/core/types'; interface Arguments { id: string; @@ -103,5 +104,30 @@ export function savedMap(): ExpressionFunctionDefinition< generatedAt: Date.now(), }; }, + extract(state) { + const refName = 'savedMap.id'; + const references: SavedObjectReference[] = [ + { + name: refName, + type: 'map', + id: state.id[0] as string, + }, + ]; + return { + state: { + ...state, + id: [refName], + }, + references, + }; + }, + + inject(state, references) { + const reference = references.find((ref) => ref.name === 'savedMap.id'); + if (reference) { + state.id[0] = reference.id; + } + return state; + }, }; } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_search.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_search.ts index 8d7e1da95487e..8e3ec9dc9e186 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_search.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_search.ts @@ -16,6 +16,7 @@ import { import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; import { ExpressionValueFilter } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; +import { SavedObjectReference } from '../../../../../../src/core/types'; interface Arguments { id: string; @@ -53,5 +54,30 @@ export function savedSearch(): ExpressionFunctionDefinition< generatedAt: Date.now(), }; }, + extract(state) { + const refName = 'savedSearch.id'; + const references: SavedObjectReference[] = [ + { + name: refName, + type: 'search', + id: state.id[0] as string, + }, + ]; + return { + state: { + ...state, + id: [refName], + }, + references, + }; + }, + + inject(state, references) { + const reference = references.find((ref) => ref.name === 'savedSearch.id'); + if (reference) { + state.id[0] = reference.id; + } + return state; + }, }; } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_visualization.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_visualization.ts index 796038540262d..92ddf6420f0e0 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_visualization.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_visualization.ts @@ -15,6 +15,7 @@ import { import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; import { ExpressionValueFilter, TimeRange as TimeRangeArg, SeriesStyle } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; +import { SavedObjectReference } from '../../../../../../src/core/types'; interface Arguments { id: string; @@ -103,5 +104,30 @@ export function savedVisualization(): ExpressionFunctionDefinition< generatedAt: Date.now(), }; }, + extract(state) { + const refName = 'savedVisualization.id'; + const references: SavedObjectReference[] = [ + { + name: refName, + type: 'visualization', + id: state.id[0] as string, + }, + ]; + return { + state: { + ...state, + id: [refName], + }, + references, + }; + }, + + inject(state, references) { + const reference = references.find((ref) => ref.name === 'savedVisualization.id'); + if (reference) { + state.id[0] = reference.id; + } + return state; + }, }; } diff --git a/x-pack/plugins/cases/README.md b/x-pack/plugins/cases/README.md index 069441ab640ee..14afe89829a68 100644 --- a/x-pack/plugins/cases/README.md +++ b/x-pack/plugins/cases/README.md @@ -1,18 +1,160 @@ -# Case Workflow - -*Experimental Feature* - -Elastic is developing a Case Management Workflow. Follow our progress: - -- [Case API Documentation](https://www.elastic.co/guide/en/security/master/cases-overview.html) - - -# Action types - +Case management in Kibana + +[![Issues][issues-shield]][issues-url] +[![Pull Requests][pr-shield]][pr-url] + +# Cases Plugin Docs + +![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_ + + +## 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` 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;` alert data to post to case +|createCaseNavigation|`CasesNavigation` route configuration for create cases page +|disabledStatuses?|`CaseStatuses[];` array of disabled statuses +|onRowClick|(theCase?: Case | SubCase) => void; callback for row click, passing case in row +|updateCase?|(theCase: Case | SubCase) => void; 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` 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| CasesNavigation +|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` +|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];` fetch alerts +|userCanCrud| `boolean;` user permissions to crud + +UI component: + ![Case View Component][case-view-img] + +### `getCreateCase` +Arguments: + +|Property|Description| +|---|---| +|afterCaseCreated?|`(theCase: Case) => Promise;` callback passing newly created case before pushCaseToExternalService is called +|onCancel|`() => void;` callback when create case is canceled +|onSuccess|`(theCase: Case) => Promise;` 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` +|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` 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 | \ No newline at end of file +| syncAlerts | Turn on or off alert synching. | boolean | + + + + + + + + +[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 + diff --git a/x-pack/plugins/cases/common/api/index.ts b/x-pack/plugins/cases/common/api/index.ts index 7780564089d3d..2ef03dd96e315 100644 --- a/x-pack/plugins/cases/common/api/index.ts +++ b/x-pack/plugins/cases/common/api/index.ts @@ -7,6 +7,7 @@ export * from './cases'; export * from './connectors'; +export * from './helpers'; export * from './runtime_types'; export * from './saved_object'; export * from './user'; diff --git a/x-pack/plugins/cases/common/api/runtime_types.ts b/x-pack/plugins/cases/common/api/runtime_types.ts index b2ff763838287..8001eb80cec73 100644 --- a/x-pack/plugins/cases/common/api/runtime_types.ts +++ b/x-pack/plugins/cases/common/api/runtime_types.ts @@ -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; diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index 148b81c346b6e..f9fae2466a59b 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -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. diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/index.ts b/x-pack/plugins/cases/common/index.ts similarity index 74% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/index.ts rename to x-pack/plugins/cases/common/index.ts index 3a1bbfcae75ba..3d277d12d6826 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/index.ts +++ b/x-pack/plugins/cases/common/index.ts @@ -5,4 +5,6 @@ * 2.0. */ -export { setMockValues, mockOverviewValues, mockActions } from './overview_logic.mock'; +export * from './constants'; +export * from './api'; +export * from './ui/types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/translations.ts b/x-pack/plugins/cases/common/ui/index.ts similarity index 87% rename from x-pack/plugins/security_solution/public/cases/components/add_comment/translations.ts rename to x-pack/plugins/cases/common/ui/index.ts index d94a4a8607d1e..6cc0ccaa93a6d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/translations.ts +++ b/x-pack/plugins/cases/common/ui/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from '../../translations'; +export * from './types'; diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/cases/common/ui/types.ts similarity index 74% rename from x-pack/plugins/security_solution/public/cases/containers/types.ts rename to x-pack/plugins/cases/common/ui/types.ts index ac60f2999c510..43e3453500b17 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -6,20 +6,22 @@ */ import { - User, - UserActionField, - UserAction, - CaseConnector, - CommentRequest, - CaseStatuses, + AssociationType, CaseAttributes, + CaseConnector, CasePatchRequest, + CaseStatuses, CaseType, - AssociationType, -} from '../../../../cases/common/api'; -import { CaseStatusWithAllStatus } from '../components/status'; + CommentRequest, + User, + UserAction, + UserActionField, +} from '../api'; + +export const StatusAll = 'all' as const; +export type StatusAllType = typeof StatusAll; -export { CaseConnector, ActionConnector, CaseStatuses } from '../../../../cases/common/api'; +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; +} diff --git a/x-pack/plugins/cases/images/all_cases.png b/x-pack/plugins/cases/images/all_cases.png new file mode 100644 index 0000000000000..3c6adf8ff2de2 Binary files /dev/null and b/x-pack/plugins/cases/images/all_cases.png differ diff --git a/x-pack/plugins/cases/images/all_cases_selector_modal.png b/x-pack/plugins/cases/images/all_cases_selector_modal.png new file mode 100644 index 0000000000000..f24ad32509dd1 Binary files /dev/null and b/x-pack/plugins/cases/images/all_cases_selector_modal.png differ diff --git a/x-pack/plugins/cases/images/case_view.png b/x-pack/plugins/cases/images/case_view.png new file mode 100644 index 0000000000000..4fb14d7b41b26 Binary files /dev/null and b/x-pack/plugins/cases/images/case_view.png differ diff --git a/x-pack/plugins/cases/images/configure.png b/x-pack/plugins/cases/images/configure.png new file mode 100644 index 0000000000000..02a2a6dbed314 Binary files /dev/null and b/x-pack/plugins/cases/images/configure.png differ diff --git a/x-pack/plugins/cases/images/create.png b/x-pack/plugins/cases/images/create.png new file mode 100644 index 0000000000000..df9bac09d5345 Binary files /dev/null and b/x-pack/plugins/cases/images/create.png differ diff --git a/x-pack/plugins/cases/images/logo.png b/x-pack/plugins/cases/images/logo.png new file mode 100644 index 0000000000000..7c56b0a667fe3 Binary files /dev/null and b/x-pack/plugins/cases/images/logo.png differ diff --git a/x-pack/plugins/cases/images/recent_cases.png b/x-pack/plugins/cases/images/recent_cases.png new file mode 100644 index 0000000000000..528bf36273979 Binary files /dev/null and b/x-pack/plugins/cases/images/recent_cases.png differ diff --git a/x-pack/plugins/cases/kibana.json b/x-pack/plugins/cases/kibana.json index 1aaf84decbe36..4a534c29de804 100644 --- a/x-pack/plugins/cases/kibana.json +++ b/x-pack/plugins/cases/kibana.json @@ -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" } diff --git a/x-pack/plugins/cases/public/common/errors.ts b/x-pack/plugins/cases/public/common/errors.ts new file mode 100644 index 0000000000000..6edef08c1f4b1 --- /dev/null +++ b/x-pack/plugins/cases/public/common/errors.ts @@ -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); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts new file mode 100644 index 0000000000000..392b71befe2b4 --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts @@ -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(); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts new file mode 100644 index 0000000000000..cb90568982282 --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts @@ -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(DEFAULT_DATE_FORMAT); + +export const useTimeZone = (): string => { + const timeZone = useUiSetting(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(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(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(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; +}; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/index.ts b/x-pack/plugins/cases/public/common/lib/kibana/index.ts new file mode 100644 index 0000000000000..5a89cbca9e471 --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/index.ts @@ -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'; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts new file mode 100644 index 0000000000000..326163f6cdc03 --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts @@ -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 => + partialTheme as EuiTheme; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.ts b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.ts new file mode 100644 index 0000000000000..921463c4c41ab --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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(); + +export { KibanaContextProvider, useTypedKibana as useKibana, useUiSetting, useUiSetting$ }; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/services.ts b/x-pack/plugins/cases/public/common/lib/kibana/services.ts new file mode 100644 index 0000000000000..94487bd3ca5e9 --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/services.ts @@ -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; + +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?' + ); + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/index.ts b/x-pack/plugins/cases/public/common/mock/index.ts similarity index 86% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/index.ts rename to x-pack/plugins/cases/public/common/mock/index.ts index 69c843fe3821e..add4c1c206dd4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/index.ts +++ b/x-pack/plugins/cases/public/common/mock/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { Overview } from './overview'; +export * from './test_providers'; diff --git a/x-pack/plugins/cases/public/common/mock/match_media.ts b/x-pack/plugins/cases/public/common/mock/match_media.ts new file mode 100644 index 0000000000000..722f4c3917ea0 --- /dev/null +++ b/x-pack/plugins/cases/public/common/mock/match_media.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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(), + }; +}); diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx new file mode 100644 index 0000000000000..94ee5dd4f2743 --- /dev/null +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -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 = ({ children }) => ( + + + ({ eui: euiDarkVars, darkMode: true })}>{children} + + +); + +export const TestProviders = React.memo(TestProvidersComponent); + +export const useFormFieldMock = (options?: Partial>): FieldHook => { + 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, + }; +}; diff --git a/x-pack/plugins/cases/public/common/shared_imports.ts b/x-pack/plugins/cases/public/common/shared_imports.ts new file mode 100644 index 0000000000000..675204076b02a --- /dev/null +++ b/x-pack/plugins/cases/public/common/shared_imports.ts @@ -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'; diff --git a/x-pack/plugins/cases/public/common/test_utils.ts b/x-pack/plugins/cases/public/common/test_utils.ts new file mode 100644 index 0000000000000..f6ccf28bcb643 --- /dev/null +++ b/x-pack/plugins/cases/public/common/test_utils.ts @@ -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, ''); diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts new file mode 100644 index 0000000000000..834bd1292ccdd --- /dev/null +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -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', +}); diff --git a/x-pack/plugins/cases/public/components/__mock__/form.ts b/x-pack/plugins/cases/public/components/__mock__/form.ts new file mode 100644 index 0000000000000..6d3e8353e630a --- /dev/null +++ b/x-pack/plugins/cases/public/components/__mock__/form.ts @@ -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; diff --git a/x-pack/plugins/cases/public/components/__mock__/router.ts b/x-pack/plugins/cases/public/components/__mock__/router.ts new file mode 100644 index 0000000000000..58b7bb0ac2688 --- /dev/null +++ b/x-pack/plugins/cases/public/components/__mock__/router.ts @@ -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 }; diff --git a/x-pack/plugins/cases/public/components/__mock__/timeline.tsx b/x-pack/plugins/cases/public/components/__mock__/timeline.tsx new file mode 100644 index 0000000000000..0aeda0f08302d --- /dev/null +++ b/x-pack/plugins/cases/public/components/__mock__/timeline.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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) => {name}; + +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; diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx similarity index 79% rename from x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx rename to x-pack/plugins/cases/public/components/add_comment/index.test.tsx index 9c06fc032f819..d35a3dc6a7462 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx @@ -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 ', () => { ); - 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 ', () => { ); - 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( - - - + + + + + ); diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/cases/public/components/add_comment/index.tsx similarity index 87% rename from x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx rename to x-pack/plugins/cases/public/components/add_comment/index.tsx index acd27e99a857f..b4aadc85ad5a7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.tsx @@ -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( ), }} /> + ); diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/schema.tsx b/x-pack/plugins/cases/public/components/add_comment/schema.tsx similarity index 88% rename from x-pack/plugins/security_solution/public/cases/components/add_comment/schema.tsx rename to x-pack/plugins/cases/public/components/add_comment/schema.tsx index 2cf7d3c6c555b..9693219dd5196 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/schema.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/schema.tsx @@ -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; diff --git a/x-pack/plugins/cases/public/components/add_comment/translations.ts b/x-pack/plugins/cases/public/components/add_comment/translations.ts new file mode 100644 index 0000000000000..a3d96a3b9b5b6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/add_comment/translations.ts @@ -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'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx b/x-pack/plugins/cases/public/components/all_cases/actions.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx rename to x-pack/plugins/cases/public/components/all_cases/actions.tsx index daa988641fbab..8742b8fea23a4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/actions.tsx @@ -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>; deleteCaseOnClick: (deleteCase: Case) => void; } export const getActions = ({ - caseStatus, dispatchUpdate, deleteCaseOnClick, }: GetActions): Array> => { diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx new file mode 100644 index 0000000000000..83f38aab21aa4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx @@ -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; + caseDetailsNavigation?: CasesNavigation; // 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( + ({ + 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(0); + const [isLoading, handleIsLoading] = useState(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) => { + 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>( + () => ({ + 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) && ( + + )} + {configureCasesNavigation != null && ( + + )} + + + + + + + ); + } +); + +AllCasesGeneric.displayName = 'AllCasesGeneric'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/all_cases/columns.test.tsx rename to x-pack/plugins/cases/public/components/all_cases/columns.test.tsx index ac877b9fae381..c7a255da9dda6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx @@ -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'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx similarity index 65% rename from x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx rename to x-pack/plugins/cases/public/components/all_cases/columns.tsx index 1efcdf2d792f4..cf5da3928446e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -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 + | EuiTableActionsColumnType | EuiTableComputedColumnType - | EuiTableActionsColumnType; + | EuiTableFieldDataColumnType; 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 ? {field} : getEmptyTagValue(); -export const getCasesColumns = ( - actions: Array>, - filterStatus: string, - isModal: boolean -): CasesColumns[] => { - const columns = [ +export interface GetCasesColumn { + caseDetailsNavigation?: CasesNavigation; + 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({ + 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) => { + 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 ? ( - - {theCase.title} - - ) : ( - {theCase.title} - ); + const caseDetailsLinkComponent = + caseDetailsNavigation != null ? ( + + {theCase.title} + + ) : ( + {theCase.title} + ); return theCase.status !== CaseStatuses.closed ? ( caseDetailsLinkComponent ) : ( @@ -218,15 +291,26 @@ export const getCasesColumns = ( )); }, }, - { - name: i18n.ACTIONS, - actions, - }, + ...(showActions + ? [ + { + name: ( + <> + {i18n.ACTIONS} + + + ), + actions, + }, + ] + : []), ]; - if (isModal) { - columns.pop(); // remove actions if in modal - } - return columns; }; interface Props { diff --git a/x-pack/plugins/cases/public/components/all_cases/count.tsx b/x-pack/plugins/cases/public/components/all_cases/count.tsx new file mode 100644 index 0000000000000..e42e52cfdc934 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/count.tsx @@ -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 = ({ refresh }) => { + const { + countOpenCases, + countInProgressCases, + countClosedCases, + isLoading: isCasesStatusLoading, + fetchCasesStatus, + } = useGetCasesStatus(); + useEffect(() => { + if (refresh != null) { + fetchCasesStatus(); + } + }, [fetchCasesStatus, refresh]); + return ( + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx b/x-pack/plugins/cases/public/components/all_cases/expanded_row.tsx similarity index 85% rename from x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx rename to x-pack/plugins/cases/public/components/all_cases/expanded_row.tsx index 43f0d9df49e94..59efcf868c9ee 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/expanded_row.tsx @@ -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 | {}; -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', }; }; diff --git a/x-pack/plugins/cases/public/components/all_cases/header.tsx b/x-pack/plugins/cases/public/components/all_cases/header.tsx new file mode 100644 index 0000000000000..a6737b987e2c4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/header.tsx @@ -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 = ({ + actionsErrors, + configureCasesNavigation, + createCaseNavigation, + refresh, + userCanCrud, +}) => ( + + + + + + + + + + +); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts b/x-pack/plugins/cases/public/components/all_cases/helpers.ts similarity index 98% rename from x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts rename to x-pack/plugins/cases/public/components/all_cases/helpers.ts index 8962d67319371..1751d478a5d9c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts +++ b/x-pack/plugins/cases/public/components/all_cases/helpers.ts @@ -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'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx similarity index 81% rename from x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx rename to x-pack/plugins/cases/public/components/all_cases/index.test.tsx index c7dd392bf801c..82db4a63115e4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx @@ -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; 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( - + ); 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( - + ); @@ -193,7 +212,7 @@ describe('AllCases', () => { const wrapper = mount( - + ); @@ -234,20 +253,22 @@ describe('AllCases', () => { }); const wrapper = mount( - + ); 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(() => + 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( - + ); wrapper.find('[data-test-subj="euiCollapsedItemActionsButton"]').first().simulate('click'); @@ -301,7 +322,7 @@ describe('AllCases', () => { }); const wrapper = mount( - + ); @@ -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( - + ); + const { result } = renderHook(() => + 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( - + ); 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( - + ); 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( - + ); @@ -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( - + ); @@ -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( - + ); @@ -495,7 +524,7 @@ describe('AllCases', () => { const wrapper = mount( - + ); @@ -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( - + ); wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click'); @@ -565,7 +594,7 @@ describe('AllCases', () => { const wrapper = mount( - + ); wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click'); @@ -588,7 +617,7 @@ describe('AllCases', () => { const wrapper = mount( - + ); wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click'); @@ -607,7 +636,7 @@ describe('AllCases', () => { const wrapper = mount( - + ); wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click'); @@ -628,7 +657,7 @@ describe('AllCases', () => { mount( - + ); await waitFor(() => { @@ -646,7 +675,7 @@ describe('AllCases', () => { mount( - + ); 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( - + ); 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( - + ); 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( - + ); 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( - + ); 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( - + ); 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( - + ); 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( - + ); 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( - + ); 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( - + ); 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( - + ); 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( - + ); wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); @@ -882,7 +928,7 @@ describe('AllCases', () => { const wrapper = mount( - + ); @@ -908,7 +954,7 @@ describe('AllCases', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/cases/public/components/all_cases/index.tsx b/x-pack/plugins/cases/public/components/all_cases/index.tsx new file mode 100644 index 0000000000000..2c506cd2da411 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/index.tsx @@ -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; // 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 = (props) => { + return ; +}; + +// eslint-disable-next-line import/no-default-export +export { AllCases as default }; diff --git a/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx b/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx new file mode 100644 index 0000000000000..e29551f43c2bd --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx @@ -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 = ({ + actionsErrors, + configureCasesNavigation, + createCaseNavigation, + userCanCrud, +}) => ( + + + } + titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''} + /> + + + + {i18n.CREATE_TITLE} + + + +); diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/index.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/index.test.tsx new file mode 100644 index 0000000000000..aaec37335c699 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/index.test.tsx @@ -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( + + + + ); + + expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeTruthy(); + }); + + it('Closing modal calls onCloseCaseModal', () => { + const wrapper = mount( + + + + ); + + 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( + + + + ); + // @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, + }) + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/index.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/index.tsx new file mode 100644 index 0000000000000..0a83ef13e8ee6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/index.tsx @@ -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; + 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 = ({ + alertData, + createCaseNavigation, + disabledStatuses, + onRowClick, + updateCase, + userCanCrud, +}) => { + const [isModalOpen, setIsModalOpen] = useState(true); + const closeModal = useCallback(() => setIsModalOpen(false), []); + const onClick = useCallback( + (theCase?: Case | SubCase) => { + closeModal(); + onRowClick(theCase); + }, + [closeModal, onRowClick] + ); + return isModalOpen ? ( + + + {i18n.SELECT_CASE_TITLE} + + + + + + ) : null; +}; +// eslint-disable-next-line import/no-default-export +export { AllCasesSelectorModal as default }; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx b/x-pack/plugins/cases/public/components/all_cases/status_filter.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx rename to x-pack/plugins/cases/public/components/all_cases/status_filter.test.tsx index 5c9f11d1e3a83..1a9dd9c772294 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/status_filter.test.tsx @@ -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, diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx b/x-pack/plugins/cases/public/components/all_cases/status_filter.tsx similarity index 93% rename from x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx rename to x-pack/plugins/cases/public/components/all_cases/status_filter.tsx index 34186a201cc05..9fb00933f0307 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/status_filter.tsx @@ -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; diff --git a/x-pack/plugins/cases/public/components/all_cases/table.tsx b/x-pack/plugins/cases/public/components/all_cases/table.tsx new file mode 100644 index 0000000000000..4b786e320d50c --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/table.tsx @@ -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['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['itemIdToExpandedRowMap']; + onChange: EuiBasicTableProps['onChange']; + pagination: EuiBasicTableProps['pagination']; + refreshCases: (a?: boolean) => void; + selectedCases: Case[]; + selection: EuiTableSelectionType; + showActions: boolean; + sorting: EuiBasicTableProps['sorting']; + tableRowProps: EuiBasicTableProps['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 = ({ + columns, + createCaseNavigation, + data, + filterOptions, + goToCreateCase, + handleIsLoading, + isCasesLoading, + isCommentUpdating, + isDataEmpty, + isSelectorView, + itemIdToExpandedRowMap, + onChange, + pagination, + refreshCases, + selectedCases, + selection, + showActions, + sorting, + tableRowProps, + userCanCrud, +}) => + isCasesLoading && isDataEmpty ? ( +
+ +
+ ) : ( +
+ + {i18n.NO_CASES}} + titleSize="xs" + body={i18n.NO_CASES_BODY} + actions={ + + {i18n.ADD_NEW_CASE} + + } + /> + } + onChange={onChange} + pagination={pagination} + rowProps={tableRowProps} + selection={showActions ? selection : undefined} + sorting={sorting} + className={classnames({ isSelectorView })} + /> +
+ ); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx rename to x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx index 48a642aaf51a9..20892ce8e9c5d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx @@ -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'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx similarity index 91% rename from x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx rename to x-pack/plugins/cases/public/components/all_cases/table_filters.tsx index ff5b511ef9026..9428a374a0314 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx @@ -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( diff --git a/x-pack/plugins/cases/public/components/all_cases/translations.ts b/x-pack/plugins/cases/public/components/all_cases/translations.ts new file mode 100644 index 0000000000000..0f535b771ec8a --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/translations.ts @@ -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', +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/types.ts b/x-pack/plugins/cases/public/components/all_cases/types.ts new file mode 100644 index 0000000000000..5014522177570 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/types.ts @@ -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; + +export interface EuiBasicTableSortTypes { + field: string; + direction: SortOrder; +} + +export interface EuiBasicTableOnChange { + page: { + index: number; + size: number; + }; + sort?: EuiBasicTableSortTypes; +} diff --git a/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx b/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx new file mode 100644 index 0000000000000..d0981c38385e9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx @@ -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 = ({ + data, + enableBulkActions = false, + filterOptions, + handleIsLoading, + refreshCases, + selectedCases, +}) => { + const [deleteBulk, setDeleteBulk] = useState([]); + const [deleteThisCase, setDeleteThisCase] = useState({ + 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) => ( + + ), + [selectedCases, filterOptions.status, toggleBulkDeleteModal, handleUpdateCaseStatus] + ); + return ( + + + + + {i18n.SHOWING_CASES(data.total ?? 0)} + + + + {enableBulkActions && ( + <> + + {i18n.SHOWING_SELECTED_CASES(selectedCases.length)} + + + + {i18n.BULK_ACTIONS} + + + )} + + {i18n.REFRESH} + + + + 0} + onCancel={handleToggleModal} + onConfirm={handleOnDeleteConfirm.bind( + null, + deleteBulk.length > 0 ? deleteBulk : [deleteThisCase] + )} + /> + + ); +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/bulk_actions/index.tsx b/x-pack/plugins/cases/public/components/bulk_actions/index.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/bulk_actions/index.tsx rename to x-pack/plugins/cases/public/components/bulk_actions/index.tsx index 24897a14f0754..fae1c4909ffe2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/bulk_actions/index.tsx +++ b/x-pack/plugins/cases/public/components/bulk_actions/index.tsx @@ -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'; diff --git a/x-pack/plugins/security_solution/public/cases/components/bulk_actions/translations.ts b/x-pack/plugins/cases/public/components/bulk_actions/translations.ts similarity index 83% rename from x-pack/plugins/security_solution/public/cases/components/bulk_actions/translations.ts rename to x-pack/plugins/cases/public/components/bulk_actions/translations.ts index 1171495f4a202..c5bc5d7cde66b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/bulk_actions/translations.ts +++ b/x-pack/plugins/cases/public/components/bulk_actions/translations.ts @@ -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', } diff --git a/x-pack/plugins/cases/public/components/callout/callout.test.tsx b/x-pack/plugins/cases/public/components/callout/callout.test.tsx new file mode 100644 index 0000000000000..926fe7b63fb5a --- /dev/null +++ b/x-pack/plugins/cases/public/components/callout/callout.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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:

{'error'}

, + }, + ], + showCallOut: true, + handleDismissCallout: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('It renders the callout', () => { + const wrapper = mount(); + 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(); + 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(); + expect(wrapper.find(`[data-test-subj="callout-messages-md5-hex"]`).exists()).toBeFalsy(); + }); + + it('transform the button color correctly - primary', () => { + const wrapper = mount(); + 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(); + 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(); + 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(); + 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(); + 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'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/callout/callout.tsx b/x-pack/plugins/cases/public/components/callout/callout.tsx new file mode 100644 index 0000000000000..8e2f439f02c4b --- /dev/null +++ b/x-pack/plugins/cases/public/components/callout/callout.tsx @@ -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; + title: string; + messages: ErrorMessage[]; + showCallOut: boolean; + handleDismissCallout: (id: string, type: NonNullable) => void; +} + +const CallOutComponent = ({ + id, + type, + title, + messages, + showCallOut, + handleDismissCallout, +}: CallOutProps) => { + const handleCallOut = useCallback(() => handleDismissCallout(id, type), [ + handleDismissCallout, + id, + type, + ]); + + return showCallOut ? ( + + {!isEmpty(messages) && ( + + )} + + {i18n.DISMISS_CALLOUT} + + + ) : null; +}; + +export const CallOut = memo(CallOutComponent); diff --git a/x-pack/plugins/cases/public/components/callout/helpers.test.tsx b/x-pack/plugins/cases/public/components/callout/helpers.test.tsx new file mode 100644 index 0000000000000..b5b92a3374874 --- /dev/null +++ b/x-pack/plugins/cases/public/components/callout/helpers.test.tsx @@ -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); + }); +}); diff --git a/x-pack/plugins/cases/public/components/callout/helpers.tsx b/x-pack/plugins/cases/public/components/callout/helpers.tsx new file mode 100644 index 0000000000000..2a7804579a57e --- /dev/null +++ b/x-pack/plugins/cases/public/components/callout/helpers.tsx @@ -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)); diff --git a/x-pack/plugins/cases/public/components/callout/index.test.tsx b/x-pack/plugins/cases/public/components/callout/index.test.tsx new file mode 100644 index 0000000000000..c46ec1b5606c9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/callout/index.test.tsx @@ -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:

{'we have two messages'}

}, + { id: 'message-two', title: 'title', description:

{'for real'}

}, + ], + }; + const wrapper = mount( + + + + ); + + 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:

{'we have two messages'}

, + errorType: 'danger', + }, + { id: 'message-two', title: 'title two', description:

{'for real'}

}, + ], + }; + + const wrapper = mount( + + + + ); + + 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:

{'we have two messages'}

}, + ], + }; + const wrapper = mount( + + + + ); + + 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:

{'we have two messages'}

}, + ], + }; + + const wrapper = mount( + + + + ); + + 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:

{'we have two messages'}

}, + ], + }; + + const id = createCalloutId(['message-one']); + + useSecurityLocalStorageMock.mockImplementation(() => ({ + ...securityLocalStorageMock, + getMessages: jest.fn(() => [id]), + })); + + const wrapper = mount( + + + + ); + + 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:

{'we have two messages'}

, + errorType: 'danger', + }, + ], + }; + + const wrapper = mount( + + + + ); + + 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:

{'we have two messages'}

, + errorType: 'warning', + }, + ], + }; + + const wrapper = mount( + + + + ); + + 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:

{'we have two messages'}

, + errorType: 'success', + }, + ], + }; + + const wrapper = mount( + + + + ); + + const id = createCalloutId(['message-one']); + wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click'); + wrapper.update(); + expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/callout/index.tsx b/x-pack/plugins/cases/public/components/callout/index.tsx new file mode 100644 index 0000000000000..1994617d62801 --- /dev/null +++ b/x-pack/plugins/cases/public/components/callout/index.tsx @@ -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]: { + 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( + (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( + (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).map( + (type: NonNullable) => { + const id = createCalloutId(groupedByTypeErrorMessages[type].messagesId); + return ( + + + + + ); + } + )} + + ); +}; + +export const CaseCallOut = memo(CaseCallOutComponent); diff --git a/x-pack/plugins/cases/public/components/callout/translations.ts b/x-pack/plugins/cases/public/components/callout/translations.ts new file mode 100644 index 0000000000000..3f551c5cf0170 --- /dev/null +++ b/x-pack/plugins/cases/public/components/callout/translations.ts @@ -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', +}); diff --git a/x-pack/plugins/cases/public/components/callout/types.ts b/x-pack/plugins/cases/public/components/callout/types.ts new file mode 100644 index 0000000000000..84d79ee391b8f --- /dev/null +++ b/x-pack/plugins/cases/public/components/callout/types.ts @@ -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'; +} diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/actions.test.tsx b/x-pack/plugins/cases/public/components/case_action_bar/actions.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/components/case_action_bar/actions.test.tsx rename to x-pack/plugins/cases/public/components/case_action_bar/actions.test.tsx index ba0c725f99460..886e740d56447 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/actions.test.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/actions.test.tsx @@ -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'; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/actions.tsx b/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/cases/components/case_action_bar/actions.tsx rename to x-pack/plugins/cases/public/components/case_action_bar/actions.tsx index 74d2a40f1ceb9..b8d9d7f85a9ef 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/actions.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx @@ -35,21 +35,6 @@ const ActionsComponent: React.FC = ({ isDisplayConfirmDeleteModal, } = useDeleteCases(); - const confirmDeleteModal = useMemo( - () => ( - - ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [isDisplayConfirmDeleteModal, caseData] - ); const propertyActions = useMemo( () => [ { @@ -78,7 +63,15 @@ const ActionsComponent: React.FC = ({ return ( <> - {confirmDeleteModal} + ); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.test.ts b/x-pack/plugins/cases/public/components/case_action_bar/helpers.test.ts similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.test.ts rename to x-pack/plugins/cases/public/components/case_action_bar/helpers.test.ts index 8e26c0fd7a7ff..ed5832d19b4da 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.test.ts +++ b/x-pack/plugins/cases/public/components/case_action_bar/helpers.test.ts @@ -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'; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.ts b/x-pack/plugins/cases/public/components/case_action_bar/helpers.ts similarity index 92% rename from x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.ts rename to x-pack/plugins/cases/public/components/case_action_bar/helpers.ts index 68a243040145a..35cfdae3abe21 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.ts +++ b/x-pack/plugins/cases/public/components/case_action_bar/helpers.ts @@ -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'; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.test.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.test.tsx rename to x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx index b6158946aa82d..0d29335ea730e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx @@ -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(); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx rename to x-pack/plugins/cases/public/components/case_action_bar/index.tsx index 63ce441732251..0f06dde6a86d1 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx @@ -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'; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.test.tsx b/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.test.tsx rename to x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.test.tsx index 4e414706d1fd7..29cca46d372f0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.test.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.test.tsx @@ -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', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.tsx b/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.tsx rename to x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.tsx index 92dcd16a86193..2922b797f9d40 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.tsx @@ -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 { diff --git a/x-pack/plugins/cases/public/components/case_header_page/index.tsx b/x-pack/plugins/cases/public/components/case_header_page/index.tsx new file mode 100644 index 0000000000000..7e60db1030587 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_header_page/index.tsx @@ -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 = (props) => ; + +export const CaseHeaderPage = React.memo(CaseHeaderPageComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.test.tsx b/x-pack/plugins/cases/public/components/case_settings/sync_alerts_switch.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.test.tsx rename to x-pack/plugins/cases/public/components/case_settings/sync_alerts_switch.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx b/x-pack/plugins/cases/public/components/case_settings/sync_alerts_switch.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx rename to x-pack/plugins/cases/public/components/case_settings/sync_alerts_switch.tsx index a19640339acc6..406b8dbe51ced 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx +++ b/x-pack/plugins/cases/public/components/case_settings/sync_alerts_switch.tsx @@ -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; diff --git a/x-pack/plugins/cases/public/components/case_view/helpers.test.tsx b/x-pack/plugins/cases/public/components/case_view/helpers.test.tsx new file mode 100644 index 0000000000000..f266c574c27da --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/helpers.test.tsx @@ -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']); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_view/helpers.ts b/x-pack/plugins/cases/public/components/case_view/helpers.ts new file mode 100644 index 0000000000000..ab26b132e0489 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/helpers.ts @@ -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()); + return [...dedupeAlerts]; +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/cases/public/components/case_view/index.test.tsx similarity index 73% rename from x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx rename to x-pack/plugins/cases/public/components/case_view/index.test.tsx index 0daa62bf735e8..d13e3978ce618 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.test.tsx @@ -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-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-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-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-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="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(`[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('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 ', () => { ); - 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 } }); + 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.update(); - wrapper.find(`[data-test-subj="editable-title-submit-btn"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="editable-title-submit-btn"]`).first().simulate('click'); - wrapper.update(); - const updateObject = updateCaseProperty.mock.calls[0][0]; + const updateObject = updateCaseProperty.mock.calls[0][0]; + await waitFor(() => { 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 ', () => { @@ -419,7 +442,27 @@ describe('CaseView ', () => { @@ -438,7 +481,27 @@ describe('CaseView ', () => { @@ -457,15 +520,35 @@ describe('CaseView ', () => { ); + 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( @@ -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( - + ); 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( - + ); @@ -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( - + ); + 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 ', () => { ); + 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'); diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx new file mode 100644 index 0000000000000..557f736c513b9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -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; + showAlertDetails: (alertId: string, index: string) => void; + subCaseId?: string; + useFetchAlertData: (alertIds: string[]) => [boolean, Record]; + 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( + ({ + 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(value); + if (titleUpdate.length > 0) { + updateCaseProperty({ + fetchCaseUserActions, + updateKey: 'title', + updateValue: titleUpdate, + updateCase: handleUpdateNewCase, + caseData, + onSuccess, + onError, + }); + } + break; + case 'connector': + const connector = getTypedPayload(value); + if (connector != null) { + updateCaseProperty({ + fetchCaseUserActions, + updateKey: 'connector', + updateValue: connector, + updateCase: handleUpdateNewCase, + caseData, + onSuccess, + onError, + }); + } + break; + case 'description': + const descriptionUpdate = getTypedPayload(value); + if (descriptionUpdate.length > 0) { + updateCaseProperty({ + fetchCaseUserActions, + updateKey: 'description', + updateValue: descriptionUpdate, + updateCase: handleUpdateNewCase, + caseData, + onSuccess, + onError, + }); + } + break; + case 'tags': + const tagsUpdate = getTypedPayload(value); + updateCaseProperty({ + fetchCaseUserActions, + updateKey: 'tags', + updateValue: tagsUpdate, + updateCase: handleUpdateNewCase, + caseData, + onSuccess, + onError, + }); + break; + case 'status': + const statusUpdate = getTypedPayload(value); + if (caseData.status !== value) { + updateCaseProperty({ + fetchCaseUserActions, + updateKey: 'status', + updateValue: statusUpdate, + updateCase: handleUpdateNewCase, + caseData, + onSuccess, + onError, + }); + } + break; + case 'settings': + const settingsUpdate = getTypedPayload(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 ( + <> + + + } + title={caseData.title} + > + + + + + + {!initLoadingData && pushCallouts != null && pushCallouts} + + + {initLoadingData && ( + + )} + {!initLoadingData && ( + <> + + {(caseData.type !== CaseType.collection || hasDataToPush) && ( + <> + + + {caseData.type !== CaseType.collection && ( + + + + )} + {hasDataToPush && ( + + {pushButton} + + )} + + + )} + + )} + + + + + + + + + + + {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 ( + + + + + + ); + } + if (onCaseDataSuccess && data) { + onCaseDataSuccess(data); + } + + return ( + data && ( + + + + ) + ); + } +); + +CaseComponent.displayName = 'CaseComponent'; +CaseView.displayName = 'CaseView'; + +// eslint-disable-next-line import/no-default-export +export { CaseView as default }; diff --git a/x-pack/plugins/cases/public/components/case_view/translations.ts b/x-pack/plugins/cases/public/components/case_view/translations.ts new file mode 100644 index 0000000000000..41ffbbd9342da --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/translations.ts @@ -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`, +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx rename to x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx index ccc697a2ae84e..e3abbeadd2d3c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx @@ -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)))'; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/button.test.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/button.test.tsx rename to x-pack/plugins/cases/public/components/configure_cases/button.test.tsx index 4b2d72cf86dd6..a3f95e60dc2ae 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/button.test.tsx @@ -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', diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx b/x-pack/plugins/cases/public/components/configure_cases/button.tsx similarity index 62% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx rename to x-pack/plugins/cases/public/components/configure_cases/button.tsx index 2e116e16df52b..1830380be3765 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/button.tsx @@ -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 = ({ + 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( () => ( = ({ {label} ), - [label, isDisabled, formatUrl, goToCaseConfigure] + [label, isDisabled, onClick, href] ); return showToolTip ? ( diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/closure_options.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/closure_options.test.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/closure_options.test.tsx rename to x-pack/plugins/cases/public/components/configure_cases/closure_options.test.tsx index a7d9805bc77b4..56123a934d51f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/closure_options.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/closure_options.test.tsx @@ -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', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/closure_options.tsx b/x-pack/plugins/cases/public/components/configure_cases/closure_options.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/closure_options.tsx rename to x-pack/plugins/cases/public/components/configure_cases/closure_options.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/closure_options_radio.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/closure_options_radio.test.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/closure_options_radio.test.tsx rename to x-pack/plugins/cases/public/components/configure_cases/closure_options_radio.test.tsx index e26444590da46..b9885b4e07d48 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/closure_options_radio.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/closure_options_radio.test.tsx @@ -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; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/closure_options_radio.tsx b/x-pack/plugins/cases/public/components/configure_cases/closure_options_radio.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/closure_options_radio.tsx rename to x-pack/plugins/cases/public/components/configure_cases/closure_options_radio.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx rename to x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx index c34651c3e1dc4..d5b9a885f2c6d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx @@ -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; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx rename to x-pack/plugins/cases/public/components/configure_cases/connectors.tsx index 1e0ae95ff901c..45be02e05e1f0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx @@ -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 { diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx rename to x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx index ac0bb1f1c742f..0070bc18dfe12 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx @@ -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', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx rename to x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx index 4971ed43d5974..8c3a0f7ae1961 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -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'; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/field_mapping.test.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping.test.tsx rename to x-pack/plugins/cases/public/components/configure_cases/field_mapping.test.tsx index 35f5e1fe058dd..8c2a66ad7ee53 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/field_mapping.test.tsx @@ -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); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping.tsx b/x-pack/plugins/cases/public/components/configure_cases/field_mapping.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping.tsx rename to x-pack/plugins/cases/public/components/configure_cases/field_mapping.tsx index 6792f5d9ab49f..7d5b72b583fae 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/field_mapping.tsx @@ -58,7 +58,7 @@ const FieldMappingComponent: React.FC = ({ {mappings.map((item) => ( = ({ isLoading, - securitySolutionField, + casesField, selectedActionType, selectedThirdParty, }) => { @@ -32,7 +32,7 @@ const FieldMappingRowComponent: React.FC = ({ - {securitySolutionField} + {casesField} diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx rename to x-pack/plugins/cases/public/components/configure_cases/index.test.tsx index 8dbefdb731141..898d6cde19a77 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { ReactWrapper, mount } from 'enzyme'; import { ConfigureCases } from '.'; -import { TestProviders } from '../../../common/mock'; +import { TestProviders } from '../../common/mock'; import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; import { @@ -17,14 +17,13 @@ import { ConnectorAddFlyout, ConnectorEditFlyout, TriggersAndActionsUIPublicPluginStart, -} from '../../../../../triggers_actions_ui/public'; -import { actionTypeRegistryMock } from '../../../../../triggers_actions_ui/public/application/action_type_registry.mock'; +} from '../../../../triggers_actions_ui/public'; +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; -import { useKibana } from '../../../common/lib/kibana'; +import { useKibana } from '../../common/lib/kibana'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { useActionTypes } from '../../containers/configure/use_action_types'; -import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search'; import { connectors, @@ -33,18 +32,17 @@ import { useConnectorsResponse, useActionTypesResponse, } from './__mock__'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; +import { ConnectorTypes } from '../../../common'; -jest.mock('../../../common/lib/kibana'); +jest.mock('../../common/lib/kibana'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/configure/use_configure'); jest.mock('../../containers/configure/use_action_types'); -jest.mock('../../../common/components/navigation/use_get_url_search'); const useKibanaMock = useKibana as jest.Mocked; const useConnectorsMock = useConnectors as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; -const useGetUrlSearchMock = useGetUrlSearch as jest.Mock; +const useGetUrlSearchMock = jest.fn(); const useActionTypesMock = useActionTypes as jest.Mock; describe('ConfigureCases', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.tsx similarity index 89% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx rename to x-pack/plugins/cases/public/components/configure_cases/index.tsx index 25155ff77c2d0..fdba148e5c61e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -10,8 +10,8 @@ import styled, { css } from 'styled-components'; import { EuiCallOut } from '@elastic/eui'; -import { SUPPORTED_CONNECTORS } from '../../../../../cases/common/constants'; -import { useKibana } from '../../../common/lib/kibana'; +import { SUPPORTED_CONNECTORS } from '../../../common'; +import { useKibana } from '../../common/lib/kibana'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useActionTypes } from '../../containers/configure/use_action_types'; import { useCaseConfigure } from '../../containers/configure/use_configure'; @@ -19,7 +19,7 @@ import { useCaseConfigure } from '../../containers/configure/use_configure'; import { ClosureType } from '../../containers/configure/types'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ActionConnectorTableItem } from '../../../../../triggers_actions_ui/public/types'; +import { ActionConnectorTableItem } from '../../../../triggers_actions_ui/public/types'; import { SectionWrapper } from '../wrappers'; import { Connectors } from './connectors'; @@ -50,11 +50,11 @@ const FormWrapper = styled.div` `} `; -interface ConfigureCasesComponentProps { +export interface ConfigureCasesProps { userCanCrud: boolean; } -const ConfigureCasesComponent: React.FC = ({ userCanCrud }) => { +const ConfigureCasesComponent: React.FC = ({ userCanCrud }) => { const { triggersActionsUi } = useKibana().services; const [connectorIsValid, setConnectorIsValid] = useState(true); @@ -158,14 +158,16 @@ const ConfigureCasesComponent: React.FC = ({ userC const ConnectorAddFlyout = useMemo( () => - triggersActionsUi.getAddConnectorFlyout({ - consumer: 'case', - onClose: onCloseAddFlyout, - actionTypes: supportedActionTypes, - reloadConnectors: onConnectorUpdate, - }), + addFlyoutVisible + ? triggersActionsUi.getAddConnectorFlyout({ + consumer: 'case', + onClose: onCloseAddFlyout, + actionTypes: supportedActionTypes, + reloadConnectors: onConnectorUpdate, + }) + : null, // eslint-disable-next-line react-hooks/exhaustive-deps - [supportedActionTypes] + [addFlyoutVisible, supportedActionTypes] ); const ConnectorEditFlyout = useMemo( @@ -215,10 +217,12 @@ const ConfigureCasesComponent: React.FC = ({ userC updateConnectorDisabled={updateConnectorDisabled || !userCanCrud} /> - {addFlyoutVisible && ConnectorAddFlyout} + {ConnectorAddFlyout} {ConnectorEditFlyout} ); }; export const ConfigureCases = React.memo(ConfigureCasesComponent); +// eslint-disable-next-line import/no-default-export +export default ConfigureCases; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/mapping.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.test.tsx rename to x-pack/plugins/cases/public/components/configure_cases/mapping.test.tsx index 115481c5e7302..75b2410dde957 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/mapping.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { TestProviders } from '../../../common/mock'; +import { TestProviders } from '../../common/mock'; import { Mapping, MappingProps } from './mapping'; import { mappings } from './__mock__'; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.tsx b/x-pack/plugins/cases/public/components/configure_cases/mapping.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.tsx rename to x-pack/plugins/cases/public/components/configure_cases/mapping.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts similarity index 51% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/translations.ts rename to x-pack/plugins/cases/public/components/configure_cases/translations.ts index 697d5e1a7adfa..2fb2133ba470c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -7,182 +7,175 @@ import { i18n } from '@kbn/i18n'; -export * from '../../translations'; +export * from '../../common/translations'; export const INCIDENT_MANAGEMENT_SYSTEM_TITLE = i18n.translate( - 'xpack.securitySolution.cases.configureCases.incidentManagementSystemTitle', + 'xpack.cases.configureCases.incidentManagementSystemTitle', { defaultMessage: 'Connect to external incident management system', } ); export const INCIDENT_MANAGEMENT_SYSTEM_DESC = i18n.translate( - 'xpack.securitySolution.cases.configureCases.incidentManagementSystemDesc', + 'xpack.cases.configureCases.incidentManagementSystemDesc', { defaultMessage: - 'You may optionally connect Security cases to an external incident management system of your choosing. This will allow you to push case data as an incident in your chosen third-party system.', + 'You may optionally connect cases to an external incident management system of your choosing. This will allow you to push case data as an incident in your chosen third-party system.', } ); export const INCIDENT_MANAGEMENT_SYSTEM_LABEL = i18n.translate( - 'xpack.securitySolution.cases.configureCases.incidentManagementSystemLabel', + 'xpack.cases.configureCases.incidentManagementSystemLabel', { defaultMessage: 'Incident management system', } ); -export const ADD_NEW_CONNECTOR = i18n.translate( - 'xpack.securitySolution.cases.configureCases.addNewConnector', - { - defaultMessage: 'Add new connector', - } -); +export const ADD_NEW_CONNECTOR = i18n.translate('xpack.cases.configureCases.addNewConnector', { + defaultMessage: 'Add new connector', +}); export const CASE_CLOSURE_OPTIONS_TITLE = i18n.translate( - 'xpack.securitySolution.cases.configureCases.caseClosureOptionsTitle', + 'xpack.cases.configureCases.caseClosureOptionsTitle', { defaultMessage: 'Case Closures', } ); export const CASE_CLOSURE_OPTIONS_DESC = i18n.translate( - 'xpack.securitySolution.cases.configureCases.caseClosureOptionsDesc', + 'xpack.cases.configureCases.caseClosureOptionsDesc', { defaultMessage: - 'Define how you wish Security cases to be closed. Automated case closures require an established connection to an external incident management system.', + 'Define how you wish cases to be closed. Automated case closures require an established connection to an external incident management system.', } ); export const CASE_COLSURE_OPTIONS_SUB_CASES = i18n.translate( - 'xpack.securitySolution.cases.configureCases.caseClosureOptionsSubCases', + 'xpack.cases.configureCases.caseClosureOptionsSubCases', { defaultMessage: 'Automated closures of sub-cases is not currently supported.', } ); export const CASE_CLOSURE_OPTIONS_LABEL = i18n.translate( - 'xpack.securitySolution.cases.configureCases.caseClosureOptionsLabel', + 'xpack.cases.configureCases.caseClosureOptionsLabel', { defaultMessage: 'Case closure options', } ); export const CASE_CLOSURE_OPTIONS_MANUAL = i18n.translate( - 'xpack.securitySolution.cases.configureCases.caseClosureOptionsManual', + 'xpack.cases.configureCases.caseClosureOptionsManual', { - defaultMessage: 'Manually close Security cases', + defaultMessage: 'Manually close cases', } ); export const CASE_CLOSURE_OPTIONS_NEW_INCIDENT = i18n.translate( - 'xpack.securitySolution.cases.configureCases.caseClosureOptionsNewIncident', + 'xpack.cases.configureCases.caseClosureOptionsNewIncident', { - defaultMessage: - 'Automatically close Security cases when pushing new incident to external system', + defaultMessage: 'Automatically close cases when pushing new incident to external system', } ); export const CASE_CLOSURE_OPTIONS_CLOSED_INCIDENT = i18n.translate( - 'xpack.securitySolution.cases.configureCases.caseClosureOptionsClosedIncident', + 'xpack.cases.configureCases.caseClosureOptionsClosedIncident', { - defaultMessage: 'Automatically close Security cases when incident is closed in external system', + defaultMessage: 'Automatically close cases when incident is closed in external system', } ); export const FIELD_MAPPING_TITLE = (thirdPartyName: string): string => { - return i18n.translate('xpack.securitySolution.cases.configureCases.fieldMappingTitle', { + return i18n.translate('xpack.cases.configureCases.fieldMappingTitle', { values: { thirdPartyName }, defaultMessage: '{ thirdPartyName } field mappings', }); }; export const FIELD_MAPPING_DESC = (thirdPartyName: string): string => { - return i18n.translate('xpack.securitySolution.cases.configureCases.fieldMappingDesc', { + return i18n.translate('xpack.cases.configureCases.fieldMappingDesc', { values: { thirdPartyName }, defaultMessage: - 'Map Security Case fields to { thirdPartyName } fields when pushing data to { thirdPartyName }. Field mappings require an established connection to { thirdPartyName }.', + 'Map Case fields to { thirdPartyName } fields when pushing data to { thirdPartyName }. Field mappings require an established connection to { thirdPartyName }.', }); }; export const FIELD_MAPPING_DESC_ERR = (thirdPartyName: string): string => { - return i18n.translate('xpack.securitySolution.cases.configureCases.fieldMappingDescErr', { + return i18n.translate('xpack.cases.configureCases.fieldMappingDescErr', { values: { thirdPartyName }, defaultMessage: 'Field mappings require an established connection to { thirdPartyName }. Please check your connection credentials.', }); }; export const EDIT_FIELD_MAPPING_TITLE = (thirdPartyName: string): string => { - return i18n.translate('xpack.securitySolution.cases.configureCases.editFieldMappingTitle', { + return i18n.translate('xpack.cases.configureCases.editFieldMappingTitle', { values: { thirdPartyName }, defaultMessage: 'Edit { thirdPartyName } field mappings', }); }; export const FIELD_MAPPING_FIRST_COL = i18n.translate( - 'xpack.securitySolution.cases.configureCases.fieldMappingFirstCol', + 'xpack.cases.configureCases.fieldMappingFirstCol', { - defaultMessage: 'Security case field', + defaultMessage: 'Kibana case field', } ); export const FIELD_MAPPING_SECOND_COL = (thirdPartyName: string): string => { - return i18n.translate('xpack.securitySolution.cases.configureCases.fieldMappingSecondCol', { + return i18n.translate('xpack.cases.configureCases.fieldMappingSecondCol', { values: { thirdPartyName }, defaultMessage: '{ thirdPartyName } field', }); }; export const FIELD_MAPPING_THIRD_COL = i18n.translate( - 'xpack.securitySolution.cases.configureCases.fieldMappingThirdCol', + 'xpack.cases.configureCases.fieldMappingThirdCol', { defaultMessage: 'On edit and update', } ); export const FIELD_MAPPING_EDIT_NOTHING = i18n.translate( - 'xpack.securitySolution.cases.configureCases.fieldMappingEditNothing', + 'xpack.cases.configureCases.fieldMappingEditNothing', { defaultMessage: 'Nothing', } ); export const FIELD_MAPPING_EDIT_OVERWRITE = i18n.translate( - 'xpack.securitySolution.cases.configureCases.fieldMappingEditOverwrite', + 'xpack.cases.configureCases.fieldMappingEditOverwrite', { defaultMessage: 'Overwrite', } ); export const FIELD_MAPPING_EDIT_APPEND = i18n.translate( - 'xpack.securitySolution.cases.configureCases.fieldMappingEditAppend', + 'xpack.cases.configureCases.fieldMappingEditAppend', { defaultMessage: 'Append', } ); -export const CANCEL = i18n.translate('xpack.securitySolution.cases.configureCases.cancelButton', { +export const CANCEL = i18n.translate('xpack.cases.configureCases.cancelButton', { defaultMessage: 'Cancel', }); -export const SAVE = i18n.translate('xpack.securitySolution.cases.configureCases.saveButton', { +export const SAVE = i18n.translate('xpack.cases.configureCases.saveButton', { defaultMessage: 'Save', }); -export const SAVE_CLOSE = i18n.translate( - 'xpack.securitySolution.cases.configureCases.saveAndCloseButton', - { - defaultMessage: 'Save & close', - } -); +export const SAVE_CLOSE = i18n.translate('xpack.cases.configureCases.saveAndCloseButton', { + defaultMessage: 'Save & close', +}); export const WARNING_NO_CONNECTOR_TITLE = i18n.translate( - 'xpack.securitySolution.cases.configureCases.warningTitle', + 'xpack.cases.configureCases.warningTitle', { defaultMessage: 'Warning', } ); export const WARNING_NO_CONNECTOR_MESSAGE = i18n.translate( - 'xpack.securitySolution.cases.configureCases.warningMessage', + 'xpack.cases.configureCases.warningMessage', { defaultMessage: 'The selected connector has been deleted. Either select a different connector or create a new one.', @@ -190,21 +183,18 @@ export const WARNING_NO_CONNECTOR_MESSAGE = i18n.translate( ); export const MAPPING_FIELD_NOT_MAPPED = i18n.translate( - 'xpack.securitySolution.cases.configureCases.mappingFieldNotMapped', + 'xpack.cases.configureCases.mappingFieldNotMapped', { defaultMessage: 'Not mapped', } ); -export const COMMENT = i18n.translate( - 'xpack.securitySolution.cases.configureCases.commentMapping', - { - defaultMessage: 'Comments', - } -); +export const COMMENT = i18n.translate('xpack.cases.configureCases.commentMapping', { + defaultMessage: 'Comments', +}); export const NO_FIELDS_ERROR = (connectorName: string): string => { - return i18n.translate('xpack.securitySolution.cases.configureCases.noFieldsError', { + return i18n.translate('xpack.cases.configureCases.noFieldsError', { values: { connectorName }, defaultMessage: 'No { connectorName } fields found. Please check your { connectorName } connector settings or your { connectorName } instance settings to resolve.', @@ -212,28 +202,25 @@ export const NO_FIELDS_ERROR = (connectorName: string): string => { }; export const BLANK_MAPPINGS = (connectorName: string): string => { - return i18n.translate('xpack.securitySolution.cases.configureCases.blankMappings', { + return i18n.translate('xpack.cases.configureCases.blankMappings', { values: { connectorName }, defaultMessage: 'At least one field needs to be mapped to { connectorName }', }); }; export const REQUIRED_MAPPINGS = (connectorName: string, fields: string): string => { - return i18n.translate('xpack.securitySolution.cases.configureCases.requiredMappings', { + return i18n.translate('xpack.cases.configureCases.requiredMappings', { values: { connectorName, fields }, defaultMessage: 'At least one Case field needs to be mapped to the following required { connectorName } fields: { fields }', }); }; -export const UPDATE_FIELD_MAPPINGS = i18n.translate( - 'xpack.securitySolution.cases.configureCases.updateConnector', - { - defaultMessage: 'Update field mappings', - } -); +export const UPDATE_FIELD_MAPPINGS = i18n.translate('xpack.cases.configureCases.updateConnector', { + defaultMessage: 'Update field mappings', +}); export const UPDATE_SELECTED_CONNECTOR = (connectorName: string): string => { - return i18n.translate('xpack.securitySolution.cases.configureCases.updateSelectedConnector', { + return i18n.translate('xpack.cases.configureCases.updateSelectedConnector', { values: { connectorName }, defaultMessage: 'Update { connectorName }', }); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/utils.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.test.tsx rename to x-pack/plugins/cases/public/components/configure_cases/utils.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.ts b/x-pack/plugins/cases/public/components/configure_cases/utils.ts similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.ts rename to x-pack/plugins/cases/public/components/configure_cases/utils.ts index db14371b625d8..ade1a5e0c2bba 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConnectorTypeFields, ConnectorTypes } from '../../../../../cases/common/api'; +import { ConnectorTypeFields, ConnectorTypes } from '../../../common'; import { CaseField, ActionType, diff --git a/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/index.tsx b/x-pack/plugins/cases/public/components/confirm_delete_case/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/index.tsx rename to x-pack/plugins/cases/public/components/confirm_delete_case/index.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/translations.ts b/x-pack/plugins/cases/public/components/confirm_delete_case/translations.ts similarity index 50% rename from x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/translations.ts rename to x-pack/plugins/cases/public/components/confirm_delete_case/translations.ts index 07bf6966e953c..0400c4c7fef41 100644 --- a/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/translations.ts +++ b/x-pack/plugins/cases/public/components/confirm_delete_case/translations.ts @@ -6,35 +6,29 @@ */ import { i18n } from '@kbn/i18n'; -export * from '../../translations'; +export * from '../../common/translations'; export const DELETE_TITLE = (caseTitle: string) => - i18n.translate('xpack.securitySolution.cases.confirmDeleteCase.deleteTitle', { + i18n.translate('xpack.cases.confirmDeleteCase.deleteTitle', { values: { caseTitle }, defaultMessage: 'Delete "{caseTitle}"', }); export const DELETE_THIS_CASE = (caseTitle: string) => - i18n.translate('xpack.securitySolution.cases.confirmDeleteCase.deleteThisCase', { + i18n.translate('xpack.cases.confirmDeleteCase.deleteThisCase', { defaultMessage: 'Delete this case', }); -export const CONFIRM_QUESTION = i18n.translate( - 'xpack.securitySolution.cases.confirmDeleteCase.confirmQuestion', - { - defaultMessage: - 'By deleting this case, all related case data will be permanently removed and you will no longer be able to push data to an external incident management system. Are you sure you wish to proceed?', - } -); -export const DELETE_SELECTED_CASES = i18n.translate( - 'xpack.securitySolution.cases.confirmDeleteCase.selectedCases', - { - defaultMessage: 'Delete selected cases', - } -); +export const CONFIRM_QUESTION = i18n.translate('xpack.cases.confirmDeleteCase.confirmQuestion', { + defaultMessage: + 'By deleting this case, all related case data will be permanently removed and you will no longer be able to push data to an external incident management system. Are you sure you wish to proceed?', +}); +export const DELETE_SELECTED_CASES = i18n.translate('xpack.cases.confirmDeleteCase.selectedCases', { + defaultMessage: 'Delete selected cases', +}); export const CONFIRM_QUESTION_PLURAL = i18n.translate( - 'xpack.securitySolution.cases.confirmDeleteCase.confirmQuestionPlural', + 'xpack.cases.confirmDeleteCase.confirmQuestionPlural', { defaultMessage: 'By deleting these cases, all related case data will be permanently removed and you will no longer be able to push data to an external incident management system. Are you sure you wish to proceed?', diff --git a/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.test.tsx b/x-pack/plugins/cases/public/components/connector_selector/form.test.tsx similarity index 91% rename from x-pack/plugins/security_solution/public/cases/components/connector_selector/form.test.tsx rename to x-pack/plugins/cases/public/components/connector_selector/form.test.tsx index 00e827b62a34e..ec136989dd937 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.test.tsx +++ b/x-pack/plugins/cases/public/components/connector_selector/form.test.tsx @@ -7,14 +7,12 @@ import React from 'react'; import { mount } from 'enzyme'; -import { UseField, Form, useForm, FormHook } from '../../../shared_imports'; +import { UseField, Form, useForm, FormHook } from '../../common/shared_imports'; import { ConnectorSelector } from './form'; import { connectorsMock } from '../../containers/mock'; import { getFormMock } from '../__mock__/form'; -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'); const useFormMock = useForm as jest.Mock; diff --git a/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx b/x-pack/plugins/cases/public/components/connector_selector/form.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx rename to x-pack/plugins/cases/public/components/connector_selector/form.tsx index 63c6f265b1ab2..210334e93adb8 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx +++ b/x-pack/plugins/cases/public/components/connector_selector/form.tsx @@ -9,9 +9,9 @@ import React, { useCallback } from 'react'; import { isEmpty } from 'lodash/fp'; import { EuiFormRow } from '@elastic/eui'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../common/shared_imports'; import { ConnectorsDropdown } from '../configure_cases/connectors_dropdown'; -import { ActionConnector } from '../../../../../cases/common/api'; +import { ActionConnector } from '../../../common'; interface ConnectorSelectorProps { connectors: ActionConnector[]; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/card.tsx b/x-pack/plugins/cases/public/components/connectors/card.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/connectors/card.tsx rename to x-pack/plugins/cases/public/components/connectors/card.tsx index af9a86b0b711b..82a508ccf3432 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/card.tsx +++ b/x-pack/plugins/cases/public/components/connectors/card.tsx @@ -10,7 +10,7 @@ import { EuiCard, EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; import styled from 'styled-components'; import { connectorsConfiguration } from '.'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; +import { ConnectorTypes } from '../../../common'; interface ConnectorCardProps { connectorType: ConnectorTypes; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx b/x-pack/plugins/cases/public/components/connectors/case/alert_fields.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx rename to x-pack/plugins/cases/public/components/connectors/case/alert_fields.tsx index 05161456976c6..0c44bcab70679 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/case/alert_fields.tsx @@ -11,8 +11,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; -import { ActionParamsProps } from '../../../../../../triggers_actions_ui/public/types'; -import { CommentType } from '../../../../../../cases/common/api'; +import { ActionParamsProps } from '../../../../../triggers_actions_ui/public/types'; +import { CommentType } from '../../../../common'; import { CaseActionParams } from './types'; import { ExistingCase } from './existing_case'; @@ -36,8 +36,6 @@ const CaseParamsFields: React.FunctionComponent { const { caseId = null, comment = defaultAlertComment } = actionParams.subActionParams ?? {}; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/cases_dropdown.tsx b/x-pack/plugins/cases/public/components/connectors/case/cases_dropdown.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/connectors/case/cases_dropdown.tsx rename to x-pack/plugins/cases/public/components/connectors/case/cases_dropdown.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx b/x-pack/plugins/cases/public/components/connectors/case/existing_case.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx rename to x-pack/plugins/cases/public/components/connectors/case/existing_case.tsx index 3c6c5f47c6d12..22798843dd856 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx +++ b/x-pack/plugins/cases/public/components/connectors/case/existing_case.tsx @@ -6,7 +6,7 @@ */ import React, { memo, useMemo, useCallback } from 'react'; -import { CaseType } from '../../../../../../cases/common/api'; +import { CaseType } from '../../../../common'; import { useGetCases, DEFAULT_QUERY_PARAMS, diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/index.ts b/x-pack/plugins/cases/public/components/connectors/case/index.ts similarity index 93% rename from x-pack/plugins/security_solution/public/cases/components/connectors/case/index.ts rename to x-pack/plugins/cases/public/components/connectors/case/index.ts index 4f7a720ea6410..c2cf4980da7ec 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/case/index.ts @@ -8,7 +8,7 @@ import { lazy } from 'react'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ActionTypeModel } from '../../../../../../triggers_actions_ui/public/types'; +import { ActionTypeModel } from '../../../../../triggers_actions_ui/public/types'; import { CaseActionParams } from './types'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/translations.ts b/x-pack/plugins/cases/public/components/connectors/case/translations.ts similarity index 64% rename from x-pack/plugins/security_solution/public/cases/components/connectors/case/translations.ts rename to x-pack/plugins/cases/public/components/connectors/case/translations.ts index 1d15a3da496a6..8304aaef5765c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/translations.ts +++ b/x-pack/plugins/cases/public/components/connectors/case/translations.ts @@ -7,80 +7,80 @@ import { i18n } from '@kbn/i18n'; -export * from '../../../translations'; +export * from '../../../common/translations'; export const CASE_CONNECTOR_DESC = i18n.translate( - 'xpack.securitySolution.cases.components.connectors.cases.selectMessageText', + 'xpack.cases.components.connectors.cases.selectMessageText', { defaultMessage: 'Create or update a case.', } ); export const CASE_CONNECTOR_TITLE = i18n.translate( - 'xpack.securitySolution.cases.components.connectors.cases.actionTypeTitle', + 'xpack.cases.components.connectors.cases.actionTypeTitle', { defaultMessage: 'Cases', } ); export const CASE_CONNECTOR_COMMENT_LABEL = i18n.translate( - 'xpack.securitySolution.cases.components.connectors.cases.commentLabel', + 'xpack.cases.components.connectors.cases.commentLabel', { defaultMessage: 'Comment', } ); export const CASE_CONNECTOR_COMMENT_REQUIRED = i18n.translate( - 'xpack.securitySolution.cases.components.connectors.cases.commentRequired', + 'xpack.cases.components.connectors.cases.commentRequired', { defaultMessage: 'Comment is required.', } ); export const CASE_CONNECTOR_CASES_DROPDOWN_ROW_LABEL = i18n.translate( - 'xpack.securitySolution.cases.components.connectors.cases.casesDropdownRowLabel', + 'xpack.cases.components.connectors.cases.casesDropdownRowLabel', { defaultMessage: 'Case allowing sub-cases', } ); export const CASE_CONNECTOR_CASES_DROPDOWN_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.cases.components.connectors.cases.casesDropdownPlaceholder', + 'xpack.cases.components.connectors.cases.casesDropdownPlaceholder', { defaultMessage: 'Select case', } ); export const CASE_CONNECTOR_CASES_OPTION_NEW_CASE = i18n.translate( - 'xpack.securitySolution.cases.components.connectors.cases.optionAddNewCase', + 'xpack.cases.components.connectors.cases.optionAddNewCase', { defaultMessage: 'Add to a new case', } ); export const CASE_CONNECTOR_CASES_OPTION_EXISTING_CASE = i18n.translate( - 'xpack.securitySolution.cases.components.connectors.cases.optionAddToExistingCase', + 'xpack.cases.components.connectors.cases.optionAddToExistingCase', { defaultMessage: 'Add to existing case', } ); export const CASE_CONNECTOR_CASE_REQUIRED = i18n.translate( - 'xpack.securitySolution.cases.components.connectors.cases.caseRequired', + 'xpack.cases.components.connectors.cases.caseRequired', { defaultMessage: 'You must select a case.', } ); export const CASE_CONNECTOR_CALL_OUT_TITLE = i18n.translate( - 'xpack.securitySolution.cases.components.connectors.cases.callOutTitle', + 'xpack.cases.components.connectors.cases.callOutTitle', { defaultMessage: 'Generated alerts will be attached to sub-cases', } ); export const CASE_CONNECTOR_CALL_OUT_MSG = i18n.translate( - 'xpack.securitySolution.cases.components.connectors.cases.callOutMsg', + 'xpack.cases.components.connectors.cases.callOutMsg', { defaultMessage: 'A case can contain multiple sub-cases to allow grouping of generated alerts. Sub-cases will give more granular control over the status of these generated alerts and prevents having too many alerts attached to one case.', @@ -88,21 +88,21 @@ export const CASE_CONNECTOR_CALL_OUT_MSG = i18n.translate( ); export const CASE_CONNECTOR_ADD_NEW_CASE = i18n.translate( - 'xpack.securitySolution.cases.components.connectors.cases.addNewCaseOption', + 'xpack.cases.components.connectors.cases.addNewCaseOption', { defaultMessage: 'Add new case', } ); export const CREATE_CASE = i18n.translate( - 'xpack.securitySolution.cases.components.connectors.cases.createCaseLabel', + 'xpack.cases.components.connectors.cases.createCaseLabel', { defaultMessage: 'Create case', } ); export const CONNECTED_CASE = i18n.translate( - 'xpack.securitySolution.cases.components.connectors.cases.connectedCaseLabel', + 'xpack.cases.components.connectors.cases.connectedCaseLabel', { defaultMessage: 'Connected case', } diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/types.ts b/x-pack/plugins/cases/public/components/connectors/case/types.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/connectors/case/types.ts rename to x-pack/plugins/cases/public/components/connectors/case/types.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/config.ts b/x-pack/plugins/cases/public/components/connectors/config.ts similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/connectors/config.ts rename to x-pack/plugins/cases/public/components/connectors/config.ts index 1d12d4b98a823..e8d87511c7e17 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/config.ts +++ b/x-pack/plugins/cases/public/components/connectors/config.ts @@ -11,7 +11,7 @@ import { getServiceNowSIRActionType, getJiraActionType, // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../triggers_actions_ui/public/common'; +} from '../../../../triggers_actions_ui/public/common'; import { ConnectorConfiguration } from './types'; const resilient = getResilientActionType(); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/connectors_registry.ts b/x-pack/plugins/cases/public/components/connectors/connectors_registry.ts similarity index 61% rename from x-pack/plugins/security_solution/public/cases/components/connectors/connectors_registry.ts rename to x-pack/plugins/cases/public/components/connectors/connectors_registry.ts index d6896a8ac8c80..2e02cb290c3c8 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/connectors_registry.ts +++ b/x-pack/plugins/cases/public/components/connectors/connectors_registry.ts @@ -8,8 +8,6 @@ import { i18n } from '@kbn/i18n'; import { CaseConnector, CaseConnectorsRegistry } from './types'; -/* eslint-disable @typescript-eslint/no-explicit-any */ - export const createCaseConnectorsRegistry = (): CaseConnectorsRegistry => { const connectors: Map> = new Map(); @@ -18,15 +16,12 @@ export const createCaseConnectorsRegistry = (): CaseConnectorsRegistry => { register: (connector: CaseConnector) => { if (connectors.has(connector.id)) { throw new Error( - i18n.translate( - 'xpack.securitySolution.caseConnectorsRegistry.register.duplicateCaseConnectorErrorMessage', - { - defaultMessage: 'Object type "{id}" is already registered.', - values: { - id: connector.id, - }, - } - ) + i18n.translate('xpack.cases.connecors.register.duplicateCaseConnectorErrorMessage', { + defaultMessage: 'Object type "{id}" is already registered.', + values: { + id: connector.id, + }, + }) ); } @@ -35,15 +30,12 @@ export const createCaseConnectorsRegistry = (): CaseConnectorsRegistry => { get: (id: string): CaseConnector => { if (!connectors.has(id)) { throw new Error( - i18n.translate( - 'xpack.securitySolution.caseConnectorsRegistry.get.missingCaseConnectorErrorMessage', - { - defaultMessage: 'Object type "{id}" is not registered.', - values: { - id, - }, - } - ) + i18n.translate('xpack.cases.connecors.get.missingCaseConnectorErrorMessage', { + defaultMessage: 'Object type "{id}" is not registered.', + values: { + id, + }, + }) ); } return connectors.get(id)!; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/fields_form.tsx b/x-pack/plugins/cases/public/components/connectors/fields_form.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/connectors/fields_form.tsx rename to x-pack/plugins/cases/public/components/connectors/fields_form.tsx index 841c2a9e38f6d..d71da6f87689d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/fields_form.tsx +++ b/x-pack/plugins/cases/public/components/connectors/fields_form.tsx @@ -10,7 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import { CaseActionConnector, ConnectorFieldsProps } from './types'; import { getCaseConnectors } from '.'; -import { ConnectorTypeFields } from '../../../../../cases/common/api/connectors'; +import { ConnectorTypeFields } from '../../../common'; interface Props extends Omit, 'connector'> { connector: CaseActionConnector | null; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts b/x-pack/plugins/cases/public/components/connectors/index.ts similarity index 93% rename from x-pack/plugins/security_solution/public/cases/components/connectors/index.ts rename to x-pack/plugins/cases/public/components/connectors/index.ts index dad7070aad705..71ba161eb63c9 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/index.ts @@ -15,9 +15,9 @@ import { ServiceNowITSMFieldsType, ServiceNowSIRFieldsType, ResilientFieldsType, -} from '../../../../../cases/common/api/connectors'; +} from '../../../common'; -export { getActionType as getCaseConnectorUI } from './case'; +export { getActionType as getCaseConnectorUi } from './case'; export * from './config'; export * from './types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/__mocks__/api.ts b/x-pack/plugins/cases/public/components/connectors/jira/__mocks__/api.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/__mocks__/api.ts rename to x-pack/plugins/cases/public/components/connectors/jira/__mocks__/api.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/api.test.ts b/x-pack/plugins/cases/public/components/connectors/jira/api.test.ts similarity index 98% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/api.test.ts rename to x-pack/plugins/cases/public/components/connectors/jira/api.test.ts index 7190a44f3ab1f..bbab8a14b5ed9 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/api.test.ts +++ b/x-pack/plugins/cases/public/components/connectors/jira/api.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; import { getIssueTypes, getFieldsByIssueType, getIssues, getIssue } from './api'; const issueTypesResponse = { diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/api.ts b/x-pack/plugins/cases/public/components/connectors/jira/api.ts similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/api.ts rename to x-pack/plugins/cases/public/components/connectors/jira/api.ts index 4ebb06192e62d..dff3e3a5b41ab 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/api.ts +++ b/x-pack/plugins/cases/public/components/connectors/jira/api.ts @@ -6,7 +6,7 @@ */ import { HttpSetup } from 'kibana/public'; -import { ActionTypeExecutorResult } from '../../../../../../actions/common'; +import { ActionTypeExecutorResult } from '../../../../../actions/common'; import { IssueTypes, Fields, Issues, Issue } from './types'; export const BASE_ACTION_API_PATH = '/api/actions'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.test.tsx rename to x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx index b151d41c4cdd8..38a1e30616200 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx @@ -18,12 +18,11 @@ import { useGetSingleIssue } from './use_get_single_issue'; import { useGetIssues } from './use_get_issues'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -jest.mock('../../../../common/lib/kibana'); jest.mock('./use_get_issue_types'); jest.mock('./use_get_fields_by_issue_type'); jest.mock('./use_get_single_issue'); jest.mock('./use_get_issues'); - +jest.mock('../../../common/lib/kibana'); const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; const useGetSingleIssueMock = useGetSingleIssue as jest.Mock; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.tsx rename to x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx index 22e80d43f34e1..6aff81f380015 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx @@ -10,8 +10,8 @@ import { map } from 'lodash/fp'; import { EuiFormRow, EuiSelect, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import * as i18n from './translations'; -import { ConnectorTypes, JiraFieldsType } from '../../../../../../cases/common/api/connectors'; -import { useKibana } from '../../../../common/lib/kibana'; +import { ConnectorTypes, JiraFieldsType } from '../../../../common'; +import { useKibana } from '../../../common/lib/kibana'; import { ConnectorFieldsProps } from '../types'; import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/index.ts b/x-pack/plugins/cases/public/components/connectors/jira/index.ts similarity index 89% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/index.ts rename to x-pack/plugins/cases/public/components/connectors/jira/index.ts index 40e59a081a449..ea408a1bd6664 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/jira/index.ts @@ -8,7 +8,7 @@ import { lazy } from 'react'; import { CaseConnector } from '../types'; -import { JiraFieldsType } from '../../../../../../cases/common/api/connectors'; +import { JiraFieldsType } from '../../../../common'; import * as i18n from './translations'; export * from './types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/search_issues.tsx b/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/search_issues.tsx rename to x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx index 3fdc17b7157d6..79ac42e034c6a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/search_issues.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx @@ -8,8 +8,8 @@ import React, { useMemo, useEffect, useCallback, useState, memo } from 'react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { useKibana } from '../../../../common/lib/kibana'; -import { ActionConnector } from '../../../containers/types'; +import { useKibana } from '../../../common/lib/kibana'; +import { ActionConnector } from '../../../../common'; import { useGetIssues } from './use_get_issues'; import { useGetSingleIssue } from './use_get_single_issue'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/translations.ts b/x-pack/plugins/cases/public/components/connectors/jira/translations.ts similarity index 50% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/translations.ts rename to x-pack/plugins/cases/public/components/connectors/jira/translations.ts index a4948d61f952c..88dd7d0c7c27b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/translations.ts +++ b/x-pack/plugins/cases/public/components/connectors/jira/translations.ts @@ -8,70 +8,61 @@ import { i18n } from '@kbn/i18n'; export const ISSUE_TYPES_API_ERROR = i18n.translate( - 'xpack.securitySolution.components.connectors.jira.unableToGetIssueTypesMessage', + 'xpack.cases.connectors.jira.unableToGetIssueTypesMessage', { defaultMessage: 'Unable to get issue types', } ); export const FIELDS_API_ERROR = i18n.translate( - 'xpack.securitySolution.components.connectors.jira.unableToGetFieldsMessage', + 'xpack.cases.connectors.jira.unableToGetFieldsMessage', { defaultMessage: 'Unable to get connectors', } ); export const ISSUES_API_ERROR = i18n.translate( - 'xpack.securitySolution.components.connectors.jira.unableToGetIssuesMessage', + 'xpack.cases.connectors.jira.unableToGetIssuesMessage', { defaultMessage: 'Unable to get issues', } ); export const GET_ISSUE_API_ERROR = (id: string) => - i18n.translate('xpack.securitySolution.components.connectors.jira.unableToGetIssueMessage', { + i18n.translate('xpack.cases.connectors.jira.unableToGetIssueMessage', { defaultMessage: 'Unable to get issue with id {id}', values: { id }, }); export const SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL = i18n.translate( - 'xpack.securitySolution.components.connectors.jira.searchIssuesComboBoxAriaLabel', + 'xpack.cases.connectors.jira.searchIssuesComboBoxAriaLabel', { defaultMessage: 'Type to search', } ); export const SEARCH_ISSUES_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.components.connectors.jira.searchIssuesComboBoxPlaceholder', + 'xpack.cases.connectors.jira.searchIssuesComboBoxPlaceholder', { defaultMessage: 'Type to search', } ); export const SEARCH_ISSUES_LOADING = i18n.translate( - 'xpack.securitySolution.components.connectors.jira.searchIssuesLoading', + 'xpack.cases.connectors.jira.searchIssuesLoading', { defaultMessage: 'Loading...', } ); -export const PRIORITY = i18n.translate( - 'xpack.securitySolution.cases.connectors.jira.prioritySelectFieldLabel', - { - defaultMessage: 'Priority', - } -); +export const PRIORITY = i18n.translate('xpack.cases.connectors.jira.prioritySelectFieldLabel', { + defaultMessage: 'Priority', +}); -export const ISSUE_TYPE = i18n.translate( - 'xpack.securitySolution.cases.connectors.jira.issueTypesSelectFieldLabel', - { - defaultMessage: 'Issue type', - } -); +export const ISSUE_TYPE = i18n.translate('xpack.cases.connectors.jira.issueTypesSelectFieldLabel', { + defaultMessage: 'Issue type', +}); -export const PARENT_ISSUE = i18n.translate( - 'xpack.securitySolution.cases.connectors.jira.parentIssueSearchLabel', - { - defaultMessage: 'Parent issue', - } -); +export const PARENT_ISSUE = i18n.translate('xpack.cases.connectors.jira.parentIssueSearchLabel', { + defaultMessage: 'Parent issue', +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/types.ts b/x-pack/plugins/cases/public/components/connectors/jira/types.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/types.ts rename to x-pack/plugins/cases/public/components/connectors/jira/types.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_fields_by_issue_type.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.test.tsx rename to x-pack/plugins/cases/public/components/connectors/jira/use_get_fields_by_issue_type.test.tsx index 4ef5f14da2238..b4c2c848d79ed 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_fields_by_issue_type.test.tsx @@ -7,12 +7,12 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../common/lib/kibana'; import { connector } from '../mock'; import { useGetFieldsByIssueType, UseGetFieldsByIssueType } from './use_get_fields_by_issue_type'; import * as api from './api'; -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana'); jest.mock('./api'); const useKibanaMock = useKibana as jest.Mocked; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_fields_by_issue_type.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx rename to x-pack/plugins/cases/public/components/connectors/jira/use_get_fields_by_issue_type.tsx index 03000e8916617..a4958d91c88aa 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_fields_by_issue_type.tsx @@ -7,7 +7,7 @@ import { useState, useEffect, useRef } from 'react'; import { HttpSetup, ToastsApi } from 'kibana/public'; -import { ActionConnector } from '../../../containers/types'; +import { ActionConnector } from '../../../../common'; import { getFieldsByIssueType } from './api'; import { Fields } from './types'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue_types.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.test.tsx rename to x-pack/plugins/cases/public/components/connectors/jira/use_get_issue_types.test.tsx index ee32d93c655be..6c1a9b5fcab08 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue_types.test.tsx @@ -7,12 +7,12 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../common/lib/kibana'; import { connector } from '../mock'; import { useGetIssueTypes, UseGetIssueTypes } from './use_get_issue_types'; import * as api from './api'; -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana'); jest.mock('./api'); const useKibanaMock = useKibana as jest.Mocked; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue_types.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx rename to x-pack/plugins/cases/public/components/connectors/jira/use_get_issue_types.tsx index 3c35d315a2bcd..447491d2a2fff 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue_types.tsx @@ -7,7 +7,7 @@ import { useState, useEffect, useRef } from 'react'; import { HttpSetup, ToastsApi } from 'kibana/public'; -import { ActionConnector } from '../../../containers/types'; +import { ActionConnector } from '../../../../common'; import { getIssueTypes } from './api'; import { IssueTypes } from './types'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.test.tsx rename to x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.test.tsx index ee1d4ffd3d8ae..2308fe604e710 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.test.tsx @@ -7,12 +7,12 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../common/lib/kibana'; import { connector as actionConnector, issues } from '../mock'; import { useGetIssues, UseGetIssues } from './use_get_issues'; import * as api from './api'; -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana'); jest.mock('./api'); const useKibanaMock = useKibana as jest.Mocked; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx rename to x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx index b44b0558f1536..e4b6f5e4dea01 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx @@ -8,7 +8,7 @@ import { isEmpty, debounce } from 'lodash/fp'; import { useState, useEffect, useRef } from 'react'; import { HttpSetup, ToastsApi } from 'kibana/public'; -import { ActionConnector } from '../../../containers/types'; +import { ActionConnector } from '../../../../common'; import { getIssues } from './api'; import { Issues } from './types'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_single_issue.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.test.tsx rename to x-pack/plugins/cases/public/components/connectors/jira/use_get_single_issue.test.tsx index ba9752ca71811..28949b456ecdd 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_single_issue.test.tsx @@ -7,12 +7,12 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../common/lib/kibana'; import { connector as actionConnector, issues } from '../mock'; import { useGetSingleIssue, UseGetSingleIssue } from './use_get_single_issue'; import * as api from './api'; -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana'); jest.mock('./api'); const useKibanaMock = useKibana as jest.Mocked; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_single_issue.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx rename to x-pack/plugins/cases/public/components/connectors/jira/use_get_single_issue.tsx index 6c70286426168..e26940a40d39f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_single_issue.tsx @@ -7,7 +7,7 @@ import { useState, useEffect, useRef } from 'react'; import { HttpSetup, ToastsApi } from 'kibana/public'; -import { ActionConnector } from '../../../containers/types'; +import { ActionConnector } from '../../../../common'; import { getIssue } from './api'; import { Issue } from './types'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts b/x-pack/plugins/cases/public/components/connectors/mock.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts rename to x-pack/plugins/cases/public/components/connectors/mock.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/__mocks__/api.ts b/x-pack/plugins/cases/public/components/connectors/resilient/__mocks__/api.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/connectors/resilient/__mocks__/api.ts rename to x-pack/plugins/cases/public/components/connectors/resilient/__mocks__/api.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/api.ts b/x-pack/plugins/cases/public/components/connectors/resilient/api.ts similarity index 93% rename from x-pack/plugins/security_solution/public/cases/components/connectors/resilient/api.ts rename to x-pack/plugins/cases/public/components/connectors/resilient/api.ts index 6d57f38fa961c..5fec83f303950 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/api.ts +++ b/x-pack/plugins/cases/public/components/connectors/resilient/api.ts @@ -6,7 +6,7 @@ */ import { HttpSetup } from 'kibana/public'; -import { ActionTypeExecutorResult } from '../../../../../../actions/common'; +import { ActionTypeExecutorResult } from '../../../../../actions/common'; import { ResilientIncidentTypes, ResilientSeverity } from './types'; export const BASE_ACTION_API_PATH = '/api/actions'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.test.tsx rename to x-pack/plugins/cases/public/components/connectors/resilient/case_fields.test.tsx index dd13083288020..dda6ba5de95cc 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.test.tsx @@ -15,7 +15,7 @@ import { useGetIncidentTypes } from './use_get_incident_types'; import { useGetSeverity } from './use_get_severity'; import Fields from './case_fields'; -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana'); jest.mock('./use_get_incident_types'); jest.mock('./use_get_severity'); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.tsx rename to x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx index b1fbfb1169d08..e1eeb13bf684c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx @@ -15,13 +15,13 @@ import { EuiSpacer, } from '@elastic/eui'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../common/lib/kibana'; import { ConnectorFieldsProps } from '../types'; import { useGetIncidentTypes } from './use_get_incident_types'; import { useGetSeverity } from './use_get_severity'; import * as i18n from './translations'; -import { ConnectorTypes, ResilientFieldsType } from '../../../../../../cases/common/api/connectors'; +import { ConnectorTypes, ResilientFieldsType } from '../../../../common'; import { ConnectorCard } from '../card'; const ResilientFieldsComponent: React.FunctionComponent< diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/index.ts b/x-pack/plugins/cases/public/components/connectors/resilient/index.ts similarity index 88% rename from x-pack/plugins/security_solution/public/cases/components/connectors/resilient/index.ts rename to x-pack/plugins/cases/public/components/connectors/resilient/index.ts index 8a2603f39e102..c8e7ad9a063cb 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/resilient/index.ts @@ -8,7 +8,7 @@ import { lazy } from 'react'; import { CaseConnector } from '../types'; -import { ResilientFieldsType } from '../../../../../../cases/common/api/connectors'; +import { ResilientFieldsType } from '../../../../common'; import * as i18n from './translations'; export * from './types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/translations.ts b/x-pack/plugins/cases/public/components/connectors/resilient/translations.ts similarity index 60% rename from x-pack/plugins/security_solution/public/cases/components/connectors/resilient/translations.ts rename to x-pack/plugins/cases/public/components/connectors/resilient/translations.ts index 4f8061f48aa68..1b63a5098e92a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/translations.ts +++ b/x-pack/plugins/cases/public/components/connectors/resilient/translations.ts @@ -8,36 +8,33 @@ import { i18n } from '@kbn/i18n'; export const INCIDENT_TYPES_API_ERROR = i18n.translate( - 'xpack.securitySolution.cases.connectors.resilient.unableToGetIncidentTypesMessage', + 'xpack.cases.connectors.resilient.unableToGetIncidentTypesMessage', { defaultMessage: 'Unable to get incident types', } ); export const SEVERITY_API_ERROR = i18n.translate( - 'xpack.securitySolution.cases.connectors.resilient.unableToGetSeverityMessage', + 'xpack.cases.connectors.resilient.unableToGetSeverityMessage', { defaultMessage: 'Unable to get severity', } ); export const INCIDENT_TYPES_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.cases.connectors.resilient.incidentTypesPlaceholder', + 'xpack.cases.connectors.resilient.incidentTypesPlaceholder', { defaultMessage: 'Choose types', } ); export const INCIDENT_TYPES_LABEL = i18n.translate( - 'xpack.securitySolution.cases.connectors.resilient.incidentTypesLabel', + 'xpack.cases.connectors.resilient.incidentTypesLabel', { defaultMessage: 'Incident Types', } ); -export const SEVERITY_LABEL = i18n.translate( - 'xpack.securitySolution.cases.connectors.resilient.severityLabel', - { - defaultMessage: 'Severity', - } -); +export const SEVERITY_LABEL = i18n.translate('xpack.cases.connectors.resilient.severityLabel', { + defaultMessage: 'Severity', +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/types.ts b/x-pack/plugins/cases/public/components/connectors/resilient/types.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/connectors/resilient/types.ts rename to x-pack/plugins/cases/public/components/connectors/resilient/types.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.test.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_incident_types.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.test.tsx rename to x-pack/plugins/cases/public/components/connectors/resilient/use_get_incident_types.test.tsx index 19ce6d653f9fd..59c1f8e9b40d0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_incident_types.test.tsx @@ -7,12 +7,12 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../common/lib/kibana'; import { connector } from '../mock'; import { useGetIncidentTypes, UseGetIncidentTypes } from './use_get_incident_types'; import * as api from './api'; -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana'); jest.mock('./api'); const useKibanaMock = useKibana as jest.Mocked; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_incident_types.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx rename to x-pack/plugins/cases/public/components/connectors/resilient/use_get_incident_types.tsx index 34cbb0a69b0f4..530b56de8796d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx +++ b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_incident_types.tsx @@ -7,7 +7,7 @@ import { useState, useEffect, useRef } from 'react'; import { HttpSetup, ToastsApi } from 'kibana/public'; -import { ActionConnector } from '../../../containers/types'; +import { ActionConnector } from '../../../../common'; import { getIncidentTypes } from './api'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.test.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_severity.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.test.tsx rename to x-pack/plugins/cases/public/components/connectors/resilient/use_get_severity.test.tsx index 614ba3c236f06..f646dd7e8f7c2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_severity.test.tsx @@ -7,12 +7,12 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../common/lib/kibana'; import { connector } from '../mock'; import { useGetSeverity, UseGetSeverity } from './use_get_severity'; import * as api from './api'; -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana'); jest.mock('./api'); const useKibanaMock = useKibana as jest.Mocked; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_severity.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx rename to x-pack/plugins/cases/public/components/connectors/resilient/use_get_severity.tsx index 5b44c6b4a32b2..8753e3926ffe5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx +++ b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_severity.tsx @@ -7,7 +7,7 @@ import { useState, useEffect, useRef } from 'react'; import { HttpSetup, ToastsApi } from 'kibana/public'; -import { ActionConnector } from '../../../containers/types'; +import { ActionConnector } from '../../../../common'; import { getSeverity } from './api'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/__mocks__/api.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/__mocks__/api.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/__mocks__/api.ts rename to x-pack/plugins/cases/public/components/connectors/servicenow/__mocks__/api.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.test.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/api.test.ts similarity index 93% rename from x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.test.ts rename to x-pack/plugins/cases/public/components/connectors/servicenow/api.test.ts index 6a6bb7e947997..461823036ed21 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.test.ts +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/api.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; import { getChoices } from './api'; import { choices } from '../mock'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/api.ts similarity index 91% rename from x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.ts rename to x-pack/plugins/cases/public/components/connectors/servicenow/api.ts index d91ad9f8762bd..e68eb18860ae3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.ts +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/api.ts @@ -6,7 +6,7 @@ */ import { HttpSetup } from 'kibana/public'; -import { ActionTypeExecutorResult } from '../../../../../../actions/common'; +import { ActionTypeExecutorResult } from '../../../../../actions/common'; import { Choice } from './types'; export const BASE_ACTION_API_PATH = '/api/actions'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/helpers.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/helpers.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/helpers.ts rename to x-pack/plugins/cases/public/components/connectors/servicenow/helpers.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/index.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/index.ts similarity index 88% rename from x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/index.ts rename to x-pack/plugins/cases/public/components/connectors/servicenow/index.ts index b342095c39ff0..a6f0795fe4d8f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/index.ts @@ -8,10 +8,7 @@ import { lazy } from 'react'; import { CaseConnector } from '../types'; -import { - ServiceNowITSMFieldsType, - ServiceNowSIRFieldsType, -} from '../../../../../../cases/common/api/connectors'; +import { ServiceNowITSMFieldsType, ServiceNowSIRFieldsType } from '../../../../common'; import * as i18n from './translations'; export const getServiceNowITSMCaseConnector = (): CaseConnector => { diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx rename to x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx index 6e2bdec360fdf..9688ca191d672 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx @@ -16,7 +16,7 @@ import Fields from './servicenow_itsm_case_fields'; let onChoicesSuccess = (c: Choice[]) => {}; -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana'); jest.mock('./use_get_choices', () => ({ useGetChoices: (args: { onSuccess: () => void }) => { onChoicesSuccess = args.onSuccess; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx rename to x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index accb8450802d4..710e230958354 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -10,11 +10,8 @@ import { EuiFormRow, EuiSelect, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@el import * as i18n from './translations'; import { ConnectorFieldsProps } from '../types'; -import { - ConnectorTypes, - ServiceNowITSMFieldsType, -} from '../../../../../../cases/common/api/connectors'; -import { useKibana } from '../../../../common/lib/kibana'; +import { ConnectorTypes, ServiceNowITSMFieldsType } from '../../../../common'; +import { useKibana } from '../../../common/lib/kibana'; import { ConnectorCard } from '../card'; import { useGetChoices } from './use_get_choices'; import { Fields, Choice } from './types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx rename to x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx index 7cd32a0cbfbf3..4a5b34cd3c3cb 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx @@ -16,7 +16,7 @@ import Fields from './servicenow_sir_case_fields'; let onChoicesSuccess = (c: Choice[]) => {}; -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana'); jest.mock('./use_get_choices', () => ({ useGetChoices: (args: { onSuccess: () => void }) => { onChoicesSuccess = args.onSuccess; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx rename to x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index 63502e3454fcf..1f9a7cf7acd64 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -8,11 +8,8 @@ import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { EuiFormRow, EuiSelect, EuiFlexGroup, EuiFlexItem, EuiCheckbox } from '@elastic/eui'; -import { - ConnectorTypes, - ServiceNowSIRFieldsType, -} from '../../../../../../cases/common/api/connectors'; -import { useKibana } from '../../../../common/lib/kibana'; +import { ConnectorTypes, ServiceNowSIRFieldsType } from '../../../../common'; +import { useKibana } from '../../../common/lib/kibana'; import { ConnectorFieldsProps } from '../types'; import { ConnectorCard } from '../card'; import { useGetChoices } from './use_get_choices'; diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts new file mode 100644 index 0000000000000..fc48ecf17f2c6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts @@ -0,0 +1,75 @@ +/* + * 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 URGENCY = i18n.translate('xpack.cases.connectors.serviceNow.urgencySelectFieldLabel', { + defaultMessage: 'Urgency', +}); + +export const SEVERITY = i18n.translate( + 'xpack.cases.connectors.serviceNow.severitySelectFieldLabel', + { + defaultMessage: 'Severity', + } +); + +export const IMPACT = i18n.translate('xpack.cases.connectors.serviceNow.impactSelectFieldLabel', { + defaultMessage: 'Impact', +}); + +export const CHOICES_API_ERROR = i18n.translate( + 'xpack.cases.connectors.serviceNow.unableToGetChoicesMessage', + { + defaultMessage: 'Unable to get choices', + } +); + +export const MALWARE_URL = i18n.translate('xpack.cases.connectors.serviceNow.malwareURLTitle', { + defaultMessage: 'Malware URL', +}); + +export const MALWARE_HASH = i18n.translate('xpack.cases.connectors.serviceNow.malwareHashTitle', { + defaultMessage: 'Malware Hash', +}); + +export const CATEGORY = i18n.translate('xpack.cases.connectors.serviceNow.categoryTitle', { + defaultMessage: 'Category', +}); + +export const SUBCATEGORY = i18n.translate('xpack.cases.connectors.serviceNow.subcategoryTitle', { + defaultMessage: 'Subcategory', +}); + +export const SOURCE_IP = i18n.translate('xpack.cases.connectors.serviceNow.sourceIPTitle', { + defaultMessage: 'Source IP', +}); + +export const DEST_IP = i18n.translate('xpack.cases.connectors.serviceNow.destinationIPTitle', { + defaultMessage: 'Destination IP', +}); + +export const PRIORITY = i18n.translate( + 'xpack.cases.connectors.serviceNow.prioritySelectFieldTitle', + { + defaultMessage: 'Priority', + } +); + +export const ALERT_FIELDS_LABEL = i18n.translate( + 'xpack.cases.connectors.serviceNow.alertFieldsTitle', + { + defaultMessage: 'Select Observables to push', + } +); + +export const ALERT_FIELD_ENABLED_TEXT = i18n.translate( + 'xpack.cases.connectors.serviceNow.alertFieldEnabledText', + { + defaultMessage: 'Yes', + } +); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/types.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts rename to x-pack/plugins/cases/public/components/connectors/servicenow/types.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.test.tsx rename to x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx index 2492fbaaf5a83..9f88da9f35eb5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx @@ -7,14 +7,14 @@ import { renderHook } from '@testing-library/react-hooks'; -import { useKibana } from '../../../../common/lib/kibana'; -import { ActionConnector } from '../../../containers/types'; +import { useKibana } from '../../../common/lib/kibana'; +import { ActionConnector } from '../../../../common'; import { choices } from '../mock'; import { useGetChoices, UseGetChoices, UseGetChoicesProps } from './use_get_choices'; import * as api from './api'; jest.mock('./api'); -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; const onSuccess = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx rename to x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.tsx index a979f96d84ab2..4edf740a60011 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.tsx @@ -7,7 +7,7 @@ import { useState, useEffect, useRef } from 'react'; import { HttpSetup, ToastsApi } from 'kibana/public'; -import { ActionConnector } from '../../../containers/types'; +import { ActionConnector } from '../../../../common'; import { getChoices } from './api'; import { Choice } from './types'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts b/x-pack/plugins/cases/public/components/connectors/types.ts similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/connectors/types.ts rename to x-pack/plugins/cases/public/components/connectors/types.ts index 11452b966670b..fc2f66d331700 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts +++ b/x-pack/plugins/cases/public/components/connectors/types.ts @@ -12,9 +12,9 @@ import { CaseField, ActionConnector, ConnectorTypeFields, -} from '../../../../../cases/common/api'; +} from '../../../common'; -export { ThirdPartyField as AllThirdPartyFields } from '../../../../../cases/common/api'; +export { ThirdPartyField as AllThirdPartyFields } from '../../../common'; export type CaseActionConnector = ActionConnector; export interface ThirdPartyField { diff --git a/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx similarity index 76% rename from x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx rename to x-pack/plugins/cases/public/components/create/connector.test.tsx index 9c5a4a0784af1..9eb475f54221d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.test.tsx @@ -10,17 +10,16 @@ import { mount } from 'enzyme'; import { act, waitFor } from '@testing-library/react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { useForm, Form, FormHook } from '../../../shared_imports'; +import { useForm, Form, FormHook } from '../../common/shared_imports'; import { connectorsMock } from '../../containers/mock'; import { Connector } from './connector'; -import { useConnectors } from '../../containers/configure/use_connectors'; import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; import { useGetSeverity } from '../connectors/resilient/use_get_severity'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { incidentTypes, severity, choices } from '../connectors/mock'; import { schema, FormProps } from './schema'; -jest.mock('../../../common/lib/kibana', () => { +jest.mock('../../common/lib/kibana', () => { return { useKibana: () => ({ services: { @@ -30,12 +29,11 @@ jest.mock('../../../common/lib/kibana', () => { }), }; }); -jest.mock('../../containers/configure/use_connectors'); + jest.mock('../connectors/resilient/use_get_incident_types'); jest.mock('../connectors/resilient/use_get_severity'); jest.mock('../connectors/servicenow/use_get_choices'); -const useConnectorsMock = useConnectors as jest.Mock; const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; const useGetChoicesMock = useGetChoices as jest.Mock; @@ -55,6 +53,12 @@ const useGetChoicesResponse = { choices, }; +const defaultProps = { + connectors: connectorsMock, + isLoading: false, + isLoadingConnectors: false, +}; + describe('Connector', () => { let globalForm: FormHook; @@ -74,7 +78,6 @@ describe('Connector', () => { beforeEach(() => { jest.resetAllMocks(); - useConnectorsMock.mockReturnValue({ loading: false, connectors: connectorsMock }); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); useGetChoicesMock.mockReturnValue(useGetChoicesResponse); @@ -83,7 +86,7 @@ describe('Connector', () => { it('it renders', async () => { const wrapper = mount( - + ); @@ -102,36 +105,26 @@ describe('Connector', () => { }); }); - it('it is loading when fetching connectors', async () => { - useConnectorsMock.mockReturnValue({ loading: true, connectors: connectorsMock }); + it('it is disabled and loading when isLoadingConnectors=true', async () => { const wrapper = mount( - + ); expect( wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('isLoading') ).toEqual(true); - }); - - it('it is disabled when fetching connectors', async () => { - useConnectorsMock.mockReturnValue({ loading: true, connectors: connectorsMock }); - const wrapper = mount( - - - - ); expect(wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('disabled')).toEqual( true ); }); - it('it is disabled and loading when passing loading as true', async () => { + it('it is disabled and loading when isLoading=true', async () => { const wrapper = mount( - + ); @@ -146,16 +139,13 @@ describe('Connector', () => { it(`it should change connector`, async () => { const wrapper = mount( - + ); - await waitFor(() => { - expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy(); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find(`button[data-test-subj="dropdown-connector-resilient-2"]`).simulate('click'); - wrapper.update(); - }); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-resilient-2"]`).simulate('click'); await waitFor(() => { wrapper.update(); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx b/x-pack/plugins/cases/public/components/create/connector.tsx similarity index 81% rename from x-pack/plugins/security_solution/public/cases/components/create/connector.tsx rename to x-pack/plugins/cases/public/components/create/connector.tsx index 7912d97528cd2..9591933806946 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.tsx @@ -8,17 +8,18 @@ import React, { memo, useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { ConnectorTypes } from '../../../../../cases/common/api'; -import { UseField, useFormData, FieldHook, useFormContext } from '../../../shared_imports'; -import { useConnectors } from '../../containers/configure/use_connectors'; +import { ConnectorTypes } from '../../../common'; +import { UseField, useFormData, FieldHook, useFormContext } from '../../common/shared_imports'; import { ConnectorSelector } from '../connector_selector/form'; import { ConnectorFieldsForm } from '../connectors/fields_form'; -import { ActionConnector } from '../../containers/types'; +import { ActionConnector } from '../../../common'; import { getConnectorById } from '../configure_cases/utils'; import { FormProps } from './schema'; interface Props { + connectors: ActionConnector[]; isLoading: boolean; + isLoadingConnectors: boolean; hideConnectorServiceNowSir?: boolean; } @@ -55,16 +56,17 @@ const ConnectorFields = ({ ); }; -const ConnectorComponent: React.FC = ({ hideConnectorServiceNowSir = false, isLoading }) => { +const ConnectorComponent: React.FC = ({ + connectors, + hideConnectorServiceNowSir = false, + isLoading, + isLoadingConnectors, +}) => { const { getFields } = useFormContext(); - const { loading: isLoadingConnectors, connectors } = useConnectors(); - const handleConnectorChange = useCallback( - (newConnector) => { - const { fields } = getFields(); - fields.setValue(null); - }, - [getFields] - ); + const handleConnectorChange = useCallback(() => { + const { fields } = getFields(); + fields.setValue(null); + }, [getFields]); return ( diff --git a/x-pack/plugins/security_solution/public/cases/components/create/description.test.tsx b/x-pack/plugins/cases/public/components/create/description.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/create/description.test.tsx rename to x-pack/plugins/cases/public/components/create/description.test.tsx index 7d7b5278bf8a7..fcd1f82d64a53 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/description.test.tsx +++ b/x-pack/plugins/cases/public/components/create/description.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { act } from '@testing-library/react'; -import { useForm, Form, FormHook } from '../../../shared_imports'; +import { useForm, Form, FormHook } from '../../common/shared_imports'; import { Description } from './description'; import { schema, FormProps } from './schema'; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/description.tsx b/x-pack/plugins/cases/public/components/create/description.tsx similarity index 84% rename from x-pack/plugins/security_solution/public/cases/components/create/description.tsx rename to x-pack/plugins/cases/public/components/create/description.tsx index 0191dfdb929e5..0a7102cff1ad5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/description.tsx +++ b/x-pack/plugins/cases/public/components/create/description.tsx @@ -6,9 +6,8 @@ */ import React, { memo } from 'react'; -import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form'; -import { UseField } from '../../../shared_imports'; - +import { MarkdownEditorForm } from '../markdown_editor'; +import { UseField } from '../../common/shared_imports'; interface Props { isLoading: boolean; } diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.test.tsx b/x-pack/plugins/cases/public/components/create/flyout.test.tsx similarity index 68% rename from x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.test.tsx rename to x-pack/plugins/cases/public/components/create/flyout.test.tsx index 08fca0cc6e009..5187029ab60c7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout.test.tsx @@ -5,13 +5,11 @@ * 2.0. */ -/* eslint-disable react/display-name */ import React, { ReactNode } from 'react'; import { mount } from 'enzyme'; -import '../../../common/mock/match_media'; -import { CreateCaseModal } from './create_case_modal'; -import { TestProviders } from '../../../common/mock'; +import { CreateCaseFlyout } from './flyout'; +import { TestProviders } from '../../common/mock'; jest.mock('../create/form_context', () => { return { @@ -56,15 +54,14 @@ jest.mock('../create/submit_button', () => { }; }); -const onCloseCaseModal = jest.fn(); +const onCloseFlyout = jest.fn(); const onSuccess = jest.fn(); const defaultProps = { - isModalOpen: true, - onCloseCaseModal, + onCloseFlyout, onSuccess, }; -describe('CreateCaseModal', () => { +describe('CreateCaseFlyout', () => { beforeEach(() => { jest.resetAllMocks(); }); @@ -72,38 +69,28 @@ describe('CreateCaseModal', () => { it('renders', () => { const wrapper = mount( - + ); - expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeTruthy(); - }); - - it('it does not render the modal isModalOpen=false ', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj='create-case-flyout']`).exists()).toBeTruthy(); }); it('Closing modal calls onCloseCaseModal', () => { const wrapper = mount( - + ); - wrapper.find('.euiModal__closeIcon').first().simulate('click'); - expect(onCloseCaseModal).toBeCalled(); + wrapper.find('.euiFlyout__closeButton').first().simulate('click'); + expect(onCloseFlyout).toBeCalled(); }); it('pass the correct props to FormContext component', () => { const wrapper = mount( - + ); @@ -118,7 +105,7 @@ describe('CreateCaseModal', () => { it('onSuccess called when creating a case', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/cases/public/components/create/flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout.tsx new file mode 100644 index 0000000000000..8ed09865e9eab --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/flyout.tsx @@ -0,0 +1,71 @@ +/* + * 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, { memo } from 'react'; +import styled from 'styled-components'; +import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui'; + +import { FormContext } from '../create/form_context'; +import { CreateCaseForm } from '../create/form'; +import { SubmitCaseButton } from '../create/submit_button'; +import { Case } from '../../containers/types'; +import * as i18n from '../../common/translations'; + +export interface CreateCaseModalProps { + onCloseFlyout: () => void; + onSuccess: (theCase: Case) => Promise; + afterCaseCreated?: (theCase: Case) => Promise; +} + +const Container = styled.div` + ${({ theme }) => ` + margin-top: ${theme.eui.euiSize}; + text-align: right; + `} +`; + +const StyledFlyout = styled(EuiFlyout)` + ${({ theme }) => ` + z-index: ${theme.eui.euiZModal}; + `} +`; + +// Adding bottom padding because timeline's +// bottom bar gonna hide the submit button. +const FormWrapper = styled.div` + padding-bottom: 50px; +`; + +const CreateCaseFlyoutComponent: React.FC = ({ + onSuccess, + afterCaseCreated, + onCloseFlyout, +}) => { + return ( + + + +

{i18n.CREATE_TITLE}

+
+
+ + + + + + + + + + +
+ ); +}; + +export const CreateCaseFlyout = memo(CreateCaseFlyoutComponent); + +CreateCaseFlyout.displayName = 'CreateCaseFlyout'; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx rename to x-pack/plugins/cases/public/components/create/form.test.tsx index 029965444929b..9e59924bdf483 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { act, waitFor } from '@testing-library/react'; -import { useForm, Form, FormHook } from '../../../shared_imports'; +import { useForm, Form, FormHook } from '../../common/shared_imports'; import { useGetTags } from '../../containers/use_get_tags'; import { useConnectors } from '../../containers/configure/use_connectors'; import { connectorsMock } from '../../containers/mock'; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx similarity index 84% rename from x-pack/plugins/security_solution/public/cases/components/create/form.tsx rename to x-pack/plugins/cases/public/components/create/form.tsx index 09518c6f6adc1..83f759947ba65 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -9,7 +9,7 @@ import React, { useMemo } from 'react'; import { EuiLoadingSpinner, EuiSteps } from '@elastic/eui'; import styled, { css } from 'styled-components'; -import { useFormContext } from '../../../shared_imports'; +import { useFormContext } from '../../common/shared_imports'; import { Title } from './title'; import { Description } from './description'; @@ -17,6 +17,7 @@ import { Tags } from './tags'; import { Connector } from './connector'; import * as i18n from './translations'; import { SyncAlertsToggle } from './sync_alerts_toggle'; +import { ActionConnector } from '../../../common'; interface ContainerProps { big?: boolean; @@ -36,12 +37,19 @@ const MySpinner = styled(EuiLoadingSpinner)` `; interface Props { + connectors?: ActionConnector[]; hideConnectorServiceNowSir?: boolean; + isLoadingConnectors?: boolean; withSteps?: boolean; } - +const empty: ActionConnector[] = []; export const CreateCaseForm: React.FC = React.memo( - ({ hideConnectorServiceNowSir = false, withSteps = true }) => { + ({ + connectors = empty, + isLoadingConnectors = false, + hideConnectorServiceNowSir = false, + withSteps = true, + }) => { const { isSubmitting } = useFormContext(); const firstStep = useMemo( @@ -80,13 +88,15 @@ export const CreateCaseForm: React.FC = React.memo( children: ( ), }), - [hideConnectorServiceNowSir, isSubmitting] + [connectors, hideConnectorServiceNowSir, isLoadingConnectors, isSubmitting] ); const allSteps = useMemo(() => [firstStep, secondStep, thirdStep], [ diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx rename to x-pack/plugins/cases/public/components/create/form_context.test.tsx index 99626c4cfb797..9a8671c7fc571 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -10,9 +10,10 @@ import { mount, ReactWrapper } from 'enzyme'; import { act, waitFor } from '@testing-library/react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { ConnectorTypes } from '../../../../../cases/common/api'; -import { TestProviders } from '../../../common/mock'; +import { ConnectorTypes } from '../../../common'; +import { TestProviders } from '../../common/mock'; import { usePostCase } from '../../containers/use_post_case'; +import { usePostComment } from '../../containers/use_post_comment'; import { useGetTags } from '../../containers/use_get_tags'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; @@ -41,6 +42,7 @@ import { usePostPushToService } from '../../containers/use_post_push_to_service' const sampleId = 'case-id'; jest.mock('../../containers/use_post_case'); +jest.mock('../../containers/use_post_comment'); jest.mock('../../containers/use_post_push_to_service'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_connectors'); @@ -56,6 +58,7 @@ jest.mock('../connectors/servicenow/use_get_choices'); const useConnectorsMock = useConnectors as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; const usePostCaseMock = usePostCase as jest.Mock; +const usePostCommentMock = usePostComment as jest.Mock; const usePostPushToServiceMock = usePostPushToService as jest.Mock; const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; @@ -71,6 +74,11 @@ const defaultPostCase = { postCase, }; +const defaultCreateCaseForm = { + isLoadingConnectors: false, + connectors: [], +}; + const defaultPostPushToService = { isLoading: false, isError: false, @@ -99,14 +107,15 @@ describe('Create case', () => { const fetchTags = jest.fn(); const onFormSubmitSuccess = jest.fn(); const afterCaseCreated = jest.fn(); + const postComment = jest.fn(); - beforeEach(() => { - jest.resetAllMocks(); + beforeAll(() => { postCase.mockResolvedValue({ id: sampleId, ...sampleData, }); usePostCaseMock.mockImplementation(() => defaultPostCase); + usePostCommentMock.mockImplementation(() => ({ postComment })); usePostPushToServiceMock.mockImplementation(() => defaultPostPushToService); useConnectorsMock.mockReturnValue(sampleConnectorData); useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); @@ -121,13 +130,16 @@ describe('Create case', () => { fetchTags, })); }); + beforeEach(() => { + jest.clearAllMocks(); + }); describe('Step 1 - Case Fields', () => { it('it renders', async () => { const wrapper = mount( - + @@ -151,7 +163,7 @@ describe('Create case', () => { const wrapper = mount( - + @@ -171,7 +183,7 @@ describe('Create case', () => { const wrapper = mount( - + @@ -206,7 +218,7 @@ describe('Create case', () => { const wrapper = mount( - + @@ -256,7 +268,7 @@ describe('Create case', () => { const wrapper = mount( - + @@ -281,7 +293,7 @@ describe('Create case', () => { const wrapper = mount( - + @@ -348,7 +360,7 @@ describe('Create case', () => { const wrapper = mount( - + @@ -416,7 +428,7 @@ describe('Create case', () => { const wrapper = mount( - + @@ -506,7 +518,7 @@ describe('Create case', () => { const wrapper = mount( - + @@ -604,7 +616,7 @@ describe('Create case', () => { const wrapper = mount( - + @@ -622,10 +634,13 @@ describe('Create case', () => { wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); await waitFor(() => { - expect(afterCaseCreated).toHaveBeenCalledWith({ - id: sampleId, - ...sampleData, - }); + expect(afterCaseCreated).toHaveBeenCalledWith( + { + id: sampleId, + ...sampleData, + }, + postComment + ); }); }); @@ -638,7 +653,7 @@ describe('Create case', () => { const wrapper = mount( - + diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx similarity index 75% rename from x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx rename to x-pack/plugins/cases/public/components/create/form_context.tsx index b575dfe42f074..7ca3fe4b88c8d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useEffect, useMemo } from 'react'; import { schema, FormProps } from './schema'; -import { Form, useForm } from '../../../shared_imports'; +import { Form, useForm } from '../../common/shared_imports'; import { getConnectorById, getNoneConnector, @@ -19,7 +19,8 @@ import { usePostPushToService } from '../../containers/use_post_push_to_service' import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { Case } from '../../containers/types'; -import { CaseType, ConnectorTypes } from '../../../../../cases/common/api'; +import { CaseType, ConnectorTypes } from '../../../common'; +import { UsePostComment, usePostComment } from '../../containers/use_post_comment'; const initialCaseValue: FormProps = { description: '', @@ -31,8 +32,9 @@ const initialCaseValue: FormProps = { }; interface Props { - afterCaseCreated?: (theCase: Case) => Promise; + afterCaseCreated?: (theCase: Case, postComment: UsePostComment['postComment']) => Promise; caseType?: CaseType; + children?: JSX.Element | JSX.Element[]; hideConnectorServiceNowSir?: boolean; onSuccess?: (theCase: Case) => Promise; } @@ -44,9 +46,10 @@ export const FormContext: React.FC = ({ hideConnectorServiceNowSir, onSuccess, }) => { - const { connectors } = useConnectors(); + const { connectors, loading: isLoadingConnectors } = useConnectors(); const { connector: configurationConnector } = useCaseConfigure(); const { postCase } = usePostCase(); + const { postComment } = usePostComment(); const { pushCaseToExternalService } = usePostPushToService(); const connectorId = useMemo(() => { @@ -86,7 +89,7 @@ export const FormContext: React.FC = ({ }); if (afterCaseCreated && updatedCase) { - await afterCaseCreated(updatedCase); + await afterCaseCreated(updatedCase, postComment); } if (updatedCase?.id && dataConnectorId !== 'none') { @@ -101,7 +104,15 @@ export const FormContext: React.FC = ({ } } }, - [caseType, connectors, postCase, onSuccess, pushCaseToExternalService, afterCaseCreated] + [ + caseType, + connectors, + postCase, + postComment, + onSuccess, + pushCaseToExternalService, + afterCaseCreated, + ] ); const { form } = useForm({ @@ -114,7 +125,16 @@ export const FormContext: React.FC = ({ // Set the selected connector to the configuration connector useEffect(() => setFieldValue('connectorId', connectorId), [connectorId, setFieldValue]); - return
{children}
; + const childrenWithExtraProp = useMemo( + () => + children != null + ? React.Children.map(children, (child: React.ReactElement) => + React.cloneElement(child, { connectors, isLoadingConnectors }) + ) + : null, + [children, connectors, isLoadingConnectors] + ); + return
{childrenWithExtraProp}
; }; FormContext.displayName = 'FormContext'; diff --git a/x-pack/plugins/cases/public/components/create/index.test.tsx b/x-pack/plugins/cases/public/components/create/index.test.tsx new file mode 100644 index 0000000000000..e82af8edc6337 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/index.test.tsx @@ -0,0 +1,126 @@ +/* + * 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, ReactWrapper } from 'enzyme'; +import { act, waitFor } from '@testing-library/react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { TestProviders } from '../../common/mock'; +import { useGetTags } from '../../containers/use_get_tags'; +import { useConnectors } from '../../containers/configure/use_connectors'; +import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; +import { useGetSeverity } from '../connectors/resilient/use_get_severity'; +import { useGetIssueTypes } from '../connectors/jira/use_get_issue_types'; +import { useGetFieldsByIssueType } from '../connectors/jira/use_get_fields_by_issue_type'; +import { useCaseConfigureResponse } from '../configure_cases/__mock__'; +import { + sampleConnectorData, + sampleData, + sampleTags, + useGetIncidentTypesResponse, + useGetSeverityResponse, + useGetIssueTypesResponse, + useGetFieldsByIssueTypeResponse, +} from './mock'; +import { CreateCase } from '.'; + +jest.mock('../../containers/api'); +jest.mock('../../containers/use_get_tags'); +jest.mock('../../containers/configure/use_connectors'); +jest.mock('../../containers/configure/use_configure'); +jest.mock('../connectors/resilient/use_get_incident_types'); +jest.mock('../connectors/resilient/use_get_severity'); +jest.mock('../connectors/jira/use_get_issue_types'); +jest.mock('../connectors/jira/use_get_fields_by_issue_type'); +jest.mock('../connectors/jira/use_get_single_issue'); +jest.mock('../connectors/jira/use_get_issues'); + +const useConnectorsMock = useConnectors as jest.Mock; +const useCaseConfigureMock = useCaseConfigure as jest.Mock; +const useGetTagsMock = useGetTags as jest.Mock; +const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; +const useGetSeverityMock = useGetSeverity as jest.Mock; +const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; +const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; +const fetchTags = jest.fn(); + +const fillForm = (wrapper: ReactWrapper) => { + wrapper + .find(`[data-test-subj="caseTitle"] input`) + .first() + .simulate('change', { target: { value: sampleData.title } }); + + wrapper + .find(`[data-test-subj="caseDescription"] textarea`) + .first() + .simulate('change', { target: { value: sampleData.description } }); + + act(() => { + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange(sampleTags.map((tag) => ({ label: tag }))); + }); +}; + +const defaultProps = { + onCancel: jest.fn(), + onSuccess: jest.fn(), +}; + +describe('CreateCase case', () => { + beforeEach(() => { + jest.resetAllMocks(); + useConnectorsMock.mockReturnValue(sampleConnectorData); + useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); + useGetSeverityMock.mockReturnValue(useGetSeverityResponse); + useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); + useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse); + useGetTagsMock.mockImplementation(() => ({ + tags: sampleTags, + fetchTags, + })); + }); + + it('it renders', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="create-case-submit"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="create-case-cancel"]`).exists()).toBeTruthy(); + }); + + it('should call cancel on cancel click', async () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="create-case-cancel"]`).first().simulate('click'); + expect(defaultProps.onCancel).toHaveBeenCalled(); + }); + + it('should redirect to new case when posting the case', async () => { + const wrapper = mount( + + + + ); + + fillForm(wrapper); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => { + expect(defaultProps.onSuccess).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/index.tsx b/x-pack/plugins/cases/public/components/create/index.tsx new file mode 100644 index 0000000000000..a1de4d9730b9f --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/index.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; + +import { Field, getUseField } from '../../common/shared_imports'; +import * as i18n from './translations'; +import { CreateCaseForm } from './form'; +import { FormContext } from './form_context'; +import { SubmitCaseButton } from './submit_button'; +import { Case } from '../../containers/types'; +import { CaseType } from '../../../common/api/cases'; +import { CasesTimelineIntegration, CasesTimelineIntegrationProvider } from '../timeline_context'; +import { fieldName as descriptionFieldName } from './description'; +import { InsertTimeline } from '../insert_timeline'; +import { UsePostComment } from '../../containers/use_post_comment'; + +export const CommonUseField = getUseField({ component: Field }); + +const Container = styled.div` + ${({ theme }) => ` + margin-top: ${theme.eui.euiSize}; + `} +`; + +export interface CreateCaseProps { + afterCaseCreated?: (theCase: Case, postComment: UsePostComment['postComment']) => Promise; + caseType?: CaseType; + hideConnectorServiceNowSir?: boolean; + onCancel: () => void; + onSuccess: (theCase: Case) => Promise; + timelineIntegration?: CasesTimelineIntegration; + withSteps?: boolean; +} + +export const CreateCase = ({ + afterCaseCreated, + caseType, + hideConnectorServiceNowSir, + onCancel, + onSuccess, + timelineIntegration, + withSteps, +}: CreateCaseProps) => ( + + + + + + + + {i18n.CANCEL} + + + + + + + + + + +); + +// eslint-disable-next-line import/no-default-export +export { CreateCase as default }; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/mock.ts b/x-pack/plugins/cases/public/components/create/mock.ts similarity index 92% rename from x-pack/plugins/security_solution/public/cases/components/create/mock.ts rename to x-pack/plugins/cases/public/components/create/mock.ts index 6e17be8d53e5a..eb40fa097d3cc 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/mock.ts +++ b/x-pack/plugins/cases/public/components/create/mock.ts @@ -5,8 +5,7 @@ * 2.0. */ -import { CasePostRequest, CaseType } from '../../../../../cases/common/api'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; +import { CasePostRequest, CaseType, ConnectorTypes } from '../../../common'; import { choices } from '../connectors/mock'; export const sampleTags = ['coke', 'pepsi']; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/optional_field_label/index.test.tsx b/x-pack/plugins/cases/public/components/create/optional_field_label/index.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/create/optional_field_label/index.test.tsx rename to x-pack/plugins/cases/public/components/create/optional_field_label/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/create/optional_field_label/index.tsx b/x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx similarity index 89% rename from x-pack/plugins/security_solution/public/cases/components/create/optional_field_label/index.tsx rename to x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx index f67090a1cd41c..ea994b2219961 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/optional_field_label/index.tsx +++ b/x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx @@ -8,7 +8,7 @@ import { EuiText } from '@elastic/eui'; import React from 'react'; -import * as i18n from '../../../translations'; +import * as i18n from '../../../common/translations'; export const OptionalFieldLabel = ( diff --git a/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/cases/components/create/schema.tsx rename to x-pack/plugins/cases/public/components/create/schema.tsx index b069a484d314c..7ca1e2e061545 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import { CasePostRequest, ConnectorTypeFields } from '../../../../../cases/common/api'; -import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../shared_imports'; +import { CasePostRequest, ConnectorTypeFields } from '../../../common'; +import { FIELD_TYPES, fieldValidators, FormSchema } from '../../common/shared_imports'; import * as i18n from './translations'; import { OptionalFieldLabel } from './optional_field_label'; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/submit_button.test.tsx b/x-pack/plugins/cases/public/components/create/submit_button.test.tsx similarity index 77% rename from x-pack/plugins/security_solution/public/cases/components/create/submit_button.test.tsx rename to x-pack/plugins/cases/public/components/create/submit_button.test.tsx index ab98e75b6058e..62279500616ee 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/submit_button.test.tsx +++ b/x-pack/plugins/cases/public/components/create/submit_button.test.tsx @@ -7,9 +7,9 @@ import React from 'react'; import { mount } from 'enzyme'; -import { act, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; -import { useForm, Form } from '../../../shared_imports'; +import { useForm, Form } from '../../common/shared_imports'; import { SubmitCaseButton } from './submit_button'; import { schema, FormProps } from './schema'; @@ -29,7 +29,7 @@ describe('SubmitCaseButton', () => { }; beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('it renders', async () => { @@ -48,11 +48,7 @@ describe('SubmitCaseButton', () => { ); - - await act(async () => { - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - }); - + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); await waitFor(() => expect(onSubmit).toBeCalled()); }); @@ -63,12 +59,12 @@ describe('SubmitCaseButton', () => { ); - await waitFor(() => { - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => expect( wrapper.find(`[data-test-subj="create-case-submit"]`).first().prop('isDisabled') - ).toBeTruthy(); - }); + ).toBeTruthy() + ); }); it('it is loading when submitting', async () => { @@ -78,11 +74,11 @@ describe('SubmitCaseButton', () => { ); - await waitFor(() => { - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => expect( wrapper.find(`[data-test-subj="create-case-submit"]`).first().prop('isLoading') - ).toBeTruthy(); - }); + ).toBeTruthy() + ); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/submit_button.tsx b/x-pack/plugins/cases/public/components/create/submit_button.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/cases/components/create/submit_button.tsx rename to x-pack/plugins/cases/public/components/create/submit_button.tsx index de2b2d410e60e..b5e58517e6ec1 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/submit_button.tsx +++ b/x-pack/plugins/cases/public/components/create/submit_button.tsx @@ -8,7 +8,7 @@ import React, { memo } from 'react'; import { EuiButton } from '@elastic/eui'; -import { useFormContext } from '../../../shared_imports'; +import { useFormContext } from '../../common/shared_imports'; import * as i18n from './translations'; const SubmitCaseButtonComponent: React.FC = () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.test.tsx b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.test.tsx rename to x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx index eadec1525ed90..b4a37f0abb518 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.test.tsx +++ b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { waitFor } from '@testing-library/react'; -import { useForm, Form, FormHook } from '../../../shared_imports'; +import { useForm, Form, FormHook } from '../../common/shared_imports'; import { SyncAlertsToggle } from './sync_alerts_toggle'; import { schema, FormProps } from './schema'; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.tsx b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx similarity index 93% rename from x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.tsx rename to x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx index 2ab5b8f5375cd..bed8e6d18f5e3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.tsx +++ b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx @@ -6,7 +6,7 @@ */ import React, { memo } from 'react'; -import { Field, getUseField, useFormData } from '../../../shared_imports'; +import { Field, getUseField, useFormData } from '../../common/shared_imports'; import * as i18n from './translations'; const CommonUseField = getUseField({ component: Field }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/tags.test.tsx b/x-pack/plugins/cases/public/components/create/tags.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/create/tags.test.tsx rename to x-pack/plugins/cases/public/components/create/tags.test.tsx index c723d456afe73..2eddb83dcac29 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/tags.test.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.test.tsx @@ -10,7 +10,7 @@ import { mount } from 'enzyme'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { waitFor } from '@testing-library/react'; -import { useForm, Form, FormHook } from '../../../shared_imports'; +import { useForm, Form, FormHook } from '../../common/shared_imports'; import { useGetTags } from '../../containers/use_get_tags'; import { Tags } from './tags'; import { schema, FormProps } from './schema'; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/tags.tsx b/x-pack/plugins/cases/public/components/create/tags.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/cases/components/create/tags.tsx rename to x-pack/plugins/cases/public/components/create/tags.tsx index fd0372e2f8125..ac0b67529e15a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/tags.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.tsx @@ -7,7 +7,7 @@ import React, { memo, useMemo } from 'react'; -import { Field, getUseField } from '../../../shared_imports'; +import { Field, getUseField } from '../../common/shared_imports'; import { useGetTags } from '../../containers/use_get_tags'; const CommonUseField = getUseField({ component: Field }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/title.test.tsx b/x-pack/plugins/cases/public/components/create/title.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/create/title.test.tsx rename to x-pack/plugins/cases/public/components/create/title.test.tsx index 2ac14ccd1b254..a41d5afbb4038 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/title.test.tsx +++ b/x-pack/plugins/cases/public/components/create/title.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { act } from '@testing-library/react'; -import { useForm, Form, FormHook } from '../../../shared_imports'; +import { useForm, Form, FormHook } from '../../common/shared_imports'; import { Title } from './title'; import { schema, FormProps } from './schema'; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/title.tsx b/x-pack/plugins/cases/public/components/create/title.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/cases/components/create/title.tsx rename to x-pack/plugins/cases/public/components/create/title.tsx index 95f705791e704..cc51a805b5c38 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/title.tsx +++ b/x-pack/plugins/cases/public/components/create/title.tsx @@ -6,7 +6,7 @@ */ import React, { memo } from 'react'; -import { Field, getUseField } from '../../../shared_imports'; +import { Field, getUseField } from '../../common/shared_imports'; const CommonUseField = getUseField({ component: Field }); diff --git a/x-pack/plugins/cases/public/components/create/translations.ts b/x-pack/plugins/cases/public/components/create/translations.ts new file mode 100644 index 0000000000000..7e0f7e5a6b9d5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/translations.ts @@ -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 { i18n } from '@kbn/i18n'; + +export * from '../../common/translations'; + +export const STEP_ONE_TITLE = i18n.translate('xpack.cases.create.stepOneTitle', { + defaultMessage: 'Case fields', +}); + +export const STEP_TWO_TITLE = i18n.translate('xpack.cases.create.stepTwoTitle', { + defaultMessage: 'Case settings', +}); + +export const STEP_THREE_TITLE = i18n.translate('xpack.cases.create.stepThreeTitle', { + defaultMessage: 'External Connector Fields', +}); + +export const SYNC_ALERTS_LABEL = i18n.translate('xpack.cases.create.syncAlertsLabel', { + defaultMessage: 'Sync alert status with case status', +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/helpers.ts b/x-pack/plugins/cases/public/components/edit_connector/helpers.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/edit_connector/helpers.ts rename to x-pack/plugins/cases/public/components/edit_connector/helpers.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.test.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/edit_connector/index.test.tsx rename to x-pack/plugins/cases/public/components/edit_connector/index.test.tsx index 113c5da5d0c0f..3b6d4bd3f33f2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.test.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx @@ -10,14 +10,12 @@ import { mount } from 'enzyme'; import { EditConnector } from './index'; import { getFormMock, useFormMock } from '../__mock__/form'; -import { TestProviders } from '../../../common/mock'; +import { TestProviders } from '../../common/mock'; import { connectorsMock } from '../../containers/configure/mock'; import { waitFor } from '@testing-library/react'; import { caseUserActions } from '../../containers/mock'; -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'); const onSubmit = jest.fn(); const defaultProps = { diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx rename to x-pack/plugins/cases/public/components/edit_connector/index.tsx index f76adfd2a840f..56f1a77fc407e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.tsx @@ -20,10 +20,9 @@ import { import styled from 'styled-components'; import { noop } from 'lodash/fp'; -import { Form, UseField, useForm } from '../../../shared_imports'; -import { ConnectorTypeFields } from '../../../../../cases/common/api/connectors'; +import { Form, UseField, useForm } from '../../common/shared_imports'; +import { ActionConnector, ConnectorTypeFields } from '../../../common'; import { ConnectorSelector } from '../connector_selector/form'; -import { ActionConnector } from '../../../../../cases/common/api'; import { ConnectorFieldsForm } from '../connectors/fields_form'; import { getConnectorById } from '../configure_cases/utils'; import { CaseUserActions } from '../../containers/types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/schema.tsx b/x-pack/plugins/cases/public/components/edit_connector/schema.tsx similarity index 85% rename from x-pack/plugins/security_solution/public/cases/components/edit_connector/schema.tsx rename to x-pack/plugins/cases/public/components/edit_connector/schema.tsx index f757c2b6a86c4..a12511f704be2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/edit_connector/schema.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/schema.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { FormSchema, FIELD_TYPES } from '../../../shared_imports'; +import { FormSchema, FIELD_TYPES } from '../../common/shared_imports'; export interface FormProps { connectorId: string; diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/translations.ts b/x-pack/plugins/cases/public/components/edit_connector/translations.ts similarity index 79% rename from x-pack/plugins/security_solution/public/cases/components/edit_connector/translations.ts rename to x-pack/plugins/cases/public/components/edit_connector/translations.ts index 12fa0d1855062..ab69c94321703 100644 --- a/x-pack/plugins/security_solution/public/cases/components/edit_connector/translations.ts +++ b/x-pack/plugins/cases/public/components/edit_connector/translations.ts @@ -7,10 +7,10 @@ import { i18n } from '@kbn/i18n'; -export * from '../../translations'; +export * from '../../common/translations'; export const EDIT_CONNECTOR_ARIA = i18n.translate( - 'xpack.securitySolution.cases.editConnector.editConnectorLinkAria', + 'xpack.cases.editConnector.editConnectorLinkAria', { defaultMessage: 'click to edit connector', } diff --git a/x-pack/plugins/cases/public/components/empty_value/__snapshots__/empty_value.test.tsx.snap b/x-pack/plugins/cases/public/components/empty_value/__snapshots__/empty_value.test.tsx.snap new file mode 100644 index 0000000000000..142ed7a0d7175 --- /dev/null +++ b/x-pack/plugins/cases/public/components/empty_value/__snapshots__/empty_value.test.tsx.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EmptyValue it renders against snapshot 1`] = ` +

+ (Empty String) +

+`; diff --git a/x-pack/plugins/cases/public/components/empty_value/empty_value.test.tsx b/x-pack/plugins/cases/public/components/empty_value/empty_value.test.tsx new file mode 100644 index 0000000000000..e1dfc71867f6e --- /dev/null +++ b/x-pack/plugins/cases/public/components/empty_value/empty_value.test.tsx @@ -0,0 +1,166 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mountWithIntl } from '@kbn/test/jest'; + +import { + defaultToEmptyTag, + getEmptyString, + getEmptyStringTag, + getEmptyTagValue, + getEmptyValue, + getOrEmptyTag, +} from '.'; +import { getMockTheme } from '../../common/lib/kibana/kibana_react.mock'; + +describe('EmptyValue', () => { + const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } }); + + test('it renders against snapshot', () => { + const wrapper = shallow(

{getEmptyString()}

); + expect(wrapper).toMatchSnapshot(); + }); + + describe('#getEmptyValue', () => { + test('should return an empty value', () => expect(getEmptyValue()).toBe('—')); + }); + + describe('#getEmptyString', () => { + test('should turn into an empty string place holder', () => { + const wrapper = mountWithIntl( + +

{getEmptyString()}

+
+ ); + expect(wrapper.text()).toBe('(Empty String)'); + }); + }); + + describe('#getEmptyTagValue', () => { + const wrapper = mount( + +

{getEmptyTagValue()}

+
+ ); + test('should return an empty tag value', () => expect(wrapper.text()).toBe('—')); + }); + + describe('#getEmptyStringTag', () => { + test('should turn into an span that has length of 1', () => { + const wrapper = mountWithIntl( + +

{getEmptyStringTag()}

+
+ ); + expect(wrapper.find('span')).toHaveLength(1); + }); + + test('should turn into an empty string tag place holder', () => { + const wrapper = mountWithIntl( + +

{getEmptyStringTag()}

+
+ ); + expect(wrapper.text()).toBe(getEmptyString()); + }); + }); + + describe('#defaultToEmptyTag', () => { + test('should default to an empty value when a value is null', () => { + const wrapper = mount( + +

{defaultToEmptyTag(null)}

+
+ ); + expect(wrapper.text()).toBe(getEmptyValue()); + }); + + test('should default to an empty value when a value is undefined', () => { + const wrapper = mount( + +

{defaultToEmptyTag(undefined)}

+
+ ); + expect(wrapper.text()).toBe(getEmptyValue()); + }); + + test('should return a deep path value', () => { + const test = { + a: { + b: { + c: 1, + }, + }, + }; + const wrapper = mount(

{defaultToEmptyTag(test.a.b.c)}

); + expect(wrapper.text()).toBe('1'); + }); + }); + + describe('#getOrEmptyTag', () => { + test('should default empty value when a deep rooted value is null', () => { + const test = { + a: { + b: { + c: null, + }, + }, + }; + const wrapper = mount( + +

{getOrEmptyTag('a.b.c', test)}

+
+ ); + expect(wrapper.text()).toBe(getEmptyValue()); + }); + + test('should default empty value when a deep rooted value is undefined', () => { + const test = { + a: { + b: { + c: undefined, + }, + }, + }; + const wrapper = mount( + +

{getOrEmptyTag('a.b.c', test)}

+
+ ); + expect(wrapper.text()).toBe(getEmptyValue()); + }); + + test('should default empty value when a deep rooted value is missing', () => { + const test = { + a: { + b: {}, + }, + }; + const wrapper = mount( + +

{getOrEmptyTag('a.b.c', test)}

+
+ ); + expect(wrapper.text()).toBe(getEmptyValue()); + }); + + test('should return a deep path value', () => { + const test = { + a: { + b: { + c: 1, + }, + }, + }; + const wrapper = mount(

{getOrEmptyTag('a.b.c', test)}

); + expect(wrapper.text()).toBe('1'); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/empty_value/index.tsx b/x-pack/plugins/cases/public/components/empty_value/index.tsx new file mode 100644 index 0000000000000..86efb4a78277a --- /dev/null +++ b/x-pack/plugins/cases/public/components/empty_value/index.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get, isString } from 'lodash/fp'; +import React from 'react'; +import styled from 'styled-components'; + +import * as i18n from './translations'; + +const EmptyWrapper = styled.span` + color: ${(props) => props.theme.eui.euiColorMediumShade}; +`; + +EmptyWrapper.displayName = 'EmptyWrapper'; + +export const getEmptyValue = () => '—'; +export const getEmptyString = () => `(${i18n.EMPTY_STRING})`; + +export const getEmptyTagValue = () => {getEmptyValue()}; +export const getEmptyStringTag = () => {getEmptyString()}; + +export const defaultToEmptyTag = (item: T): JSX.Element => { + if (item == null) { + return getEmptyTagValue(); + } else if (isString(item) && item === '') { + return getEmptyStringTag(); + } else { + return <>{item}; + } +}; + +export const getOrEmptyTag = (path: string, item: unknown): JSX.Element => { + const text = get(path, item); + return getOrEmptyTagFromValue(text); +}; + +export const getOrEmptyTagFromValue = (value: string | number | null | undefined): JSX.Element => { + if (value == null) { + return getEmptyTagValue(); + } else if (value === '') { + return getEmptyStringTag(); + } else { + return <>{value}; + } +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/translations.ts b/x-pack/plugins/cases/public/components/empty_value/translations.ts similarity index 69% rename from x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/translations.ts rename to x-pack/plugins/cases/public/components/empty_value/translations.ts index 36db3c631100f..af04a6d404553 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/translations.ts +++ b/x-pack/plugins/cases/public/components/empty_value/translations.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -export const SELECT_CASE_TITLE = i18n.translate('xpack.securitySolution.cases.caseModal.title', { - defaultMessage: 'Select case', + +export const EMPTY_STRING = i18n.translate('xpack.cases.emptyString.emptyStringDescription', { + defaultMessage: 'Empty String', }); diff --git a/x-pack/plugins/security_solution/public/cases/components/filter_popover/index.tsx b/x-pack/plugins/cases/public/components/filter_popover/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/filter_popover/index.tsx rename to x-pack/plugins/cases/public/components/filter_popover/index.tsx diff --git a/x-pack/plugins/cases/public/components/formatted_date/__snapshots__/index.test.tsx.snap b/x-pack/plugins/cases/public/components/formatted_date/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..9e851ddcd7d0f --- /dev/null +++ b/x-pack/plugins/cases/public/components/formatted_date/__snapshots__/index.test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`formatted_date PreferenceFormattedDate renders correctly against snapshot 1`] = ` + + 2019-02-25T22:27:05Z + +`; diff --git a/x-pack/plugins/cases/public/components/formatted_date/index.test.tsx b/x-pack/plugins/cases/public/components/formatted_date/index.test.tsx new file mode 100644 index 0000000000000..d54430b9f27da --- /dev/null +++ b/x-pack/plugins/cases/public/components/formatted_date/index.test.tsx @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount, shallow } from 'enzyme'; +import React from 'react'; + +import { useDateFormat, useTimeZone } from '../../common/lib/kibana'; + +import { TestProviders } from '../../common/mock'; +import { getEmptyString, getEmptyValue } from '../empty_value'; +import { PreferenceFormattedDate, FormattedDate, FormattedRelativePreferenceDate } from '.'; + +jest.mock('../../common/lib/kibana'); +const mockUseDateFormat = useDateFormat as jest.Mock; +const mockUseTimeZone = useTimeZone as jest.Mock; + +const isoDateString = '2019-02-25T22:27:05.000Z'; + +describe('formatted_date', () => { + let isoDate: Date; + + beforeEach(() => { + isoDate = new Date(isoDateString); + mockUseDateFormat.mockImplementation(() => 'MMM D, YYYY @ HH:mm:ss.SSS'); + mockUseTimeZone.mockImplementation(() => 'UTC'); + }); + + describe('PreferenceFormattedDate', () => { + test('renders correctly against snapshot', () => { + mockUseDateFormat.mockImplementation(() => ''); + const wrapper = mount(); + + expect(wrapper).toMatchSnapshot(); + }); + + test('it renders the date with the default configuration', () => { + const wrapper = mount(); + + expect(wrapper.text()).toEqual('Feb 25, 2019 @ 22:27:05.000'); + }); + + test('it renders a UTC ISO8601 date string supplied when no date format configuration exists', () => { + mockUseDateFormat.mockImplementation(() => ''); + const wrapper = mount(); + + expect(wrapper.text()).toEqual('2019-02-25T22:27:05Z'); + }); + + test('it renders the correct timezone when a non-UTC configuration exists', () => { + mockUseTimeZone.mockImplementation(() => 'America/Denver'); + const wrapper = mount(); + + expect(wrapper.text()).toEqual('Feb 25, 2019 @ 15:27:05.000'); + }); + + test('it renders the date with a user-defined format', () => { + mockUseDateFormat.mockImplementation(() => 'MMM-DD-YYYY'); + const wrapper = mount(); + + expect(wrapper.text()).toEqual('Feb-25-2019'); + }); + }); + + describe('FormattedDate', () => { + test('it renders against a numeric epoch', () => { + const wrapper = mount(); + expect(wrapper.text()).toEqual('May 28, 2019 @ 21:35:39.000'); + }); + + test('it renders against a string epoch', () => { + const wrapper = mount(); + expect(wrapper.text()).toEqual('May 28, 2019 @ 21:35:39.000'); + }); + + test('it renders against a ISO string', () => { + const wrapper = mount( + + ); + expect(wrapper.text()).toEqual('May 28, 2019 @ 22:04:49.957'); + }); + + test('it renders against an empty string as an empty string placeholder', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toEqual(getEmptyString()); + }); + + test('it renders against an null as a EMPTY_VALUE', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toEqual(getEmptyValue()); + }); + + test('it renders against an undefined as a EMPTY_VALUE', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toEqual(getEmptyValue()); + }); + + test('it renders against an invalid date time as just the string its self', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toEqual('Rebecca Evan Braden'); + }); + }); + + describe('FormattedRelativePreferenceDate', () => { + test('renders time over an hour correctly against snapshot', () => { + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="preference-time"]').exists()).toBe(true); + }); + + test('renders time under an hour correctly against snapshot', () => { + const timeTwelveMinutesAgo = new Date(new Date().getTime() - 12 * 60 * 1000).toISOString(); + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="relative-time"]').exists()).toBe(true); + }); + + test('renders empty string value correctly', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toBe(getEmptyString()); + }); + + test('renders undefined value correctly', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toBe(getEmptyValue()); + }); + + test('renders null value correctly', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toBe(getEmptyValue()); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/formatted_date/index.tsx b/x-pack/plugins/cases/public/components/formatted_date/index.tsx new file mode 100644 index 0000000000000..5bb90bfbff797 --- /dev/null +++ b/x-pack/plugins/cases/public/components/formatted_date/index.tsx @@ -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 moment from 'moment-timezone'; +import React from 'react'; +import { FormattedRelative } from '@kbn/i18n/react'; + +import { useDateFormat, useTimeZone, useUiSetting$ } from '../../common/lib/kibana'; +import { getOrEmptyTagFromValue } from '../empty_value'; +import { LocalizedDateTooltip } from '../localized_date_tooltip'; +import { getMaybeDate } from './maybe_date'; + +export const PreferenceFormattedDate = React.memo<{ dateFormat?: string; value: Date }>( + /* eslint-disable-next-line react-hooks/rules-of-hooks */ + ({ value, dateFormat = useDateFormat() }) => ( + <>{moment.tz(value, useTimeZone()).format(dateFormat)} + ) +); + +PreferenceFormattedDate.displayName = 'PreferenceFormattedDate'; + +export const PreferenceFormattedDateFromPrimitive = ({ + value, +}: { + value?: string | number | null; +}) => { + if (value == null) { + return getOrEmptyTagFromValue(value); + } + const maybeDate = getMaybeDate(value); + if (!maybeDate.isValid()) { + return getOrEmptyTagFromValue(value); + } + const date = maybeDate.toDate(); + return ; +}; + +PreferenceFormattedDateFromPrimitive.displayName = 'PreferenceFormattedDateFromPrimitive'; + +/** + * This function may be passed to `Array.find()` to locate the `P1DT` + * configuration (sub) setting, a string array that contains two entries + * like the following example: `['P1DT', 'YYYY-MM-DD']`. + */ +export const isP1DTFormatterSetting = (formatNameFormatterPair?: string[]) => + Array.isArray(formatNameFormatterPair) && + formatNameFormatterPair[0] === 'P1DT' && + formatNameFormatterPair.length === 2; + +/** + * Renders a date in `P1DT` format, e.g. `YYYY-MM-DD`, as specified by + * the `P1DT1` entry in the `dateFormat:scaled` Kibana Advanced setting. + * + * If the `P1DT` format is not specified in the `dateFormat:scaled` setting, + * the fallback format `YYYY-MM-DD` will be applied + */ +export const PreferenceFormattedP1DTDate = React.memo<{ value: Date }>(({ value }) => { + /** + * A fallback "format name / formatter" 2-tuple for the `P1DT` formatter, which is + * one of many such pairs expected to be contained in the `dateFormat:scaled` + * Kibana advanced setting. + */ + const FALLBACK_DATE_FORMAT_SCALED_P1DT = ['P1DT', 'YYYY-MM-DD']; + + // Read the 'dateFormat:scaled' Kibana Advanced setting, which contains 2-tuple sub-settings: + const [scaledDateFormatPreference] = useUiSetting$('dateFormat:scaled'); + + // attempt to find the nested `['P1DT', 'formatString']` setting + const maybeP1DTFormatter = Array.isArray(scaledDateFormatPreference) + ? scaledDateFormatPreference.find(isP1DTFormatterSetting) + : null; + + const p1dtFormat = + Array.isArray(maybeP1DTFormatter) && maybeP1DTFormatter.length === 2 + ? maybeP1DTFormatter[1] + : FALLBACK_DATE_FORMAT_SCALED_P1DT[1]; + + return ; +}); + +PreferenceFormattedP1DTDate.displayName = 'PreferenceFormattedP1DTDate'; + +/** + * Renders the specified date value in a format determined by the user's preferences, + * with a tooltip that renders: + * - the name of the field + * - a humanized relative date (e.g. 16 minutes ago) + * - a long representation of the date that includes the day of the week (e.g. Thursday, March 21, 2019 6:47pm) + * - the raw date value (e.g. 2019-03-22T00:47:46Z) + */ +export const FormattedDate = React.memo<{ + fieldName: string; + value?: string | number | null; + className?: string; +}>( + ({ value, fieldName, className = '' }): JSX.Element => { + if (value == null) { + return getOrEmptyTagFromValue(value); + } + const maybeDate = getMaybeDate(value); + return maybeDate.isValid() ? ( + + + + ) : ( + getOrEmptyTagFromValue(value) + ); + } +); + +FormattedDate.displayName = 'FormattedDate'; + +/** + * Renders the specified date value according to under/over one hour + * Under an hour = relative format + * Over an hour = in a format determined by the user's preferences, + * with a tooltip that renders: + * - the name of the field + * - a humanized relative date (e.g. 16 minutes ago) + * - a long representation of the date that includes the day of the week (e.g. Thursday, March 21, 2019 6:47pm) + * - the raw date value (e.g. 2019-03-22T00:47:46Z) + */ + +export const FormattedRelativePreferenceDate = ({ value }: { value?: string | number | null }) => { + if (value == null) { + return getOrEmptyTagFromValue(value); + } + const maybeDate = getMaybeDate(value); + if (!maybeDate.isValid()) { + return getOrEmptyTagFromValue(value); + } + const date = maybeDate.toDate(); + return ( + + {moment(date).add(1, 'hours').isBefore(new Date()) ? ( + + ) : ( + + )} + + ); +}; + +/** + * Renders a preceding label according to under/over one hour + */ + +export const FormattedRelativePreferenceLabel = ({ + value, + preferenceLabel, + relativeLabel, +}: { + value?: string | number | null; + preferenceLabel?: string | null; + relativeLabel?: string | null; +}) => { + if (value == null) { + return null; + } + const maybeDate = getMaybeDate(value); + if (!maybeDate.isValid()) { + return null; + } + return moment(maybeDate.toDate()).add(1, 'hours').isBefore(new Date()) ? ( + <>{preferenceLabel} + ) : ( + <>{relativeLabel} + ); +}; diff --git a/x-pack/plugins/cases/public/components/formatted_date/maybe_date.test.ts b/x-pack/plugins/cases/public/components/formatted_date/maybe_date.test.ts new file mode 100644 index 0000000000000..402d811da7bd9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/formatted_date/maybe_date.test.ts @@ -0,0 +1,46 @@ +/* + * 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 { getMaybeDate } from './maybe_date'; + +describe('#getMaybeDate', () => { + test('returns empty string as invalid date', () => { + expect(getMaybeDate('').isValid()).toBe(false); + }); + + test('returns string with empty spaces as invalid date', () => { + expect(getMaybeDate(' ').isValid()).toBe(false); + }); + + test('returns string date time as valid date', () => { + expect(getMaybeDate('2019-05-28T23:05:28.405Z').isValid()).toBe(true); + }); + + test('returns string date time as the date we expect', () => { + expect(getMaybeDate('2019-05-28T23:05:28.405Z').toISOString()).toBe('2019-05-28T23:05:28.405Z'); + }); + + test('returns plain string number as epoch as valid date', () => { + expect(getMaybeDate('1559084770612').isValid()).toBe(true); + }); + + test('returns plain string number as the date we expect', () => { + expect(getMaybeDate('1559084770612').toDate().toISOString()).toBe('2019-05-28T23:06:10.612Z'); + }); + + test('returns plain number as epoch as valid date', () => { + expect(getMaybeDate(1559084770612).isValid()).toBe(true); + }); + + test('returns plain number as epoch as the date we expect', () => { + expect(getMaybeDate(1559084770612).toDate().toISOString()).toBe('2019-05-28T23:06:10.612Z'); + }); + + test('returns a short date time string as an epoch (sadly) so this is ambiguous', () => { + expect(getMaybeDate('20190101').toDate().toISOString()).toBe('1970-01-01T05:36:30.101Z'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/formatted_date/maybe_date.ts b/x-pack/plugins/cases/public/components/formatted_date/maybe_date.ts new file mode 100644 index 0000000000000..cc7add4f0f1f2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/formatted_date/maybe_date.ts @@ -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 { isString } from 'lodash/fp'; +import moment from 'moment'; + +export const getMaybeDate = (value: string | number): moment.Moment => { + if (isString(value) && value.trim() !== '') { + const maybeDate = moment(new Date(value)); + if (maybeDate.isValid() || isNaN(+value)) { + return maybeDate; + } else { + return moment(new Date(+value)); + } + } else { + return moment(new Date(value)); + } +}; diff --git a/x-pack/plugins/cases/public/components/header_page/__snapshots__/editable_title.test.tsx.snap b/x-pack/plugins/cases/public/components/header_page/__snapshots__/editable_title.test.tsx.snap new file mode 100644 index 0000000000000..c8d4b6ec3b4c8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/header_page/__snapshots__/editable_title.test.tsx.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EditableTitle it renders 1`] = ` + + + + + + + + +`; diff --git a/x-pack/plugins/cases/public/components/header_page/__snapshots__/index.test.tsx.snap b/x-pack/plugins/cases/public/components/header_page/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..a100f5e4f93b4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/header_page/__snapshots__/index.test.tsx.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HeaderPage it renders 1`] = ` +
+ + + + + + + +

+ Test supplement +

+
+
+
+`; diff --git a/x-pack/plugins/cases/public/components/header_page/__snapshots__/title.test.tsx.snap b/x-pack/plugins/cases/public/components/header_page/__snapshots__/title.test.tsx.snap new file mode 100644 index 0000000000000..05af2fee2c2a2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/header_page/__snapshots__/title.test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Title it renders 1`] = ` + +

+ Test title + + +

+
+`; diff --git a/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx b/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx new file mode 100644 index 0000000000000..90a10a388d717 --- /dev/null +++ b/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx @@ -0,0 +1,172 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; + +import '../../common/mock/match_media'; +import { TestProviders } from '../../common/mock'; +import { EditableTitle } from './editable_title'; +import { useMountAppended } from '../../utils/use_mount_appended'; + +describe('EditableTitle', () => { + const mount = useMountAppended(); + const submitTitle = jest.fn(); + + test('it renders', () => { + const wrapper = shallow( + + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('it shows the edit title input field', () => { + const wrapper = mount( + + + + ); + + wrapper.find('button[data-test-subj="editable-title-edit-icon"]').simulate('click'); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="editable-title-input-field"]').first().exists()).toBe( + true + ); + }); + + test('it shows the submit button', () => { + const wrapper = mount( + + + + ); + + wrapper.find('button[data-test-subj="editable-title-edit-icon"]').simulate('click'); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="editable-title-submit-btn"]').first().exists()).toBe( + true + ); + }); + + test('it shows the cancel button', () => { + const wrapper = mount( + + + + ); + + wrapper.find('button[data-test-subj="editable-title-edit-icon"]').simulate('click'); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="editable-title-cancel-btn"]').first().exists()).toBe( + true + ); + }); + + test('it DOES NOT shows the edit icon when in edit mode', () => { + const wrapper = mount( + + + + ); + + wrapper.find('button[data-test-subj="editable-title-edit-icon"]').simulate('click'); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="editable-title-edit-icon"]').first().exists()).toBe( + false + ); + }); + + test('it switch to non edit mode when canceled', () => { + const wrapper = mount( + + + + ); + + wrapper.find('button[data-test-subj="editable-title-edit-icon"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="editable-title-cancel-btn"]').simulate('click'); + + expect(wrapper.find('[data-test-subj="editable-title-edit-icon"]').first().exists()).toBe(true); + }); + + test('it should change the title', () => { + const newTitle = 'new test title'; + + const wrapper = mount( + + + + ); + + wrapper.find('button[data-test-subj="editable-title-edit-icon"]').simulate('click'); + wrapper.update(); + + wrapper + .find('input[data-test-subj="editable-title-input-field"]') + .simulate('change', { target: { value: newTitle } }); + + wrapper.update(); + + expect( + wrapper.find('input[data-test-subj="editable-title-input-field"]').prop('value') + ).toEqual(newTitle); + }); + + test('it should NOT change the title when cancel', () => { + const title = 'Test title'; + const newTitle = 'new test title'; + + const wrapper = mount( + + + + ); + + wrapper.find('button[data-test-subj="editable-title-edit-icon"]').simulate('click'); + wrapper.update(); + + wrapper + .find('input[data-test-subj="editable-title-input-field"]') + .simulate('change', { target: { value: newTitle } }); + wrapper.update(); + + wrapper.find('button[data-test-subj="editable-title-cancel-btn"]').simulate('click'); + wrapper.update(); + + expect(wrapper.find('h1[data-test-subj="header-page-title"]').text()).toEqual(title); + }); + + test('it submits the title', () => { + const newTitle = 'new test title'; + + const wrapper = mount( + + + + ); + + wrapper.find('button[data-test-subj="editable-title-edit-icon"]').simulate('click'); + wrapper.update(); + + wrapper + .find('input[data-test-subj="editable-title-input-field"]') + .simulate('change', { target: { value: newTitle } }); + + wrapper.find('button[data-test-subj="editable-title-submit-btn"]').simulate('click'); + wrapper.update(); + + expect(submitTitle).toHaveBeenCalled(); + expect(submitTitle.mock.calls[0][0]).toEqual(newTitle); + expect(wrapper.find('[data-test-subj="editable-title-edit-icon"]').first().exists()).toBe(true); + }); +}); diff --git a/x-pack/plugins/cases/public/components/header_page/editable_title.tsx b/x-pack/plugins/cases/public/components/header_page/editable_title.tsx new file mode 100644 index 0000000000000..b53560db6745b --- /dev/null +++ b/x-pack/plugins/cases/public/components/header_page/editable_title.tsx @@ -0,0 +1,123 @@ +/* + * 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, ChangeEvent } from 'react'; +import styled, { css } from 'styled-components'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFieldText, + EuiButtonIcon, + EuiLoadingSpinner, +} from '@elastic/eui'; + +import * as i18n from './translations'; + +import { Title } from './title'; + +const MyEuiButtonIcon = styled(EuiButtonIcon)` + ${({ theme }) => css` + margin-left: ${theme.eui.euiSize}; + `} +`; + +const MySpinner = styled(EuiLoadingSpinner)` + ${({ theme }) => css` + margin-left: ${theme.eui.euiSize}; + `} +`; + +interface Props { + disabled?: boolean; + isLoading: boolean; + title: string | React.ReactNode; + onSubmit: (title: string) => void; +} + +const EditableTitleComponent: React.FC = ({ + disabled = false, + onSubmit, + isLoading, + title, +}) => { + const [editMode, setEditMode] = useState(false); + const [changedTitle, onTitleChange] = useState(typeof title === 'string' ? title : ''); + + const onCancel = useCallback(() => setEditMode(false), []); + const onClickEditIcon = useCallback(() => setEditMode(true), []); + + const onClickSubmit = useCallback((): void => { + if (changedTitle !== title) { + onSubmit(changedTitle); + } + setEditMode(false); + }, [changedTitle, onSubmit, title]); + + const handleOnChange = useCallback( + (e: ChangeEvent) => onTitleChange(e.target.value), + [] + ); + return editMode ? ( + + + + + + + + {i18n.SAVE} + + + + + {i18n.CANCEL} + + + + + + ) : ( + + + + </EuiFlexItem> + <EuiFlexItem grow={false}> + {isLoading && <MySpinner data-test-subj="editable-title-loading" />} + {!isLoading && ( + <MyEuiButtonIcon + isDisabled={disabled} + aria-label={i18n.EDIT_TITLE_ARIA(title as string)} + iconType="pencil" + onClick={onClickEditIcon} + data-test-subj="editable-title-edit-icon" + /> + )} + </EuiFlexItem> + </EuiFlexGroup> + ); +}; + +export const EditableTitle = React.memo(EditableTitleComponent); diff --git a/x-pack/plugins/cases/public/components/header_page/index.test.tsx b/x-pack/plugins/cases/public/components/header_page/index.test.tsx new file mode 100644 index 0000000000000..d84a6d9272def --- /dev/null +++ b/x-pack/plugins/cases/public/components/header_page/index.test.tsx @@ -0,0 +1,157 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; + +import '../../common/mock/match_media'; +import { TestProviders } from '../../common/mock'; +import { HeaderPage } from './index'; +import { useMountAppended } from '../../utils/use_mount_appended'; + +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + + return { + ...original, + useHistory: () => ({ + useHistory: jest.fn(), + }), + }; +}); + +describe('HeaderPage', () => { + const mount = useMountAppended(); + + test('it renders', () => { + const wrapper = shallow( + <HeaderPage + badgeOptions={{ beta: true, text: 'Beta', tooltip: 'Test tooltip' }} + border + subtitle="Test subtitle" + subtitle2="Test subtitle 2" + title="Test title" + > + <p>{'Test supplement'}</p> + </HeaderPage> + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('it renders the back link when provided', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage + backOptions={{ href: '#', text: 'Test link', onClick: jest.fn() }} + title="Test title" + /> + </TestProviders> + ); + + expect(wrapper.find('.casesHeaderPage__linkBack').first().exists()).toBe(true); + }); + + test('it DOES NOT render the back link when not provided', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage title="Test title" /> + </TestProviders> + ); + + expect(wrapper.find('.casesHeaderPage__linkBack').first().exists()).toBe(false); + }); + + test('it renders the first subtitle when provided', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage subtitle="Test subtitle" title="Test title" /> + </TestProviders> + ); + + expect(wrapper.find('[data-test-subj="header-page-subtitle"]').first().exists()).toBe(true); + }); + + test('it DOES NOT render the first subtitle when not provided', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage title="Test title" /> + </TestProviders> + ); + + expect(wrapper.find('[data-test-subj="header-section-subtitle"]').first().exists()).toBe(false); + }); + + test('it renders the second subtitle when provided', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage subtitle2="Test subtitle 2" title="Test title" /> + </TestProviders> + ); + + expect(wrapper.find('[data-test-subj="header-page-subtitle-2"]').first().exists()).toBe(true); + }); + + test('it DOES NOT render the second subtitle when not provided', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage title="Test title" /> + </TestProviders> + ); + + expect(wrapper.find('[data-test-subj="header-section-subtitle-2"]').first().exists()).toBe( + false + ); + }); + + test('it renders supplements when children provided', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage title="Test title"> + <p>{'Test supplement'}</p> + </HeaderPage> + </TestProviders> + ); + + expect(wrapper.find('[data-test-subj="header-page-supplements"]').first().exists()).toBe(true); + }); + + test('it DOES NOT render supplements when children not provided', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage title="Test title" /> + </TestProviders> + ); + + expect(wrapper.find('[data-test-subj="header-page-supplements"]').first().exists()).toBe(false); + }); + + test('it applies border styles when border is true', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage border title="Test title" /> + </TestProviders> + ); + const casesHeaderPage = wrapper.find('.casesHeaderPage').first(); + + expect(casesHeaderPage).toHaveStyleRule('border-bottom', euiDarkVars.euiBorderThin); + expect(casesHeaderPage).toHaveStyleRule('padding-bottom', euiDarkVars.paddingSizes.l); + }); + + test('it DOES NOT apply border styles when border is false', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage title="Test title" /> + </TestProviders> + ); + const casesHeaderPage = wrapper.find('.casesHeaderPage').first(); + + expect(casesHeaderPage).not.toHaveStyleRule('border-bottom', euiDarkVars.euiBorderThin); + expect(casesHeaderPage).not.toHaveStyleRule('padding-bottom', euiDarkVars.paddingSizes.l); + }); +}); diff --git a/x-pack/plugins/cases/public/components/header_page/index.tsx b/x-pack/plugins/cases/public/components/header_page/index.tsx new file mode 100644 index 0000000000000..dc9f73e37b027 --- /dev/null +++ b/x-pack/plugins/cases/public/components/header_page/index.tsx @@ -0,0 +1,128 @@ +/* + * 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 { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui'; +import React from 'react'; +import styled, { css } from 'styled-components'; + +import { LinkIcon, LinkIconProps } from '../link_icon'; +import { Subtitle, SubtitleProps } from '../subtitle'; +import { Title } from './title'; +import { BadgeOptions, TitleProp } from './types'; +interface HeaderProps { + border?: boolean; + isLoading?: boolean; +} + +const Header = styled.header.attrs({ + className: 'casesHeaderPage', +})<HeaderProps>` + ${({ border, theme }) => css` + margin-bottom: ${theme.eui.euiSizeL}; + + ${border && + css` + border-bottom: ${theme.eui.euiBorderThin}; + padding-bottom: ${theme.eui.paddingSizes.l}; + .euiProgress { + top: ${theme.eui.paddingSizes.l}; + } + `} + `} +`; +Header.displayName = 'Header'; + +const FlexItem = styled(EuiFlexItem)` + display: block; +`; +FlexItem.displayName = 'FlexItem'; + +const LinkBack = styled.div.attrs({ + className: 'casesHeaderPage__linkBack', +})` + ${({ theme }) => css` + font-size: ${theme.eui.euiFontSizeXS}; + line-height: ${theme.eui.euiLineHeight}; + margin-bottom: ${theme.eui.euiSizeS}; + `} +`; +LinkBack.displayName = 'LinkBack'; + +const Badge = (styled(EuiBadge)` + letter-spacing: 0; +` as unknown) as typeof EuiBadge; +Badge.displayName = 'Badge'; + +interface BackOptions { + href: LinkIconProps['href']; + onClick?: (ev: MouseEvent) => void; + text: LinkIconProps['children']; + dataTestSubj?: string; +} + +export interface HeaderPageProps extends HeaderProps { + backOptions?: BackOptions; + /** A component to be displayed as the back button. Used only if `backOption` is not defined */ + backComponent?: React.ReactNode; + badgeOptions?: BadgeOptions; + children?: React.ReactNode; + subtitle?: SubtitleProps['items']; + subtitle2?: SubtitleProps['items']; + title: TitleProp; + titleNode?: React.ReactElement; +} + +const HeaderPageComponent: React.FC<HeaderPageProps> = ({ + backOptions, + backComponent, + badgeOptions, + border, + children, + isLoading, + subtitle, + subtitle2, + title, + titleNode, + ...rest +}) => { + return ( + <Header border={border} {...rest}> + <EuiFlexGroup alignItems="center"> + <FlexItem> + {backOptions && ( + <LinkBack> + <LinkIcon + dataTestSubj={backOptions.dataTestSubj} + onClick={backOptions.onClick} + href={backOptions.href} + iconType="arrowLeft" + > + {backOptions.text} + </LinkIcon> + </LinkBack> + )} + + {!backOptions && backComponent && <>{backComponent}</>} + + {titleNode || <Title title={title} badgeOptions={badgeOptions} />} + + {subtitle && <Subtitle data-test-subj="header-page-subtitle" items={subtitle} />} + {subtitle2 && <Subtitle data-test-subj="header-page-subtitle-2" items={subtitle2} />} + {border && isLoading && <EuiProgress size="xs" color="accent" />} + </FlexItem> + + {children && ( + <FlexItem data-test-subj="header-page-supplements" grow={false}> + {children} + </FlexItem> + )} + </EuiFlexGroup> + </Header> + ); +}; + +export const HeaderPage = React.memo(HeaderPageComponent); diff --git a/x-pack/plugins/cases/public/components/header_page/title.test.tsx b/x-pack/plugins/cases/public/components/header_page/title.test.tsx new file mode 100644 index 0000000000000..2423104eb8819 --- /dev/null +++ b/x-pack/plugins/cases/public/components/header_page/title.test.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; + +import '../../common/mock/match_media'; +import { TestProviders } from '../../common/mock'; +import { Title } from './title'; +import { useMountAppended } from '../../utils/use_mount_appended'; + +describe('Title', () => { + const mount = useMountAppended(); + + test('it renders', () => { + const wrapper = shallow( + <Title + badgeOptions={{ beta: true, text: 'Beta', tooltip: 'Test tooltip' }} + title="Test title" + /> + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('it renders the title', () => { + const wrapper = mount( + <TestProviders> + <Title title="Test title" /> + </TestProviders> + ); + + expect(wrapper.find('[data-test-subj="header-page-title"]').first().exists()).toBe(true); + }); +}); diff --git a/x-pack/plugins/cases/public/components/header_page/title.tsx b/x-pack/plugins/cases/public/components/header_page/title.tsx new file mode 100644 index 0000000000000..3a0390a436e1c --- /dev/null +++ b/x-pack/plugins/cases/public/components/header_page/title.tsx @@ -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 React from 'react'; +import { EuiBetaBadge, EuiBadge, EuiTitle } from '@elastic/eui'; +import styled from 'styled-components'; + +import { BadgeOptions, TitleProp } from './types'; + +const StyledEuiBetaBadge = styled(EuiBetaBadge)` + vertical-align: middle; +`; + +StyledEuiBetaBadge.displayName = 'StyledEuiBetaBadge'; + +const Badge = (styled(EuiBadge)` + letter-spacing: 0; +` as unknown) as typeof EuiBadge; +Badge.displayName = 'Badge'; + +interface Props { + badgeOptions?: BadgeOptions; + title: TitleProp; +} + +const TitleComponent: React.FC<Props> = ({ title, badgeOptions }) => ( + <EuiTitle size="l"> + <h1 data-test-subj="header-page-title"> + {title} + {badgeOptions && ( + <> + {' '} + {badgeOptions.beta ? ( + <StyledEuiBetaBadge + label={badgeOptions.text} + tooltipContent={badgeOptions.tooltip} + tooltipPosition="bottom" + /> + ) : ( + <Badge color="hollow" title=""> + {badgeOptions.text} + </Badge> + )} + </> + )} + </h1> + </EuiTitle> +); + +export const Title = React.memo(TitleComponent); diff --git a/x-pack/plugins/cases/public/components/header_page/translations.ts b/x-pack/plugins/cases/public/components/header_page/translations.ts new file mode 100644 index 0000000000000..b24c347857a6c --- /dev/null +++ b/x-pack/plugins/cases/public/components/header_page/translations.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const SAVE = i18n.translate('xpack.cases.header.editableTitle.save', { + defaultMessage: 'Save', +}); + +export const CANCEL = i18n.translate('xpack.cases.header.editableTitle.cancel', { + defaultMessage: 'Cancel', +}); + +export const EDIT_TITLE_ARIA = (title: string) => + i18n.translate('xpack.cases.header.editableTitle.editButtonAria', { + values: { title }, + defaultMessage: 'You can edit {title} by clicking', + }); diff --git a/x-pack/plugins/cases/public/components/header_page/types.ts b/x-pack/plugins/cases/public/components/header_page/types.ts new file mode 100644 index 0000000000000..e95d0c8e1e69c --- /dev/null +++ b/x-pack/plugins/cases/public/components/header_page/types.ts @@ -0,0 +1,20 @@ +/* + * 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 type React from 'react'; +export type TitleProp = string | React.ReactNode; + +export interface DraggableArguments { + field: string; + value: string; +} + +export interface BadgeOptions { + beta?: boolean; + text: string; + tooltip?: string; +} diff --git a/x-pack/plugins/cases/public/components/insert_timeline/index.test.tsx b/x-pack/plugins/cases/public/components/insert_timeline/index.test.tsx new file mode 100644 index 0000000000000..84a19578c80de --- /dev/null +++ b/x-pack/plugins/cases/public/components/insert_timeline/index.test.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { waitFor } from '@testing-library/react'; + +import { TestProviders } from '../../common/mock'; +import { Form, useForm, FormHook } from '../../common/shared_imports'; +import { CasesTimelineIntegrationProvider } from '../timeline_context'; +import { timelineIntegrationMock } from '../__mock__/timeline'; +import { getFormMock } from '../__mock__/form'; +import { InsertTimeline } from '.'; +import { useTimelineContext } from '../timeline_context/use_timeline_context'; + +jest.mock('../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'); +jest.mock('../timeline_context/use_timeline_context'); + +const useFormMock = useForm as jest.Mock; +const useTimelineContextMock = useTimelineContext as jest.Mock; + +describe('InsertTimeline ', () => { + const formHookMock = getFormMock({ comment: 'someValue' }); + const mockTimelineIntegration = { ...timelineIntegrationMock }; + const useInsertTimelineMock = jest.fn(); + let attachTimeline = jest.fn(); + beforeEach(() => { + jest.resetAllMocks(); + useFormMock.mockImplementation(() => ({ form: formHookMock })); + }); + + it('it should not call useInsertTimeline without timeline context', async () => { + mount( + <TestProviders> + <CasesTimelineIntegrationProvider> + <Form form={(formHookMock as unknown) as FormHook}> + <InsertTimeline fieldName="comment" /> + </Form> + </CasesTimelineIntegrationProvider> + </TestProviders> + ); + + await waitFor(() => { + expect(attachTimeline).not.toHaveBeenCalled(); + }); + }); + + it('should call useInsertTimeline with correct arguments', async () => { + useInsertTimelineMock.mockImplementation((comment, onTimelineAttached) => { + attachTimeline = onTimelineAttached; + }); + mockTimelineIntegration.hooks.useInsertTimeline = useInsertTimelineMock; + useTimelineContextMock.mockImplementation(() => ({ ...mockTimelineIntegration })); + + mount( + <TestProviders> + <CasesTimelineIntegrationProvider timelineIntegration={mockTimelineIntegration}> + <Form form={(formHookMock as unknown) as FormHook}> + <InsertTimeline fieldName="comment" /> + </Form> + </CasesTimelineIntegrationProvider> + </TestProviders> + ); + + await waitFor(() => { + expect(useInsertTimelineMock).toHaveBeenCalledWith('someValue', attachTimeline); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/insert_timeline/index.tsx b/x-pack/plugins/cases/public/components/insert_timeline/index.tsx new file mode 100644 index 0000000000000..473bf5485782f --- /dev/null +++ b/x-pack/plugins/cases/public/components/insert_timeline/index.tsx @@ -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 { useCallback } from 'react'; +import { useFormContext } from '../../common/shared_imports'; +import { useTimelineContext } from '../timeline_context/use_timeline_context'; + +type InsertFields = 'comment' | 'description'; + +export const InsertTimeline = ({ fieldName }: { fieldName: InsertFields }) => { + const { setFieldValue, getFormData } = useFormContext(); + const timelineHooks = useTimelineContext()?.hooks; + const formData = getFormData(); + const onTimelineAttached = useCallback((newValue: string) => setFieldValue(fieldName, newValue), [ + fieldName, + setFieldValue, + ]); + timelineHooks?.useInsertTimeline(formData[fieldName] ?? '', onTimelineAttached); + return null; +}; diff --git a/x-pack/plugins/cases/public/components/link_icon/__snapshots__/index.test.tsx.snap b/x-pack/plugins/cases/public/components/link_icon/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..7044c055e4b78 --- /dev/null +++ b/x-pack/plugins/cases/public/components/link_icon/__snapshots__/index.test.tsx.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LinkIcon it renders 1`] = ` +<Link + aria-label="Test link" + className="casesLinkIcon" + href="#" + iconSide="right" +> + <EuiIcon + size="xxl" + type="alert" + /> + <span + className="casesLinkIcon__label" + > + Test link + </span> +</Link> +`; diff --git a/x-pack/plugins/cases/public/components/link_icon/index.test.tsx b/x-pack/plugins/cases/public/components/link_icon/index.test.tsx new file mode 100644 index 0000000000000..4600f0dc4adc4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/link_icon/index.test.tsx @@ -0,0 +1,95 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; + +import { TestProviders } from '../../common/mock'; +import { LinkIcon } from './index'; + +describe('LinkIcon', () => { + test('it renders', () => { + const wrapper = shallow( + <LinkIcon href="#" iconSide="right" iconSize="xxl" iconType="alert"> + {'Test link'} + </LinkIcon> + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('it renders an action button when onClick is provided', () => { + const wrapper = mount( + <TestProviders> + <LinkIcon iconType="alert" onClick={() => alert('Test alert')}> + {'Test link'} + </LinkIcon> + </TestProviders> + ); + + expect(wrapper.find('button').first().exists()).toBe(true); + }); + + test('it renders an action link when href is provided', () => { + const wrapper = mount( + <TestProviders> + <LinkIcon href="#" iconType="alert"> + {'Test link'} + </LinkIcon> + </TestProviders> + ); + + expect(wrapper.find('a').first().exists()).toBe(true); + }); + + test('it renders an icon', () => { + const wrapper = mount( + <TestProviders> + <LinkIcon iconType="alert">{'Test link'}</LinkIcon> + </TestProviders> + ); + + expect(wrapper.find('[data-euiicon-type]').first().exists()).toBe(true); + }); + + test('it positions the icon to the right when iconSide is right', () => { + const wrapper = mount( + <TestProviders> + <LinkIcon iconSide="right" iconType="alert"> + {'Test link'} + </LinkIcon> + </TestProviders> + ); + + expect(wrapper.find('.casesLinkIcon').at(1)).toHaveStyleRule('flex-direction', 'row-reverse'); + }); + + test('it positions the icon to the left when iconSide is left (or not provided)', () => { + const wrapper = mount( + <TestProviders> + <LinkIcon iconSide="left" iconType="alert"> + {'Test link'} + </LinkIcon> + </TestProviders> + ); + + expect(wrapper.find('.casesLinkIcon').at(1)).not.toHaveStyleRule( + 'flex-direction', + 'row-reverse' + ); + }); + + test('it renders a label', () => { + const wrapper = mount( + <TestProviders> + <LinkIcon iconType="alert">{'Test link'}</LinkIcon> + </TestProviders> + ); + + expect(wrapper.find('.casesLinkIcon__label').first().exists()).toBe(true); + }); +}); diff --git a/x-pack/plugins/cases/public/components/link_icon/index.tsx b/x-pack/plugins/cases/public/components/link_icon/index.tsx new file mode 100644 index 0000000000000..b33529399db90 --- /dev/null +++ b/x-pack/plugins/cases/public/components/link_icon/index.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiIcon, EuiLink, IconSize, IconType } from '@elastic/eui'; +import { LinkAnchorProps } from '@elastic/eui/src/components/link/link'; +import React, { ReactNode, useCallback, useMemo } from 'react'; +import styled, { css } from 'styled-components'; + +interface LinkProps { + ariaLabel?: string; + color?: LinkAnchorProps['color']; + disabled?: boolean; + href?: string; + iconSide?: 'left' | 'right'; + onClick?: Function; +} + +export const Link = styled(({ iconSide, children, ...rest }) => ( + <EuiLink {...rest}>{children}</EuiLink> +))<LinkProps>` + ${({ iconSide, theme }) => css` + align-items: center; + display: inline-flex; + vertical-align: top; + white-space: nowrap; + + ${iconSide === 'left' && + css` + .euiIcon { + margin-right: ${theme.eui.euiSizeXS}; + } + `} + + ${iconSide === 'right' && + css` + flex-direction: row-reverse; + + .euiIcon { + margin-left: ${theme.eui.euiSizeXS}; + } + `} + `} +`; +Link.displayName = 'Link'; + +export interface LinkIconProps extends LinkProps { + children: string | ReactNode; + iconSize?: IconSize; + iconType: IconType; + dataTestSubj?: string; +} + +export const LinkIcon = React.memo<LinkIconProps>( + ({ + ariaLabel, + children, + color, + dataTestSubj, + disabled, + href, + iconSide = 'left', + iconSize = 's', + iconType, + onClick, + }) => { + const getChildrenString = useCallback((theChild: string | ReactNode): string => { + if ( + typeof theChild === 'object' && + theChild != null && + 'props' in theChild && + theChild.props && + theChild.props.children + ) { + return getChildrenString(theChild.props.children); + } + return theChild != null && Object.keys(theChild).length > 0 ? (theChild as string) : ''; + }, []); + const aria = useMemo(() => { + if (ariaLabel) { + return ariaLabel; + } + return getChildrenString(children); + }, [ariaLabel, children, getChildrenString]); + + return ( + <Link + className="casesLinkIcon" + color={color} + data-test-subj={dataTestSubj} + disabled={disabled} + href={href} + iconSide={iconSide} + onClick={onClick} + aria-label={aria} + > + <EuiIcon size={iconSize} type={iconType} /> + <span className="casesLinkIcon__label">{children}</span> + </Link> + ); + } +); +LinkIcon.displayName = 'LinkIcon'; diff --git a/x-pack/plugins/cases/public/components/links/index.tsx b/x-pack/plugins/cases/public/components/links/index.tsx new file mode 100644 index 0000000000000..310d700aa2a25 --- /dev/null +++ b/x-pack/plugins/cases/public/components/links/index.tsx @@ -0,0 +1,70 @@ +/* + * 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 { + EuiButton, + EuiButtonProps, + EuiLink, + EuiLinkProps, + PropsForAnchor, + PropsForButton, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; +import * as i18n from './translations'; + +export interface CasesNavigation<T = React.MouseEvent | MouseEvent, K = null> { + href: K extends 'configurable' ? (arg: T) => string : string; + onClick: (arg: T) => void; +} + +export const LinkButton: React.FC< + PropsForButton<EuiButtonProps> | PropsForAnchor<EuiButtonProps> +> = ({ children, ...props }) => <EuiButton {...props}>{children}</EuiButton>; + +export const LinkAnchor: React.FC<EuiLinkProps> = ({ children, ...props }) => ( + <EuiLink {...props}>{children}</EuiLink> +); + +export interface CaseDetailsHrefSchema { + detailName: string; + search?: string; + subCaseId?: string; +} + +const CaseDetailsLinkComponent: React.FC<{ + children?: React.ReactNode; + detailName: string; + caseDetailsNavigation: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>; + subCaseId?: string; + title?: string; +}> = ({ caseDetailsNavigation, children, detailName, subCaseId, title }) => { + const { href: getHref, onClick } = caseDetailsNavigation; + const goToCaseDetails = useCallback( + (ev) => { + if (onClick) { + ev.preventDefault(); + onClick({ detailName, subCaseId }); + } + }, + [detailName, onClick, subCaseId] + ); + + const href = getHref({ detailName, subCaseId }); + + return ( + <LinkAnchor + onClick={goToCaseDetails} + href={href} + data-test-subj="case-details-link" + aria-label={i18n.CASE_DETAILS_LINK_ARIA(title ?? detailName)} + > + {children ? children : detailName} + </LinkAnchor> + ); +}; +export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); +CaseDetailsLink.displayName = 'CaseDetailsLink'; diff --git a/x-pack/plugins/cases/public/components/links/translations.ts b/x-pack/plugins/cases/public/components/links/translations.ts new file mode 100644 index 0000000000000..248750961d348 --- /dev/null +++ b/x-pack/plugins/cases/public/components/links/translations.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const CASE_DETAILS_LINK_ARIA = (detailName: string) => + i18n.translate('xpack.cases.caseTable.caseDetailsLinkAria', { + values: { detailName }, + defaultMessage: 'click to visit case with title {detailName}', + }); diff --git a/x-pack/plugins/cases/public/components/localized_date_tooltip/index.test.tsx b/x-pack/plugins/cases/public/components/localized_date_tooltip/index.test.tsx new file mode 100644 index 0000000000000..83fba7a041ca5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/localized_date_tooltip/index.test.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import moment from 'moment-timezone'; +import React from 'react'; + +import { LocalizedDateTooltip } from '.'; + +describe('LocalizedDateTooltip', () => { + beforeEach(() => { + moment.tz.setDefault('UTC'); + }); + afterEach(() => { + moment.tz.setDefault('Browser'); + }); + + moment.locale('en'); + const date = moment('2019-02-19 04:21:00'); + + const sampleContentText = + 'this content is typically the string representation of the date prop, but can be any valid react child'; + + const SampleContent = () => <span data-test-subj="sample-content">{sampleContentText}</span>; + + test('it renders the child content', () => { + const wrapper = mount( + <LocalizedDateTooltip date={date.toDate()}> + <SampleContent /> + </LocalizedDateTooltip> + ); + + expect(wrapper.find('[data-test-subj="sample-content"]').exists()).toEqual(true); + }); + + test('it renders', () => { + const wrapper = mount( + <LocalizedDateTooltip date={date.toDate()}> + <SampleContent /> + </LocalizedDateTooltip> + ); + + expect(wrapper.find('[data-test-subj="localized-date-tool-tip"]').exists()).toEqual(true); + }); +}); diff --git a/x-pack/plugins/cases/public/components/localized_date_tooltip/index.tsx b/x-pack/plugins/cases/public/components/localized_date_tooltip/index.tsx new file mode 100644 index 0000000000000..3b140caeeda30 --- /dev/null +++ b/x-pack/plugins/cases/public/components/localized_date_tooltip/index.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; +import moment from 'moment'; +import React from 'react'; + +export const LocalizedDateTooltip = React.memo<{ + children: React.ReactNode; + date: Date; + fieldName?: string; + className?: string; +}>(({ children, date, fieldName, className = '' }) => ( + <EuiToolTip + data-test-subj="localized-date-tool-tip" + anchorClassName={className} + content={ + <EuiFlexGroup data-test-subj="dates-container" direction="column" gutterSize="none"> + {fieldName != null ? ( + <EuiFlexItem grow={false}> + <span data-test-subj="field-name">{fieldName}</span> + </EuiFlexItem> + ) : null} + <EuiFlexItem grow={false}> + <FormattedRelative + data-test-subj="humanized-relative-date" + value={moment.utc(date).toDate()} + /> + </EuiFlexItem> + <EuiFlexItem data-test-subj="with-day-of-week" grow={false}> + {moment.utc(date).local().format('llll')} + </EuiFlexItem> + <EuiFlexItem data-test-subj="with-time-zone-offset-in-hours" grow={false}> + {moment(date).format()} + </EuiFlexItem> + </EuiFlexGroup> + } + > + <>{children}</> + </EuiToolTip> +)); + +LocalizedDateTooltip.displayName = 'LocalizedDateTooltip'; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx new file mode 100644 index 0000000000000..f80e66a8c3e9f --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx @@ -0,0 +1,62 @@ +/* + * 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, { memo, useEffect, useState, useCallback } from 'react'; +import { PluggableList } from 'unified'; +import { EuiMarkdownEditor } from '@elastic/eui'; +import { EuiMarkdownEditorUiPlugin } from '@elastic/eui'; +import { usePlugins } from './use_plugins'; + +interface MarkdownEditorProps { + ariaLabel: string; + dataTestSubj?: string; + editorId?: string; + height?: number; + onChange: (content: string) => void; + parsingPlugins?: PluggableList; + processingPlugins?: PluggableList; + uiPlugins?: EuiMarkdownEditorUiPlugin[] | undefined; + value: string; +} + +const MarkdownEditorComponent: React.FC<MarkdownEditorProps> = ({ + ariaLabel, + dataTestSubj, + editorId, + height, + onChange, + value, +}) => { + const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); + const onParse = useCallback((err, { messages }) => { + setMarkdownErrorMessages(err ? [err] : messages); + }, []); + const { parsingPlugins, processingPlugins, uiPlugins } = usePlugins(); + + useEffect( + () => document.querySelector<HTMLElement>('textarea.euiMarkdownEditorTextArea')?.focus(), + [] + ); + + return ( + <EuiMarkdownEditor + aria-label={ariaLabel} + editorId={editorId} + onChange={onChange} + value={value} + uiPlugins={uiPlugins} + parsingPluginList={parsingPlugins} + processingPluginList={processingPlugins} + onParse={onParse} + errors={markdownErrorMessages} + data-test-subj={dataTestSubj} + height={height} + /> + ); +}; + +export const MarkdownEditor = memo(MarkdownEditorComponent); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx new file mode 100644 index 0000000000000..5b0634302dfb6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx @@ -0,0 +1,65 @@ +/* + * 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 styled from 'styled-components'; +import { EuiMarkdownEditorProps, EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../common/shared_imports'; +import { MarkdownEditor } from './editor'; + +type MarkdownEditorFormProps = EuiMarkdownEditorProps & { + id: string; + field: FieldHook; + dataTestSubj: string; + idAria: string; + isDisabled?: boolean; + bottomRightContent?: React.ReactNode; +}; + +const BottomContentWrapper = styled(EuiFlexGroup)` + ${({ theme }) => ` + padding: ${theme.eui.ruleMargins.marginSmall} 0; + `} +`; + +export const MarkdownEditorForm: React.FC<MarkdownEditorFormProps> = ({ + id, + field, + dataTestSubj, + idAria, + bottomRightContent, +}) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + return ( + <EuiFormRow + data-test-subj={dataTestSubj} + describedByIds={idAria ? [idAria] : undefined} + error={errorMessage} + fullWidth + helpText={field.helpText} + isInvalid={isInvalid} + label={field.label} + labelAppend={field.labelAppend} + > + <> + <MarkdownEditor + ariaLabel={idAria} + editorId={id} + onChange={field.setValue} + value={field.value as string} + data-test-subj={`${dataTestSubj}-markdown-editor`} + /> + {bottomRightContent && ( + <BottomContentWrapper justifyContent={'flexEnd'}> + <EuiFlexItem grow={false}>{bottomRightContent}</EuiFlexItem> + </BottomContentWrapper> + )} + </> + </EuiFormRow> + ); +}; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/index.tsx b/x-pack/plugins/cases/public/components/markdown_editor/index.tsx new file mode 100644 index 0000000000000..e77a36d48f7d9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/index.tsx @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './types'; +export * from './renderer'; +export * from './editor'; +export * from './eui_form'; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/markdown_link.tsx b/x-pack/plugins/cases/public/components/markdown_editor/markdown_link.tsx new file mode 100644 index 0000000000000..7cc8a07c8c04e --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/markdown_link.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiLink, EuiLinkAnchorProps, EuiToolTip } from '@elastic/eui'; + +type MarkdownLinkProps = { disableLinks?: boolean } & EuiLinkAnchorProps; + +/** prevents search engine manipulation by noting the linked document is not trusted or endorsed by us */ +const REL_NOFOLLOW = 'nofollow'; + +const MarkdownLinkComponent: React.FC<MarkdownLinkProps> = ({ + disableLinks, + href, + target, + children, + ...props +}) => ( + <EuiToolTip content={href}> + <EuiLink + href={disableLinks ? undefined : href} + data-test-subj="markdown-link" + rel={`${REL_NOFOLLOW}`} + target="_blank" + > + {children} + </EuiLink> + </EuiToolTip> +); + +export const MarkdownLink = memo(MarkdownLinkComponent); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/renderer.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/renderer.test.tsx new file mode 100644 index 0000000000000..5d299529561ba --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/renderer.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { removeExternalLinkText } from '../../common/test_utils'; +import { MarkdownRenderer } from './renderer'; + +describe('Markdown', () => { + describe('markdown links', () => { + const markdownWithLink = 'A link to an external site [External Site](https://google.com)'; + + test('it renders the expected link text', () => { + const wrapper = mount(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>); + + expect( + removeExternalLinkText(wrapper.find('[data-test-subj="markdown-link"]').first().text()) + ).toEqual('External Site'); + }); + + test('it renders the expected href', () => { + const wrapper = mount(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>); + + expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( + 'href', + 'https://google.com/' + ); + }); + + test('it does NOT render the href if links are disabled', () => { + const wrapper = mount( + <MarkdownRenderer disableLinks={true}>{markdownWithLink}</MarkdownRenderer> + ); + + expect( + wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode() + ).not.toHaveProperty('href'); + }); + + test('it opens links in a new tab via target="_blank"', () => { + const wrapper = mount(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>); + + expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( + 'target', + '_blank' + ); + }); + + test('it sets the link `rel` attribute to `noopener` to prevent the new page from accessing `window.opener`, `nofollow` to note the link is not endorsed by us, and noreferrer to prevent the browser from sending the current address', () => { + const wrapper = mount(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>); + + expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( + 'rel', + 'nofollow noopener noreferrer' + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/renderer.tsx b/x-pack/plugins/cases/public/components/markdown_editor/renderer.tsx new file mode 100644 index 0000000000000..6a91dda97a892 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/renderer.tsx @@ -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 React, { memo, useMemo } from 'react'; +import { cloneDeep } from 'lodash/fp'; +import { EuiMarkdownFormat, EuiLinkAnchorProps } from '@elastic/eui'; +import { MarkdownLink } from './markdown_link'; +import { usePlugins } from './use_plugins'; + +interface Props { + children: string; + disableLinks?: boolean; +} + +const MarkdownRendererComponent: React.FC<Props> = ({ children, disableLinks }) => { + const { processingPlugins, parsingPlugins } = usePlugins(); + const MarkdownLinkProcessingComponent: React.FC<EuiLinkAnchorProps> = useMemo( + () => (props) => <MarkdownLink {...props} disableLinks={disableLinks} />, + [disableLinks] + ); + // Deep clone of the processing plugins to prevent affecting the markdown editor. + const processingPluginList = cloneDeep(processingPlugins); + // This line of code is TS-compatible and it will break if [1][1] change in the future. + processingPluginList[1][1].components.a = MarkdownLinkProcessingComponent; + + return ( + <EuiMarkdownFormat + parsingPluginList={parsingPlugins} + processingPluginList={processingPluginList} + > + {children} + </EuiMarkdownFormat> + ); +}; + +export const MarkdownRenderer = memo(MarkdownRendererComponent); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/translations.ts b/x-pack/plugins/cases/public/components/markdown_editor/translations.ts new file mode 100644 index 0000000000000..365738f53ef8a --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/translations.ts @@ -0,0 +1,19 @@ +/* + * 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 MARKDOWN_SYNTAX_HELP = i18n.translate('xpack.cases.markdownEditor.markdownInputHelp', { + defaultMessage: 'Markdown syntax help', +}); + +export const MARKDOWN = i18n.translate('xpack.cases.markdownEditor.markdown', { + defaultMessage: 'Markdown', +}); +export const PREVIEW = i18n.translate('xpack.cases.markdownEditor.preview', { + defaultMessage: 'Preview', +}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/types.ts b/x-pack/plugins/cases/public/components/markdown_editor/types.ts new file mode 100644 index 0000000000000..bb932f2fcfe22 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/types.ts @@ -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 { FunctionComponent } from 'react'; +import { Plugin, PluggableList } from 'unified'; +// Remove after this issue is resolved: https://github.com/elastic/eui/issues/4688 +// eslint-disable-next-line import/no-extraneous-dependencies +import { Options as Remark2RehypeOptions } from 'mdast-util-to-hast'; +// eslint-disable-next-line import/no-extraneous-dependencies +import rehype2react from 'rehype-react'; +import { EuiLinkAnchorProps } from '@elastic/eui'; +export interface CursorPosition { + start: number; + end: number; +} + +export type TemporaryProcessingPluginsType = [ + [Plugin, Remark2RehypeOptions], + [ + typeof rehype2react, + Parameters<typeof rehype2react>[0] & { + components: { a: FunctionComponent<EuiLinkAnchorProps>; timeline: unknown }; + } + ], + ...PluggableList +]; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts b/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts new file mode 100644 index 0000000000000..e98af8bca8bce --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts @@ -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 { + getDefaultEuiMarkdownParsingPlugins, + getDefaultEuiMarkdownProcessingPlugins, + getDefaultEuiMarkdownUiPlugins, +} from '@elastic/eui'; +import { useMemo } from 'react'; +import { useTimelineContext } from '../timeline_context/use_timeline_context'; +import { TemporaryProcessingPluginsType } from './types'; + +export const usePlugins = () => { + const timelinePlugins = useTimelineContext()?.editor_plugins; + + return useMemo(() => { + const uiPlugins = getDefaultEuiMarkdownUiPlugins(); + const parsingPlugins = getDefaultEuiMarkdownParsingPlugins(); + const processingPlugins = getDefaultEuiMarkdownProcessingPlugins() as TemporaryProcessingPluginsType; + + if (timelinePlugins) { + uiPlugins.push(timelinePlugins.uiPlugin); + + parsingPlugins.push(timelinePlugins.parsingPlugin); + + // This line of code is TS-compatible and it will break if [1][1] change in the future. + processingPlugins[1][1].components.timeline = timelinePlugins.processingPluginRenderer; + } + + return { + uiPlugins, + parsingPlugins, + processingPlugins, + }; + }, [timelinePlugins]); +}; diff --git a/x-pack/plugins/cases/public/components/panel/index.test.tsx b/x-pack/plugins/cases/public/components/panel/index.test.tsx new file mode 100644 index 0000000000000..81c80158ae577 --- /dev/null +++ b/x-pack/plugins/cases/public/components/panel/index.test.tsx @@ -0,0 +1,17 @@ +/* + * 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 { mount } from 'enzyme'; +import { Panel } from '.'; +import React from 'react'; + +describe('Panel', () => { + test('it does not have the boolean loading as a Eui Property', () => { + const wrapper = mount(<Panel loading={true} />); + expect(Object.keys(wrapper.find('EuiPanel').props())).not.toContain('loading'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/panel/index.tsx b/x-pack/plugins/cases/public/components/panel/index.tsx new file mode 100644 index 0000000000000..652d22409cb0c --- /dev/null +++ b/x-pack/plugins/cases/public/components/panel/index.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import styled from 'styled-components'; +import React from 'react'; +import { EuiPanel } from '@elastic/eui'; + +/** + * The reason for the type of syntax below of: + * `styled(({ loading, ...props })` + * is filter out the "loading" attribute from being put on the DOM + * and getting one of the stack traces from + * ``` + * ReactJS about non-standard HTML such as this one: + * Warning: Received `true` for a non-boolean attribute `loading`. + * If you want to write it to the DOM, pass a string instead: loading="true" or loading={value.toString()}. + * ``` + * + * Ref: https://github.com/styled-components/styled-components/issues/1198#issuecomment-425650423 + * Ref: https://github.com/elastic/kibana/pull/41596#issuecomment-514418978 + * Ref: https://www.styled-components.com/docs/faqs#why-am-i-getting-html-attribute-warnings + * Ref: https://reactjs.org/blog/2017/09/08/dom-attributes-in-react-16.html + */ +export const Panel = styled(({ loading, ...props }) => <EuiPanel {...props} />)` + position: relative; + ${({ loading }) => + loading && + ` + overflow: hidden; + `} +`; + +Panel.displayName = 'Panel'; diff --git a/x-pack/plugins/security_solution/public/cases/components/property_actions/index.tsx b/x-pack/plugins/cases/public/components/property_actions/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/property_actions/index.tsx rename to x-pack/plugins/cases/public/components/property_actions/index.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/property_actions/translations.ts b/x-pack/plugins/cases/public/components/property_actions/translations.ts similarity index 63% rename from x-pack/plugins/security_solution/public/cases/components/property_actions/translations.ts rename to x-pack/plugins/cases/public/components/property_actions/translations.ts index c5c11e0637d7b..4066254878657 100644 --- a/x-pack/plugins/security_solution/public/cases/components/property_actions/translations.ts +++ b/x-pack/plugins/cases/public/components/property_actions/translations.ts @@ -7,9 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const ACTIONS_ARIA = i18n.translate( - 'xpack.securitySolution.cases.caseView.editActionsLinkAria', - { - defaultMessage: 'click to see all actions', - } -); +export const ACTIONS_ARIA = i18n.translate('xpack.cases.caseView.editActionsLinkAria', { + defaultMessage: 'click to see all actions', +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/filters/index.tsx b/x-pack/plugins/cases/public/components/recent_cases/filters/index.tsx similarity index 93% rename from x-pack/plugins/security_solution/public/overview/components/recent_cases/filters/index.tsx rename to x-pack/plugins/cases/public/components/recent_cases/filters/index.tsx index 5b6c59e31e202..cc37a826e18b9 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/filters/index.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/filters/index.tsx @@ -27,7 +27,7 @@ const toggleButtonIcons: EuiButtonGroupOptionProps[] = [ }, ]; -export const Filters = React.memo<{ +export const RecentCasesFilters = React.memo<{ filterBy: FilterMode; setFilterBy: (filterBy: FilterMode) => void; showMyRecentlyReported: boolean; @@ -57,4 +57,4 @@ export const Filters = React.memo<{ ); }); -Filters.displayName = 'Filters'; +RecentCasesFilters.displayName = 'RecentCasesFilters'; diff --git a/x-pack/plugins/cases/public/components/recent_cases/icon_with_count.tsx b/x-pack/plugins/cases/public/components/recent_cases/icon_with_count.tsx new file mode 100644 index 0000000000000..f46eb631ca2d6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/recent_cases/icon_with_count.tsx @@ -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 { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiToolTip } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +const Icon = styled(EuiIcon)` + margin-right: 8px; +`; + +const FlexGroup = styled(EuiFlexGroup)` + margin-right: 16px; +`; +const OuterContainer = styled.span` + width: fit-content; +`; +export const IconWithCount = React.memo<{ count: number; icon: string; tooltip: string }>( + ({ count, icon, tooltip }) => ( + <OuterContainer> + <EuiToolTip content={tooltip}> + <FlexGroup alignItems="center" gutterSize="none"> + <EuiFlexItem grow={false}> + <Icon color="subdued" size="s" type={icon} /> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiText color="subdued" size="xs"> + {count} + </EuiText> + </EuiFlexItem> + </FlexGroup> + </EuiToolTip> + </OuterContainer> + ) +); + +IconWithCount.displayName = 'IconWithCount'; diff --git a/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx b/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx new file mode 100644 index 0000000000000..933ea51bffac4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { configure, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import RecentCases from '.'; +import { TestProviders } from '../../common/mock'; +import { useGetCases } from '../../containers/use_get_cases'; +import { useGetCasesMockState } from '../../containers/mock'; +jest.mock('../../containers/use_get_cases'); +configure({ testIdAttribute: 'data-test-subj' }); +const defaultProps = { + allCasesNavigation: { + href: 'all-cases-href', + onClick: jest.fn(), + }, + caseDetailsNavigation: { + href: () => 'case-details-href', + onClick: jest.fn(), + }, + createCaseNavigation: { + href: 'create-details-href', + onClick: jest.fn(), + }, + maxCasesToShow: 10, +}; +const setFilters = jest.fn(); +const mockData = { + ...useGetCasesMockState, + setFilters, +}; +const useGetCasesMock = useGetCases as jest.Mock; +describe('RecentCases', () => { + beforeEach(() => { + jest.clearAllMocks(); + useGetCasesMock.mockImplementation(() => mockData); + }); + it('is good at loading', () => { + useGetCasesMock.mockImplementation(() => ({ + ...mockData, + loading: 'cases', + })); + const { getAllByTestId } = render( + <TestProviders> + <RecentCases {...defaultProps} /> + </TestProviders> + ); + expect(getAllByTestId('loadingPlaceholders')).toHaveLength(3); + }); + it('is good at rendering cases', () => { + const { getAllByTestId } = render( + <TestProviders> + <RecentCases {...defaultProps} /> + </TestProviders> + ); + expect(getAllByTestId('case-details-link')).toHaveLength(5); + }); + it('is good at rendering max cases', () => { + render( + <TestProviders> + <RecentCases {...{ ...defaultProps, maxCasesToShow: 2 }} /> + </TestProviders> + ); + expect(useGetCasesMock).toBeCalledWith({ perPage: 2 }); + }); + it('updates filters', () => { + const { getByTestId } = render( + <TestProviders> + <RecentCases {...defaultProps} /> + </TestProviders> + ); + const yo = getByTestId('myRecentlyReported'); + userEvent.click(yo); + expect(setFilters).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/recent_cases/index.tsx b/x-pack/plugins/cases/public/components/recent_cases/index.tsx new file mode 100644 index 0000000000000..05aff25d0dbd8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/recent_cases/index.tsx @@ -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 { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiText, EuiTitle } from '@elastic/eui'; +import React, { useMemo, useState } from 'react'; + +import * as i18n from './translations'; +import { CaseDetailsHrefSchema, CasesNavigation, LinkAnchor } from '../links'; +import { RecentCasesFilters } from './filters'; +import { RecentCasesComp } from './recent_cases'; +import { FilterMode as RecentCasesFilterMode } from './types'; +import { useCurrentUser } from '../../common/lib/kibana'; + +export interface RecentCasesProps { + allCasesNavigation: CasesNavigation; + caseDetailsNavigation: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>; + createCaseNavigation: CasesNavigation; + maxCasesToShow: number; +} + +const RecentCases = ({ + allCasesNavigation, + caseDetailsNavigation, + createCaseNavigation, + maxCasesToShow, +}: RecentCasesProps) => { + const currentUser = useCurrentUser(); + const [recentCasesFilterBy, setRecentCasesFilterBy] = useState<RecentCasesFilterMode>( + 'recentlyCreated' + ); + + const recentCasesFilterOptions = useMemo( + () => + recentCasesFilterBy === 'myRecentlyReported' && currentUser != null + ? { + reporters: [ + { + email: currentUser.email, + full_name: currentUser.fullName, + username: currentUser.username, + }, + ], + } + : {}, + [currentUser, recentCasesFilterBy] + ); + return ( + <> + <> + <EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiTitle size="xs"> + <h2>{i18n.RECENT_CASES}</h2> + </EuiTitle> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <RecentCasesFilters + filterBy={recentCasesFilterBy} + setFilterBy={setRecentCasesFilterBy} + showMyRecentlyReported={currentUser != null} + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiHorizontalRule margin="s" /> + </> + <EuiText color="subdued" size="s"> + <RecentCasesComp + caseDetailsNavigation={caseDetailsNavigation} + createCaseNavigation={createCaseNavigation} + filterOptions={recentCasesFilterOptions} + maxCasesToShow={maxCasesToShow} + /> + <EuiHorizontalRule margin="s" /> + <EuiText size="xs"> + <LinkAnchor onClick={allCasesNavigation.onClick} href={allCasesNavigation.href}> + {' '} + {i18n.VIEW_ALL_CASES} + </LinkAnchor> + </EuiText> + </EuiText> + </> + ); +}; + +// eslint-disable-next-line import/no-default-export +export { RecentCases as default }; diff --git a/x-pack/plugins/cases/public/components/recent_cases/loading_placeholders.tsx b/x-pack/plugins/cases/public/components/recent_cases/loading_placeholders.tsx new file mode 100644 index 0000000000000..6e839e00a511d --- /dev/null +++ b/x-pack/plugins/cases/public/components/recent_cases/loading_placeholders.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiLoadingContent, EuiSpacer } from '@elastic/eui'; +import React from 'react'; + +const LoadingPlaceholdersComponent: React.FC<{ + lines: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; + placeholders: number; +}> = ({ lines, placeholders }) => ( + <> + {[...Array(placeholders).keys()].map((_, i) => ( + <React.Fragment key={i}> + <EuiLoadingContent lines={lines} data-test-subj={'loadingPlaceholders'} /> + {i !== placeholders - 1 && <EuiSpacer size="l" />} + </React.Fragment> + ))} + </> +); + +LoadingPlaceholdersComponent.displayName = 'LoadingPlaceholdersComponent'; + +export const LoadingPlaceholders = React.memo(LoadingPlaceholdersComponent); diff --git a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx new file mode 100644 index 0000000000000..0295632cc137a --- /dev/null +++ b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { TestProviders } from '../../../common/mock'; +import { NoCases } from '.'; + +describe('RecentCases', () => { + it('if no cases, a link to create cases will exist', () => { + const createCaseHref = '/create'; + const wrapper = mount( + <TestProviders> + <NoCases createCaseHref={createCaseHref} /> + </TestProviders> + ); + expect(wrapper.find(`[data-test-subj="no-cases-create-case"]`).first().prop('href')).toEqual( + createCaseHref + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx new file mode 100644 index 0000000000000..df0efcec4552c --- /dev/null +++ b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiLink } from '@elastic/eui'; +import * as i18n from '../translations'; + +const NoCasesComponent = ({ createCaseHref }: { createCaseHref: string }) => ( + <> + <span>{i18n.NO_CASES}</span> + <EuiLink + data-test-subj="no-cases-create-case" + href={createCaseHref} + >{` ${i18n.START_A_NEW_CASE}`}</EuiLink> + {'!'} + </> +); + +NoCasesComponent.displayName = 'NoCasesComponent'; + +export const NoCases = React.memo(NoCasesComponent); diff --git a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx new file mode 100644 index 0000000000000..12935e75c064f --- /dev/null +++ b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx @@ -0,0 +1,96 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import React, { useEffect, useMemo, useRef } from 'react'; +import { isEqual } from 'lodash/fp'; +import styled from 'styled-components'; + +import { IconWithCount } from './icon_with_count'; +import * as i18n from './translations'; +import { useGetCases } from '../../containers/use_get_cases'; +import { CaseDetailsHrefSchema, CaseDetailsLink, CasesNavigation } from '../links'; +import { LoadingPlaceholders } from './loading_placeholders'; +import { NoCases } from './no_cases'; +import { isSubCase } from '../all_cases/helpers'; +import { MarkdownRenderer } from '../markdown_editor'; +import { FilterOptions } from '../../containers/types'; + +const MarkdownContainer = styled.div` + max-height: 150px; + overflow-y: auto; + width: 300px; +`; + +export interface RecentCasesProps { + filterOptions: Partial<FilterOptions>; + caseDetailsNavigation: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>; + createCaseNavigation: CasesNavigation; + maxCasesToShow: number; +} +const usePrevious = (value: Partial<FilterOptions>) => { + const ref = useRef(); + useEffect(() => { + (ref.current as unknown) = value; + }); + return ref.current; +}; +export const RecentCasesComp = ({ + caseDetailsNavigation, + createCaseNavigation, + filterOptions, + maxCasesToShow, +}: RecentCasesProps) => { + const previousFilterOptions = usePrevious(filterOptions); + const { data, loading, setFilters } = useGetCases({ perPage: maxCasesToShow }); + + useEffect(() => { + if (previousFilterOptions !== undefined && !isEqual(previousFilterOptions, filterOptions)) { + setFilters(filterOptions); + } + }, [previousFilterOptions, filterOptions, setFilters]); + + const isLoadingCases = useMemo( + () => loading.indexOf('cases') > -1 || loading.indexOf('caseUpdate') > -1, + [loading] + ); + + return isLoadingCases ? ( + <LoadingPlaceholders lines={2} placeholders={3} /> + ) : !isLoadingCases && data.cases.length === 0 ? ( + <NoCases createCaseHref={createCaseNavigation.href} /> + ) : ( + <> + {data.cases.map((c, i) => ( + <EuiFlexGroup key={c.id} gutterSize="none" justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiText size="s"> + <CaseDetailsLink + caseDetailsNavigation={caseDetailsNavigation} + detailName={isSubCase(c) ? c.caseParentId : c.id} + title={c.title} + subCaseId={isSubCase(c) ? c.id : undefined} + > + {c.title} + </CaseDetailsLink> + </EuiText> + + <IconWithCount count={c.totalComment} icon={'editorComment'} tooltip={i18n.COMMENTS} /> + {c.description && c.description.length && ( + <MarkdownContainer> + <EuiText color="subdued" size="xs"> + <MarkdownRenderer disableLinks={true}>{c.description}</MarkdownRenderer> + </EuiText> + </MarkdownContainer> + )} + {i !== data.cases.length - 1 && <EuiSpacer size="l" />} + </EuiFlexItem> + </EuiFlexGroup> + ))} + </> + ); +}; diff --git a/x-pack/plugins/cases/public/components/recent_cases/translations.ts b/x-pack/plugins/cases/public/components/recent_cases/translations.ts new file mode 100644 index 0000000000000..c8f6c349d8f72 --- /dev/null +++ b/x-pack/plugins/cases/public/components/recent_cases/translations.ts @@ -0,0 +1,46 @@ +/* + * 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 COMMENTS = i18n.translate('xpack.cases.recentCases.commentsTooltip', { + defaultMessage: 'Comments', +}); + +export const MY_RECENTLY_REPORTED_CASES = i18n.translate( + 'xpack.cases.recentCases.myRecentlyReportedCasesButtonLabel', + { + defaultMessage: 'My recently reported cases', + } +); + +export const NO_CASES = i18n.translate('xpack.cases.recentCases.noCasesMessage', { + defaultMessage: 'No cases have been created yet. Put your detective hat on and', +}); + +export const RECENT_CASES = i18n.translate('xpack.cases.recentCases.recentCasesSidebarTitle', { + defaultMessage: 'Recent cases', +}); + +export const RECENTLY_CREATED_CASES = i18n.translate( + 'xpack.cases.recentCases.recentlyCreatedCasesButtonLabel', + { + defaultMessage: 'Recently created cases', + } +); + +export const START_A_NEW_CASE = i18n.translate('xpack.cases.recentCases.startNewCaseLink', { + defaultMessage: 'start a new case', +}); + +export const VIEW_ALL_CASES = i18n.translate('xpack.cases.recentCases.viewAllCasesLink', { + defaultMessage: 'View all cases', +}); + +export const CASES_FILTER_CONTROL = i18n.translate('xpack.cases.recentCases.controlLegend', { + defaultMessage: 'Cases filter', +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/types.ts b/x-pack/plugins/cases/public/components/recent_cases/types.ts similarity index 100% rename from x-pack/plugins/security_solution/public/overview/components/recent_cases/types.ts rename to x-pack/plugins/cases/public/components/recent_cases/types.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx b/x-pack/plugins/cases/public/components/status/button.test.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx rename to x-pack/plugins/cases/public/components/status/button.test.tsx index 6bf4eb95bc049..a4d4a53ff4a62 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx +++ b/x-pack/plugins/cases/public/components/status/button.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../common'; import { StatusActionButton } from './button'; describe('StatusActionButton', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/status/button.tsx b/x-pack/plugins/cases/public/components/status/button.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/status/button.tsx rename to x-pack/plugins/cases/public/components/status/button.tsx index 5a0d98fc8a11a..623afeb43c596 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/button.tsx +++ b/x-pack/plugins/cases/public/components/status/button.tsx @@ -8,7 +8,7 @@ import React, { memo, useCallback, useMemo } from 'react'; import { EuiButton } from '@elastic/eui'; -import { CaseStatuses, caseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses, caseStatuses } from '../../../common'; import { statuses } from './config'; interface Props { diff --git a/x-pack/plugins/security_solution/public/cases/components/status/config.ts b/x-pack/plugins/cases/public/components/status/config.ts similarity index 93% rename from x-pack/plugins/security_solution/public/cases/components/status/config.ts rename to x-pack/plugins/cases/public/components/status/config.ts index 47a74549f03cc..0202507aa3721 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/config.ts +++ b/x-pack/plugins/cases/public/components/status/config.ts @@ -4,9 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses, StatusAll } from '../../../common'; import * as i18n from './translations'; -import { AllCaseStatus, Statuses, StatusAll } from './types'; +import { AllCaseStatus, Statuses } from './types'; export const allCaseStatus: AllCaseStatus = { [StatusAll]: { color: 'hollow', label: i18n.ALL }, diff --git a/x-pack/plugins/security_solution/public/cases/components/status/index.ts b/x-pack/plugins/cases/public/components/status/index.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/status/index.ts rename to x-pack/plugins/cases/public/components/status/index.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/status/stats.test.tsx b/x-pack/plugins/cases/public/components/status/stats.test.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/status/stats.test.tsx rename to x-pack/plugins/cases/public/components/status/stats.test.tsx index 266ceb04e4335..b2da828da77b0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/stats.test.tsx +++ b/x-pack/plugins/cases/public/components/status/stats.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../common'; import { Stats } from './stats'; describe('Stats', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/status/stats.tsx b/x-pack/plugins/cases/public/components/status/stats.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/cases/components/status/stats.tsx rename to x-pack/plugins/cases/public/components/status/stats.tsx index 43001c2cf5947..071ea43746fdc 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/stats.tsx +++ b/x-pack/plugins/cases/public/components/status/stats.tsx @@ -7,7 +7,7 @@ import React, { memo, useMemo } from 'react'; import { EuiDescriptionList, EuiLoadingSpinner } from '@elastic/eui'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../common'; import { statuses } from './config'; export interface Props { diff --git a/x-pack/plugins/security_solution/public/cases/components/status/status.test.tsx b/x-pack/plugins/cases/public/components/status/status.test.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/status/status.test.tsx rename to x-pack/plugins/cases/public/components/status/status.test.tsx index eff9d73c2adf9..7cddbf5ca4a1d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/status.test.tsx +++ b/x-pack/plugins/cases/public/components/status/status.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../common'; import { Status } from './status'; describe('Stats', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/status/status.tsx b/x-pack/plugins/cases/public/components/status/status.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/cases/components/status/status.tsx rename to x-pack/plugins/cases/public/components/status/status.tsx index de4c979daf4c1..03dca8642aed7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/status.tsx +++ b/x-pack/plugins/cases/public/components/status/status.tsx @@ -10,8 +10,8 @@ import { noop } from 'lodash/fp'; import { EuiBadge } from '@elastic/eui'; import { allCaseStatus, statuses } from './config'; -import { CaseStatusWithAllStatus, StatusAll } from './types'; import * as i18n from './translations'; +import { CaseStatusWithAllStatus, StatusAll } from '../../../common'; interface Props { type: CaseStatusWithAllStatus; diff --git a/x-pack/plugins/cases/public/components/status/translations.ts b/x-pack/plugins/cases/public/components/status/translations.ts new file mode 100644 index 0000000000000..b3eadfd681ba5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/status/translations.ts @@ -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 { i18n } from '@kbn/i18n'; +export * from '../../common/translations'; + +export const ALL = i18n.translate('xpack.cases.status.all', { + defaultMessage: 'All', +}); + +export const OPEN = i18n.translate('xpack.cases.status.open', { + defaultMessage: 'Open', +}); + +export const IN_PROGRESS = i18n.translate('xpack.cases.status.inProgress', { + defaultMessage: 'In progress', +}); + +export const CLOSED = i18n.translate('xpack.cases.status.closed', { + defaultMessage: 'Closed', +}); + +export const STATUS_ICON_ARIA = i18n.translate('xpack.cases.status.iconAria', { + defaultMessage: 'Change status', +}); + +export const CASE_OPENED = i18n.translate('xpack.cases.caseView.caseOpened', { + defaultMessage: 'Case opened', +}); + +export const CASE_IN_PROGRESS = i18n.translate('xpack.cases.caseView.caseInProgress', { + defaultMessage: 'Case in progress', +}); + +export const CASE_CLOSED = i18n.translate('xpack.cases.caseView.caseClosed', { + defaultMessage: 'Case closed', +}); + +export const BULK_ACTION_CLOSE_SELECTED = i18n.translate( + 'xpack.cases.caseTable.bulkActions.closeSelectedTitle', + { + defaultMessage: 'Close selected', + } +); + +export const BULK_ACTION_OPEN_SELECTED = i18n.translate( + 'xpack.cases.caseTable.bulkActions.openSelectedTitle', + { + defaultMessage: 'Open selected', + } +); + +export const BULK_ACTION_DELETE_SELECTED = i18n.translate( + 'xpack.cases.caseTable.bulkActions.deleteSelectedTitle', + { + defaultMessage: 'Delete selected', + } +); + +export const BULK_ACTION_MARK_IN_PROGRESS = i18n.translate( + 'xpack.cases.caseTable.bulkActions.markInProgressTitle', + { + defaultMessage: 'Mark in progress', + } +); diff --git a/x-pack/plugins/security_solution/public/cases/components/status/types.ts b/x-pack/plugins/cases/public/components/status/types.ts similarity index 78% rename from x-pack/plugins/security_solution/public/cases/components/status/types.ts rename to x-pack/plugins/cases/public/components/status/types.ts index 5618e7802579d..f8115b8d692b3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/types.ts +++ b/x-pack/plugins/cases/public/components/status/types.ts @@ -6,12 +6,7 @@ */ import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; -import { CaseStatuses } from '../../../../../cases/common/api'; - -export const StatusAll = 'all' as const; -type StatusAllType = typeof StatusAll; - -export type CaseStatusWithAllStatus = CaseStatuses | StatusAllType; +import { CaseStatuses, StatusAllType } from '../../../common'; export type AllCaseStatus = Record<StatusAllType, { color: string; label: string }>; diff --git a/x-pack/plugins/cases/public/components/subtitle/__snapshots__/index.test.tsx.snap b/x-pack/plugins/cases/public/components/subtitle/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..7ffd043d16aed --- /dev/null +++ b/x-pack/plugins/cases/public/components/subtitle/__snapshots__/index.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Subtitle it renders 1`] = ` +<Wrapper + className="casesSubtitle" +> + <SubtitleItem> + Test subtitle + </SubtitleItem> +</Wrapper> +`; diff --git a/x-pack/plugins/cases/public/components/subtitle/index.test.tsx b/x-pack/plugins/cases/public/components/subtitle/index.test.tsx new file mode 100644 index 0000000000000..20120edc91937 --- /dev/null +++ b/x-pack/plugins/cases/public/components/subtitle/index.test.tsx @@ -0,0 +1,70 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; + +import { TestProviders } from '../../common/mock'; +import { Subtitle } from './index'; + +describe('Subtitle', () => { + test('it renders', () => { + const wrapper = shallow(<Subtitle items="Test subtitle" />); + + expect(wrapper).toMatchSnapshot(); + }); + + test('it renders one subtitle string item', () => { + const wrapper = mount( + <TestProviders> + <Subtitle items="Test subtitle" /> + </TestProviders> + ); + + expect(wrapper.find('.casesSubtitle__item--text').length).toEqual(1); + }); + + test('it renders multiple subtitle string items', () => { + const wrapper = mount( + <TestProviders> + <Subtitle items={['Test subtitle 1', 'Test subtitle 2']} /> + </TestProviders> + ); + + expect(wrapper.find('.casesSubtitle__item--text').length).toEqual(2); + }); + + test('it renders one subtitle React.ReactNode item', () => { + const wrapper = mount( + <TestProviders> + <Subtitle items={<span>{'Test subtitle'}</span>} /> + </TestProviders> + ); + + expect(wrapper.find('.casesSubtitle__item--node').length).toEqual(1); + }); + + test('it renders multiple subtitle React.ReactNode items', () => { + const wrapper = mount( + <TestProviders> + <Subtitle items={[<span>{'Test subtitle 1'}</span>, <span>{'Test subtitle 2'}</span>]} /> + </TestProviders> + ); + + expect(wrapper.find('.casesSubtitle__item--node').length).toEqual(2); + }); + + test('it renders multiple subtitle items of mixed type', () => { + const wrapper = mount( + <TestProviders> + <Subtitle items={['Test subtitle 1', <span>{'Test subtitle 2'}</span>]} /> + </TestProviders> + ); + + expect(wrapper.find('.casesSubtitle__item').length).toEqual(2); + }); +}); diff --git a/x-pack/plugins/cases/public/components/subtitle/index.tsx b/x-pack/plugins/cases/public/components/subtitle/index.tsx new file mode 100644 index 0000000000000..267c564fc498d --- /dev/null +++ b/x-pack/plugins/cases/public/components/subtitle/index.tsx @@ -0,0 +1,75 @@ +/* + * 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 styled, { css } from 'styled-components'; + +const Wrapper = styled.div` + ${({ theme }) => css` + margin-top: ${theme.eui.euiSizeS}; + + .casesSubtitle__item { + color: ${theme.eui.euiTextSubduedColor}; + font-size: ${theme.eui.euiFontSizeXS}; + line-height: ${theme.eui.euiLineHeight}; + + @media only screen and (min-width: ${theme.eui.euiBreakpoints.s}) { + display: inline-block; + margin-right: ${theme.eui.euiSize}; + + &:last-child { + margin-right: 0; + } + } + } + `} +`; +Wrapper.displayName = 'Wrapper'; + +interface SubtitleItemProps { + children: string | React.ReactNode; + dataTestSubj?: string; +} + +const SubtitleItem = React.memo<SubtitleItemProps>( + ({ children, dataTestSubj = 'header-panel-subtitle' }) => { + if (typeof children === 'string') { + return ( + <p className="casesSubtitle__item casesSubtitle__item--text" data-test-subj={dataTestSubj}> + {children} + </p> + ); + } else { + return ( + <div + className="casesSubtitle__item casesSubtitle__item--node" + data-test-subj={dataTestSubj} + > + {children} + </div> + ); + } + } +); +SubtitleItem.displayName = 'SubtitleItem'; + +export interface SubtitleProps { + items: string | React.ReactNode | Array<string | React.ReactNode>; +} + +export const Subtitle = React.memo<SubtitleProps>(({ items }) => { + return ( + <Wrapper className="casesSubtitle"> + {Array.isArray(items) ? ( + items.map((item, i) => <SubtitleItem key={i}>{item}</SubtitleItem>) + ) : ( + <SubtitleItem>{items}</SubtitleItem> + )} + </Wrapper> + ); +}); +Subtitle.displayName = 'Subtitle'; diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx b/x-pack/plugins/cases/public/components/tag_list/index.test.tsx similarity index 89% rename from x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx rename to x-pack/plugins/cases/public/components/tag_list/index.test.tsx index eb9cef2d9d1ef..296c4ba0e893b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/index.test.tsx @@ -10,17 +10,15 @@ import { mount } from 'enzyme'; import { TagList } from '.'; import { getFormMock } from '../__mock__/form'; -import { TestProviders } from '../../../common/mock'; +import { TestProviders } from '../../common/mock'; import { waitFor } from '@testing-library/react'; -import { useForm } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'; +import { useForm } from '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'; import { useGetTags } from '../../containers/use_get_tags'; -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'); jest.mock('../../containers/use_get_tags'); jest.mock( - '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider', + '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider', () => ({ FormDataProvider: ({ children }: { children: ({ tags }: { tags: string[] }) => void }) => children({ tags: ['rad', 'dude'] }), @@ -30,7 +28,6 @@ jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { ...original, - // eslint-disable-next-line react/display-name EuiFieldText: () => <input />, }; }); diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx b/x-pack/plugins/cases/public/components/tag_list/index.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx rename to x-pack/plugins/cases/public/components/tag_list/index.tsx index 8e47437b37c0e..137d58932b6ef 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/index.tsx @@ -19,7 +19,7 @@ import { import styled, { css } from 'styled-components'; import { isEqual } from 'lodash/fp'; import * as i18n from './translations'; -import { Form, FormDataProvider, useForm, getUseField, Field } from '../../../shared_imports'; +import { Form, FormDataProvider, useForm, getUseField, Field } from '../../common/shared_imports'; import { schema } from './schema'; import { useGetTags } from '../../containers/use_get_tags'; diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/schema.tsx b/x-pack/plugins/cases/public/components/tag_list/schema.tsx similarity index 86% rename from x-pack/plugins/security_solution/public/cases/components/tag_list/schema.tsx rename to x-pack/plugins/cases/public/components/tag_list/schema.tsx index 281198d51ea7d..d7db17bd97cbd 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/schema.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/schema.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { FormSchema } from '../../../shared_imports'; +import { FormSchema } from '../../common/shared_imports'; import { schemaTags } from '../create/schema'; export const schema: FormSchema = { diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx b/x-pack/plugins/cases/public/components/tag_list/tags.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx rename to x-pack/plugins/cases/public/components/tag_list/tags.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/translations.ts b/x-pack/plugins/cases/public/components/tag_list/translations.ts similarity index 59% rename from x-pack/plugins/security_solution/public/cases/components/tag_list/translations.ts rename to x-pack/plugins/cases/public/components/tag_list/translations.ts index 4bddfbdbc1a85..54e9cd05039fc 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/translations.ts +++ b/x-pack/plugins/cases/public/components/tag_list/translations.ts @@ -7,11 +7,8 @@ import { i18n } from '@kbn/i18n'; -export * from '../../translations'; +export * from '../../common/translations'; -export const EDIT_TAGS_ARIA = i18n.translate( - 'xpack.securitySolution.cases.caseView.editTagsLinkAria', - { - defaultMessage: 'click to edit tags', - } -); +export const EDIT_TAGS_ARIA = i18n.translate('xpack.cases.caseView.editTagsLinkAria', { + defaultMessage: 'click to edit tags', +}); diff --git a/x-pack/plugins/cases/public/components/timeline_context/index.tsx b/x-pack/plugins/cases/public/components/timeline_context/index.tsx new file mode 100644 index 0000000000000..727e4b64628d1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/timeline_context/index.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { EuiMarkdownEditorUiPlugin, EuiMarkdownAstNodePosition } from '@elastic/eui'; +import { Plugin } from 'unified'; +/** + * @description - manage the plugins, hooks, and ui components needed to enable timeline functionality within the cases plugin + * @TODO - To better encapsulate the timeline logic needed by cases, we are managing it in this top level context. + * This helps us avoid any prop drilling and makes it much easier later on to remove this logic when timeline becomes it's own plugin. + */ + +// TODO: copied from 'use_insert_timeline' in security_solution till timeline moved into it's own plugin. +interface UseInsertTimelineReturn { + handleOnTimelineChange: (title: string, id: string | null, graphEventId?: string) => void; +} + +interface TimelineProcessingPluginRendererProps { + id: string | null; + title: string; + graphEventId?: string; + type: 'timeline'; + [key: string]: string | null | undefined; +} + +export interface CasesTimelineIntegration { + editor_plugins: { + parsingPlugin: Plugin; + processingPluginRenderer: React.FC< + TimelineProcessingPluginRendererProps & { position: EuiMarkdownAstNodePosition } + >; + uiPlugin: EuiMarkdownEditorUiPlugin; + }; + hooks: { + useInsertTimeline: ( + value: string, + onChange: (newValue: string) => void + ) => UseInsertTimelineReturn; + }; + ui?: { + renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element; + renderTimelineDetailsPanel?: () => JSX.Element; + }; +} + +// This context is available to all children of the stateful_event component where the provider is currently set +export const CasesTimelineIntegrationContext = React.createContext<CasesTimelineIntegration | null>( + null +); + +export const CasesTimelineIntegrationProvider: React.FC<{ + timelineIntegration?: CasesTimelineIntegration; +}> = ({ children, timelineIntegration }) => { + const [activeTimelineIntegration] = useState(timelineIntegration ?? null); + + return ( + <CasesTimelineIntegrationContext.Provider value={activeTimelineIntegration}> + {children} + </CasesTimelineIntegrationContext.Provider> + ); +}; diff --git a/x-pack/plugins/cases/public/components/timeline_context/use_timeline_context.ts b/x-pack/plugins/cases/public/components/timeline_context/use_timeline_context.ts new file mode 100644 index 0000000000000..d0f9417c20ab1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/timeline_context/use_timeline_context.ts @@ -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. + */ + +import { useContext } from 'react'; +import { CasesTimelineIntegrationContext } from '.'; + +export const useTimelineContext = () => { + return useContext(CasesTimelineIntegrationContext); +}; diff --git a/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.test.tsx b/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.test.tsx new file mode 100644 index 0000000000000..661a0eedfeae4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.test.tsx @@ -0,0 +1,76 @@ +/* + * 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 { CreateCaseModal } from './create_case_modal'; +import { TestProviders } from '../../common/mock'; +import { getCreateCaseLazy as getCreateCase } from '../../methods'; + +jest.mock('../../methods'); +const getCreateCaseMock = getCreateCase as jest.Mock; +const onCloseCaseModal = jest.fn(); +const onSuccess = jest.fn(); +const defaultProps = { + isModalOpen: true, + onCloseCaseModal, + onSuccess, +}; + +describe('CreateCaseModal', () => { + beforeEach(() => { + jest.resetAllMocks(); + getCreateCaseMock.mockReturnValue(<></>); + }); + + it('renders', () => { + const wrapper = mount( + <TestProviders> + <CreateCaseModal {...defaultProps} /> + </TestProviders> + ); + + expect(wrapper.find(`[data-test-subj='create-case-modal']`).exists()).toBeTruthy(); + }); + + it('it does not render the modal isModalOpen=false ', () => { + const wrapper = mount( + <TestProviders> + <CreateCaseModal {...defaultProps} isModalOpen={false} /> + </TestProviders> + ); + + expect(wrapper.find(`[data-test-subj='create-case-modal']`).exists()).toBeFalsy(); + }); + + it('Closing modal calls onCloseCaseModal', () => { + const wrapper = mount( + <TestProviders> + <CreateCaseModal {...defaultProps} /> + </TestProviders> + ); + + wrapper.find('.euiModal__closeIcon').first().simulate('click'); + expect(onCloseCaseModal).toBeCalled(); + }); + + it('pass the correct props to getCreateCase method', () => { + mount( + <TestProviders> + <CreateCaseModal {...defaultProps} /> + </TestProviders> + ); + + expect(getCreateCaseMock.mock.calls[0][0]).toEqual( + expect.objectContaining({ + onSuccess, + onCancel: onCloseCaseModal, + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx b/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.tsx similarity index 54% rename from x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx rename to x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.tsx index 4b5eb00d95a80..e78b432b3a27c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx +++ b/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.tsx @@ -6,57 +6,41 @@ */ import React, { memo } from 'react'; -import styled from 'styled-components'; import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; -import { FormContext } from '../create/form_context'; -import { CreateCaseForm } from '../create/form'; -import { SubmitCaseButton } from '../create/submit_button'; import { Case } from '../../containers/types'; -import * as i18n from '../../translations'; -import { CaseType } from '../../../../../cases/common/api'; +import * as i18n from '../../common/translations'; +import { CaseType } from '../../../common'; +import { getCreateCaseLazy as getCreateCase } from '../../methods'; export interface CreateCaseModalProps { + caseType?: CaseType; + hideConnectorServiceNowSir?: boolean; isModalOpen: boolean; onCloseCaseModal: () => void; onSuccess: (theCase: Case) => Promise<void>; - caseType?: CaseType; - hideConnectorServiceNowSir?: boolean; } -const Container = styled.div` - ${({ theme }) => ` - margin-top: ${theme.eui.euiSize}; - text-align: right; - `} -`; - const CreateModalComponent: React.FC<CreateCaseModalProps> = ({ + caseType = CaseType.individual, + hideConnectorServiceNowSir, isModalOpen, onCloseCaseModal, onSuccess, - caseType = CaseType.individual, - hideConnectorServiceNowSir = false, }) => { return isModalOpen ? ( - <EuiModal onClose={onCloseCaseModal} data-test-subj="all-cases-modal"> + <EuiModal onClose={onCloseCaseModal} data-test-subj="create-case-modal"> <EuiModalHeader> <EuiModalHeaderTitle>{i18n.CREATE_TITLE}</EuiModalHeaderTitle> </EuiModalHeader> <EuiModalBody> - <FormContext - hideConnectorServiceNowSir={hideConnectorServiceNowSir} - caseType={caseType} - onSuccess={onSuccess} - > - <CreateCaseForm - withSteps={false} - hideConnectorServiceNowSir={hideConnectorServiceNowSir} - /> - <Container> - <SubmitCaseButton /> - </Container> - </FormContext> + {getCreateCase({ + caseType, + hideConnectorServiceNowSir, + onCancel: onCloseCaseModal, + onSuccess, + withSteps: false, + })} </EuiModalBody> </EuiModal> ) : null; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.test.tsx b/x-pack/plugins/cases/public/components/use_create_case_modal/index.test.tsx similarity index 66% rename from x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.test.tsx rename to x-pack/plugins/cases/public/components/use_create_case_modal/index.test.tsx index 5174c03e56e0b..b227dd4b898b2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.test.tsx +++ b/x-pack/plugins/cases/public/components/use_create_case_modal/index.test.tsx @@ -5,63 +5,15 @@ * 2.0. */ -/* eslint-disable react/display-name */ -import React, { ReactNode } from 'react'; +import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { render } from '@testing-library/react'; -import { useKibana } from '../../../common/lib/kibana'; -import '../../../common/mock/match_media'; +import { useKibana } from '../../common/lib/kibana'; import { useCreateCaseModal, UseCreateCaseModalProps, UseCreateCaseModalReturnedValues } from '.'; -import { mockTimelineModel, TestProviders } from '../../../common/mock'; -import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; - -jest.mock('../../../common/lib/kibana'); -jest.mock('../create/form_context', () => { - return { - FormContext: ({ - children, - onSuccess, - }: { - children: ReactNode; - onSuccess: ({ id }: { id: string }) => Promise<void>; - }) => { - return ( - <> - <button - type="button" - data-test-subj="form-context-on-success" - onClick={async () => { - await onSuccess({ id: 'case-id' }); - }} - > - {'Form submit'} - </button> - {children} - </> - ); - }, - }; -}); - -jest.mock('../create/form', () => { - return { - CreateCaseForm: () => { - return <>{'form'}</>; - }, - }; -}); - -jest.mock('../create/submit_button', () => { - return { - SubmitCaseButton: () => { - return <>{'Submit'}</>; - }, - }; -}); +import { TestProviders } from '../../common/mock'; -jest.mock('../../../common/hooks/use_selector'); +jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; const onCaseCreated = jest.fn(); @@ -72,7 +24,6 @@ describe('useCreateCaseModal', () => { beforeEach(() => { navigateToApp = jest.fn(); useKibanaMock().services.application.navigateToApp = navigateToApp; - (useDeepEqualSelector as jest.Mock).mockReturnValue(mockTimelineModel); }); it('init', async () => { @@ -148,7 +99,7 @@ describe('useCreateCaseModal', () => { render(<TestProviders>{modal}</TestProviders>); act(() => { - userEvent.click(screen.getByText('Form submit')); + result.current.modal.props.onSuccess({ id: 'case-id' }); }); expect(result.current.isModalOpen).toBe(false); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx b/x-pack/plugins/cases/public/components/use_create_case_modal/index.tsx similarity index 91% rename from x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx rename to x-pack/plugins/cases/public/components/use_create_case_modal/index.tsx index 5d2f54bd1f142..7ad85773a7917 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx +++ b/x-pack/plugins/cases/public/components/use_create_case_modal/index.tsx @@ -6,8 +6,7 @@ */ import React, { useState, useCallback, useMemo } from 'react'; -import { CaseType } from '../../../../../cases/common/api'; -import { Case } from '../../containers/types'; +import { Case, CaseType } from '../../../common'; import { CreateCaseModal } from './create_case_modal'; export interface UseCreateCaseModalProps { @@ -38,7 +37,7 @@ export const useCreateCaseModal = ({ [onCaseCreated, closeModal] ); - const state = useMemo( + return useMemo( () => ({ modal: ( <CreateCaseModal @@ -55,6 +54,4 @@ export const useCreateCaseModal = ({ }), [caseType, closeModal, hideConnectorServiceNowSir, isModalOpen, onSuccess, openModal] ); - - return state; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx b/x-pack/plugins/cases/public/components/use_push_to_service/helpers.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx rename to x-pack/plugins/cases/public/components/use_push_to_service/helpers.tsx index 30d2cb720c031..302e45f5e7e70 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx +++ b/x-pack/plugins/cases/public/components/use_push_to_service/helpers.tsx @@ -19,7 +19,7 @@ export const getLicenseError = () => ({ description: ( <FormattedMessage defaultMessage="Opening cases in external systems is available when you have the {appropriateLicense}, are using a {cloud}, or are testing out a Free Trial." - id="xpack.securitySolution.cases.caseView.pushToServiceDisableByLicenseDescription" + id="xpack.cases.caseView.pushToServiceDisableByLicenseDescription" values={{ appropriateLicense: ( <EuiLink href="https://www.elastic.co/subscriptions" target="_blank"> @@ -42,7 +42,7 @@ export const getKibanaConfigError = () => ({ description: ( <FormattedMessage defaultMessage="The kibana.yml file is configured to only allow specific connectors. To enable opening a case in external systems, add .[actionTypeId] (ex: .servicenow | .jira) to the xpack.actions.enabledActiontypes setting. For more information, see {link}." - id="xpack.securitySolution.cases.caseView.pushToServiceDisableByConfigDescription" + id="xpack.cases.caseView.pushToServiceDisableByConfigDescription" values={{ link: ( <EuiLink href="#" target="_blank"> diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx b/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx rename to x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx index c058473bbfe3f..d808234bcad36 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx @@ -5,20 +5,18 @@ * 2.0. */ -/* eslint-disable react/display-name */ import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; -import '../../../common/mock/match_media'; +import '../../common/mock/match_media'; import { usePushToService, ReturnUsePushToService, UsePushToService } from '.'; -import { TestProviders } from '../../../common/mock'; - -import { CaseStatuses } from '../../../../../cases/common/api'; +import { TestProviders } from '../../common/mock'; +import { CaseStatuses } from '../../../common'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { basicPush, actionLicenses } from '../../containers/mock'; import { useGetActionLicense } from '../../containers/use_get_action_license'; import { connectorsMock } from '../../containers/configure/mock'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; +import { ConnectorTypes } from '../../../common/api/connectors'; jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -31,7 +29,6 @@ jest.mock('react-router-dom', () => { }; }); -jest.mock('../../../common/components/link_to'); jest.mock('../../containers/use_get_action_license'); jest.mock('../../containers/use_post_push_to_service'); jest.mock('../../containers/configure/api'); @@ -67,10 +64,14 @@ describe('usePushToService', () => { caseId, caseServices, caseStatus: CaseStatuses.open, + configureCasesNavigation: { + href: 'href', + onClick: jest.fn(), + }, connectors: connectorsMock, + isValidConnector: true, updateCase, userCanCrud: true, - isValidConnector: true, }; beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx b/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx similarity index 83% rename from x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx rename to x-pack/plugins/cases/public/components/use_push_to_service/index.tsx index d83ddb08b51d2..a4ce8e3d92522 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx @@ -8,24 +8,22 @@ import { EuiButton, EuiToolTip } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback, useMemo } from 'react'; -import { useHistory } from 'react-router-dom'; import { Case } from '../../containers/types'; import { useGetActionLicense } from '../../containers/use_get_action_license'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; -import { getConfigureCasesUrl, useFormatUrl } from '../../../common/components/link_to'; import { CaseCallOut } from '../callout'; import { getLicenseError, getKibanaConfigError } from './helpers'; import * as i18n from './translations'; -import { CaseConnector, ActionConnector, CaseStatuses } from '../../../../../cases/common/api'; +import { CaseConnector, ActionConnector, CaseStatuses } from '../../../common'; import { CaseServices } from '../../containers/use_get_case_user_actions'; -import { LinkAnchor } from '../../../common/components/links'; -import { SecurityPageName } from '../../../app/types'; +import { CasesNavigation, LinkAnchor } from '../links'; import { ErrorMessage } from '../callout/types'; export interface UsePushToService { caseId: string; caseStatus: string; + configureCasesNavigation: CasesNavigation; connector: CaseConnector; caseServices: CaseServices; connectors: ActionConnector[]; @@ -40,6 +38,7 @@ export interface ReturnUsePushToService { } export const usePushToService = ({ + configureCasesNavigation: { onClick, href }, connector, caseId, caseServices, @@ -49,8 +48,6 @@ export const usePushToService = ({ userCanCrud, isValidConnector, }: UsePushToService): ReturnUsePushToService => { - const history = useHistory(); - const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case); const { isLoading, pushCaseToExternalService } = usePostPushToService(); const { isLoading: loadingLicense, actionLicense } = useGetActionLicense(); @@ -68,14 +65,6 @@ export const usePushToService = ({ } }, [caseId, connector, pushCaseToExternalService, updateCase]); - const goToConfigureCases = useCallback( - (ev) => { - ev.preventDefault(); - history.push(getConfigureCasesUrl(urlSearch)); - }, - [history, urlSearch] - ); - const errorsMsg = useMemo(() => { let errors: ErrorMessage[] = []; if (actionLicense != null && !actionLicense.enabledInLicense) { @@ -90,14 +79,10 @@ export const usePushToService = ({ description: ( <FormattedMessage defaultMessage="To open and update cases in external systems, you must configure a {link}." - id="xpack.securitySolution.cases.caseView.pushToServiceDisableByNoConnectors" + id="xpack.cases.caseView.pushToServiceDisableByNoConnectors" values={{ link: ( - <LinkAnchor - onClick={goToConfigureCases} - href={formatUrl(getConfigureCasesUrl())} - target="_blank" - > + <LinkAnchor onClick={onClick} href={href} target="_blank"> {i18n.LINK_CONNECTOR_CONFIGURE} </LinkAnchor> ), @@ -115,7 +100,7 @@ export const usePushToService = ({ description: ( <FormattedMessage defaultMessage="To open and update cases in external systems, you must select an external incident management system for this case." - id="xpack.securitySolution.cases.caseView.pushToServiceDisableByNoCaseConfigDescription" + id="xpack.cases.caseView.pushToServiceDisableByNoCaseConfigDescription" /> ), }, @@ -129,7 +114,7 @@ export const usePushToService = ({ description: ( <FormattedMessage defaultMessage="The connector used to send updates to external service has been deleted. To update cases in external systems, select a different connector or create a new one." - id="xpack.securitySolution.cases.caseView.pushToServiceDisableByInvalidConnector" + id="xpack.cases.caseView.pushToServiceDisableByInvalidConnector" /> ), errorType: 'danger', @@ -145,7 +130,7 @@ export const usePushToService = ({ description: ( <FormattedMessage defaultMessage="Closed cases cannot be sent to external systems. Reopen the case if you want to open or update it in an external system." - id="xpack.securitySolution.cases.caseView.pushToServiceDisableBecauseCaseClosedDescription" + id="xpack.cases.caseView.pushToServiceDisableBecauseCaseClosedDescription" /> ), }, @@ -156,7 +141,7 @@ export const usePushToService = ({ } return errors; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actionLicense, caseStatus, connectors.length, connector, loadingLicense, urlSearch]); + }, [actionLicense, caseStatus, connectors.length, connector, loadingLicense]); const pushToServiceButton = useMemo(() => { return ( diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts b/x-pack/plugins/cases/public/components/use_push_to_service/translations.ts similarity index 57% rename from x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts rename to x-pack/plugins/cases/public/components/use_push_to_service/translations.ts index 28a7312328b78..fd6faa634e053 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts +++ b/x-pack/plugins/cases/public/components/use_push_to_service/translations.ts @@ -8,19 +8,19 @@ import { i18n } from '@kbn/i18n'; export const ERROR_PUSH_SERVICE_CALLOUT_TITLE = i18n.translate( - 'xpack.securitySolution.cases.caseView.errorsPushServiceCallOutTitle', + 'xpack.cases.caseView.errorsPushServiceCallOutTitle', { defaultMessage: 'To send cases to external systems, you need to:', } ); export const PUSH_THIRD = (thirdParty: string) => { if (thirdParty === 'none') { - return i18n.translate('xpack.securitySolution.cases.caseView.pushThirdPartyIncident', { + return i18n.translate('xpack.cases.caseView.pushThirdPartyIncident', { defaultMessage: 'Push as external incident', }); } - return i18n.translate('xpack.securitySolution.cases.caseView.pushNamedIncident', { + return i18n.translate('xpack.cases.caseView.pushNamedIncident', { values: { thirdParty }, defaultMessage: 'Push as { thirdParty } incident', }); @@ -28,68 +28,62 @@ export const PUSH_THIRD = (thirdParty: string) => { export const UPDATE_THIRD = (thirdParty: string) => { if (thirdParty === 'none') { - return i18n.translate('xpack.securitySolution.cases.caseView.updateThirdPartyIncident', { + return i18n.translate('xpack.cases.caseView.updateThirdPartyIncident', { defaultMessage: 'Update external incident', }); } - return i18n.translate('xpack.securitySolution.cases.caseView.updateNamedIncident', { + return i18n.translate('xpack.cases.caseView.updateNamedIncident', { values: { thirdParty }, defaultMessage: 'Update { thirdParty } incident', }); }; export const PUSH_DISABLE_BY_NO_CONFIG_TITLE = i18n.translate( - 'xpack.securitySolution.cases.caseView.pushToServiceDisableByNoConfigTitle', + 'xpack.cases.caseView.pushToServiceDisableByNoConfigTitle', { defaultMessage: 'Configure external connector', } ); export const PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE = i18n.translate( - 'xpack.securitySolution.cases.caseView.pushToServiceDisableByNoCaseConfigTitle', + 'xpack.cases.caseView.pushToServiceDisableByNoCaseConfigTitle', { defaultMessage: 'Select external connector', } ); export const PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE = i18n.translate( - 'xpack.securitySolution.cases.caseView.pushToServiceDisableBecauseCaseClosedTitle', + 'xpack.cases.caseView.pushToServiceDisableBecauseCaseClosedTitle', { defaultMessage: 'Reopen the case', } ); export const PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE = i18n.translate( - 'xpack.securitySolution.cases.caseView.pushToServiceDisableByConfigTitle', + 'xpack.cases.caseView.pushToServiceDisableByConfigTitle', { defaultMessage: 'Enable external service in Kibana configuration file', } ); export const PUSH_DISABLE_BY_LICENSE_TITLE = i18n.translate( - 'xpack.securitySolution.cases.caseView.pushToServiceDisableByLicenseTitle', + 'xpack.cases.caseView.pushToServiceDisableByLicenseTitle', { defaultMessage: 'Upgrade to an appropriate license', } ); -export const LINK_CLOUD_DEPLOYMENT = i18n.translate( - 'xpack.securitySolution.cases.caseView.cloudDeploymentLink', - { - defaultMessage: 'cloud deployment', - } -); +export const LINK_CLOUD_DEPLOYMENT = i18n.translate('xpack.cases.caseView.cloudDeploymentLink', { + defaultMessage: 'cloud deployment', +}); -export const LINK_APPROPRIATE_LICENSE = i18n.translate( - 'xpack.securitySolution.cases.caseView.appropiateLicense', - { - defaultMessage: 'appropriate license', - } -); +export const LINK_APPROPRIATE_LICENSE = i18n.translate('xpack.cases.caseView.appropiateLicense', { + defaultMessage: 'appropriate license', +}); export const LINK_CONNECTOR_CONFIGURE = i18n.translate( - 'xpack.securitySolution.cases.caseView.connectorConfigureLink', + 'xpack.cases.caseView.connectorConfigureLink', { defaultMessage: 'connector', } diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx similarity index 82% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx index a62c6c0ef682d..b49a010cff38f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx @@ -8,9 +8,14 @@ import React from 'react'; import { mount } from 'enzyme'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../common'; import { basicPush, getUserAction } from '../../containers/mock'; -import { getLabelTitle, getPushedServiceLabelTitle, getConnectorLabelTitle } from './helpers'; +import { + getLabelTitle, + getPushedServiceLabelTitle, + getConnectorLabelTitle, + toStringArray, +} from './helpers'; import { connectorsMock } from '../../containers/configure/mock'; import * as i18n from './translations'; @@ -182,4 +187,38 @@ describe('User action tree helpers', () => { expect(result).toEqual('changed connector field'); }); + + describe('toStringArray', () => { + const circularReference = { otherData: 123, circularReference: undefined }; + // @ts-ignore testing catch on circular reference + circularReference.circularReference = circularReference; + it('handles all data types in an array', () => { + const value = [1, true, { a: 1 }, circularReference, 'yeah', 100n, null]; + const res = toStringArray(value); + expect(res).toEqual(['1', 'true', '{"a":1}', 'Invalid Object', 'yeah', '100']); + }); + it('handles null', () => { + const value = null; + const res = toStringArray(value); + expect(res).toEqual([]); + }); + + it('handles object', () => { + const value = { a: true }; + const res = toStringArray(value); + expect(res).toEqual([JSON.stringify(value)]); + }); + + it('handles Invalid Object', () => { + const value = circularReference; + const res = toStringArray(value); + expect(res).toEqual(['Invalid Object']); + }); + + it('handles unexpected value', () => { + const value = 100n; + const res = toStringArray(value); + expect(res).toEqual(['100']); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx similarity index 72% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx index cc8d560f91b1f..024fa4d494908 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx @@ -6,16 +6,14 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiCommentProps } from '@elastic/eui'; -import { isObject, get, isString, isNumber, isEmpty } from 'lodash'; -import React, { useMemo } from 'react'; +import React from 'react'; -import { SearchResponse } from 'elasticsearch'; import { CaseFullExternalService, ActionConnector, CaseStatuses, CommentType, -} from '../../../../../cases/common/api'; +} from '../../../common'; import { CaseUserActions } from '../../containers/types'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { parseString } from '../../containers/utils'; @@ -28,15 +26,6 @@ import { Status, statuses } from '../status'; import { UserActionShowAlert } from './user_action_show_alert'; import * as i18n from './translations'; import { AlertCommentEvent } from './user_action_alert_comment_event'; -import { InvestigateInTimelineAction } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_timeline_action'; -import { Ecs } from '../../../../common/ecs'; -import { TimelineNonEcsData } from '../../../../common/search_strategy'; -import { useSourcererScope } from '../../../common/containers/sourcerer'; -import { SourcererScopeName } from '../../../common/store/sourcerer/model'; -import { buildAlertsQuery } from '../case_view/helpers'; -import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query'; -import { KibanaServices } from '../../../common/lib/kibana'; -import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../common/constants'; interface LabelTitle { action: CaseUserActions; @@ -173,10 +162,12 @@ const getUpdateActionIcon = (actionField: string): string => { export const getUpdateAction = ({ action, + getCaseDetailHrefWithCommentId, label, handleOutlineComment, }: { action: CaseUserActions; + getCaseDetailHrefWithCommentId: (commentId: string) => string; label: string | JSX.Element; handleOutlineComment: (id: string) => void; }): EuiCommentProps => ({ @@ -194,7 +185,10 @@ export const getUpdateAction = ({ actions: ( <EuiFlexGroup> <EuiFlexItem> - <UserActionCopyLink id={action.actionId} /> + <UserActionCopyLink + getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId} + id={action.actionId} + /> </EuiFlexItem> {action.action === 'update' && action.commentId != null && ( <EuiFlexItem> @@ -208,6 +202,9 @@ export const getUpdateAction = ({ export const getAlertAttachment = ({ action, alertId, + getCaseDetailHrefWithCommentId, + getRuleDetailsHref, + onRuleDetailsClick, index, loadingAlertData, ruleId, @@ -215,6 +212,9 @@ export const getAlertAttachment = ({ onShowAlertDetails, }: { action: CaseUserActions; + getCaseDetailHrefWithCommentId: (commentId: string) => string; + getRuleDetailsHref: (ruleId: string | null | undefined) => string; + onRuleDetailsClick?: (ruleId: string | null | undefined) => void; onShowAlertDetails: (alertId: string, index: string) => void; alertId: string; index: string; @@ -234,7 +234,9 @@ export const getAlertAttachment = ({ event: ( <AlertCommentEvent alertId={alertId} + getRuleDetailsHref={getRuleDetailsHref} loadingAlertData={loadingAlertData} + onRuleDetailsClick={onRuleDetailsClick} ruleId={ruleId} ruleName={ruleName} commentType={CommentType.alert} @@ -246,7 +248,10 @@ export const getAlertAttachment = ({ actions: ( <EuiFlexGroup> <EuiFlexItem> - <UserActionCopyLink id={action.actionId} /> + <UserActionCopyLink + id={action.actionId} + getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId} + /> </EuiFlexItem> <EuiFlexItem> <UserActionShowAlert @@ -285,7 +290,7 @@ export const toStringArray = (value: unknown): string[] => { }, []); } else if (value == null) { return []; - } else if (!Array.isArray(value) && typeof value === 'object') { + } else if (typeof value === 'object') { try { return [JSON.stringify(value)]; } catch { @@ -296,54 +301,25 @@ export const toStringArray = (value: unknown): string[] => { } }; -export const formatAlertToEcsSignal = (alert: {}): Ecs => - Object.keys(alert).reduce<Ecs>((accumulator, key) => { - const item = get(alert, key); - if (item != null && isObject(item)) { - return { ...accumulator, [key]: formatAlertToEcsSignal(item) }; - } else if (Array.isArray(item) || isString(item) || isNumber(item)) { - return { ...accumulator, [key]: toStringArray(item) }; - } - return accumulator; - }, {} as Ecs); - -const EMPTY_ARRAY: TimelineNonEcsData[] = []; export const getGeneratedAlertsAttachment = ({ action, alertIds, + getCaseDetailHrefWithCommentId, + getRuleDetailsHref, + onRuleDetailsClick, + renderInvestigateInTimelineActionComponent, ruleId, ruleName, }: { action: CaseUserActions; alertIds: string[]; + getCaseDetailHrefWithCommentId: (commentId: string) => string; + getRuleDetailsHref: (ruleId: string | null | undefined) => string; + onRuleDetailsClick?: (ruleId: string | null | undefined) => void; + renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element; ruleId: string; ruleName: string; }): EuiCommentProps => { - const fetchEcsAlertsData = async (fetchAlertIds?: string[]): Promise<Ecs[]> => { - if (isEmpty(fetchAlertIds)) { - return []; - } - const alertResponse = await KibanaServices.get().http.fetch< - SearchResponse<{ '@timestamp': string; [key: string]: unknown }> - >(DETECTION_ENGINE_QUERY_SIGNALS_URL, { - method: 'POST', - body: JSON.stringify(buildAlertsQuery(fetchAlertIds ?? [])), - }); - return ( - alertResponse?.hits.hits.reduce<Ecs[]>( - (acc, { _id, _index, _source }) => [ - ...acc, - { - ...formatAlertToEcsSignal(_source as {}), - _id, - _index, - timestamp: _source['@timestamp'], - }, - ], - [] - ) ?? [] - ); - }; return { username: <EuiIcon type="logoSecurity" size="m" />, className: 'comment-alert', @@ -351,6 +327,8 @@ export const getGeneratedAlertsAttachment = ({ event: ( <AlertCommentEvent alertId={alertIds[0]} + getRuleDetailsHref={getRuleDetailsHref} + onRuleDetailsClick={onRuleDetailsClick} ruleId={ruleId} ruleName={ruleName} alertsCount={alertIds.length} @@ -363,18 +341,14 @@ export const getGeneratedAlertsAttachment = ({ actions: ( <EuiFlexGroup> <EuiFlexItem> - <UserActionCopyLink id={action.actionId} /> - </EuiFlexItem> - <EuiFlexItem> - <InvestigateInTimelineAction - ariaLabel={i18n.SEND_ALERT_TO_TIMELINE} - alertIds={alertIds} - key="investigate-in-timeline" - ecsRowData={null} - fetchEcsAlertsData={fetchEcsAlertsData} - nonEcsRowData={EMPTY_ARRAY} + <UserActionCopyLink + getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId} + id={action.actionId} /> </EuiFlexItem> + {renderInvestigateInTimelineActionComponent ? ( + <EuiFlexItem>{renderInvestigateInTimelineActionComponent(alertIds)}</EuiFlexItem> + ) : null} </EuiFlexGroup> ), }; @@ -389,15 +363,6 @@ interface Signal { }; } -interface SignalHit { - _id: string; - _index: string; - _source: { - '@timestamp': string; - signal: Signal; - }; -} - export interface Alert { _id: string; _index: string; @@ -405,32 +370,3 @@ export interface Alert { signal: Signal; [key: string]: unknown; } - -export const useFetchAlertData = (alertIds: string[]): [boolean, Record<string, Ecs>] => { - const { selectedPatterns } = useSourcererScope(SourcererScopeName.detections); - const alertsQuery = useMemo(() => buildAlertsQuery(alertIds), [alertIds]); - - const { loading: isLoadingAlerts, data: alertsData } = useQueryAlerts<SignalHit, unknown>( - alertsQuery, - selectedPatterns[0] - ); - - const alerts = useMemo( - () => - alertsData?.hits.hits.reduce<Record<string, Ecs>>( - (acc, { _id, _index, _source }) => ({ - ...acc, - [_id]: { - ...formatAlertToEcsSignal(_source), - _id, - _index, - timestamp: _source['@timestamp'], - }, - }), - {} - ) ?? {}, - [alertsData?.hits.hits] - ); - - return [isLoadingAlerts, alerts]; -}; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/index.test.tsx similarity index 77% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/index.test.tsx index a5c6b2d50f4a2..b30726bf23b25 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/index.test.tsx @@ -14,7 +14,8 @@ import { getFormMock, useFormMock, useFormDataMock } from '../__mock__/form'; import { useUpdateComment } from '../../containers/use_update_comment'; import { basicCase, basicPush, getUserAction } from '../../containers/mock'; import { UserActionTree } from '.'; -import { TestProviders } from '../../../common/mock'; +import { TestProviders } from '../../common/mock'; +import { Ecs } from '../../../common'; const fetchUserActions = jest.fn(); const onUpdateField = jest.fn(); @@ -25,13 +26,21 @@ const defaultProps = { caseServices: {}, caseUserActions: [], connectors: [], + getCaseDetailHrefWithCommentId: jest.fn(), + getRuleDetailsHref: jest.fn(), + onRuleDetailsClick: jest.fn(), data: basicCase, fetchUserActions, isLoadingDescription: false, isLoadingUserActions: false, onUpdateField, + selectedAlertPatterns: ['some-test-pattern'], updateCase, userCanCrud: true, + useFetchAlertData: (): [boolean, Record<string, Ecs>] => [ + false, + { 'some-id': { _id: 'some-id' } }, + ], alerts: {}, onShowAlertDetails, }; @@ -40,14 +49,13 @@ jest.mock('../../containers/use_update_comment'); jest.mock('./user_action_timestamp'); const patchComment = jest.fn(); -// FLAKY: https://github.com/elastic/kibana/issues/96362 -describe.skip('UserActionTree ', () => { + +describe(`UserActionTree`, () => { const sampleData = { content: 'what a great comment update', }; beforeEach(() => { jest.clearAllMocks(); - jest.resetAllMocks(); useUpdateCommentMock.mockImplementation(() => ({ isLoadingIds: [], patchComment, @@ -69,7 +77,7 @@ describe.skip('UserActionTree ', () => { </Router> </TestProviders> ); - expect(wrapper.find(`[data-test-subj="user-actions-loading"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="user-actions-loading"]`).exists()).toEqual(true); expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().prop('name')).toEqual( defaultProps.data.createdBy.fullName @@ -106,10 +114,8 @@ describe.skip('UserActionTree ', () => { </Router> </TestProviders> ); - await waitFor(() => { - expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toBeTruthy(); - }); + expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toEqual(true); + expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toEqual(true); }); it('Renders service now update line with top only when push is up to date', async () => { @@ -135,12 +141,9 @@ describe.skip('UserActionTree ', () => { </Router> </TestProviders> ); - await waitFor(() => { - expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toBeFalsy(); - }); + expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toEqual(true); + expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toEqual(false); }); - it('Outlines comment when update move to link is clicked', async () => { const ourActions = [getUserAction(['comment'], 'create'), getUserAction(['comment'], 'update')]; const props = { @@ -155,32 +158,29 @@ describe.skip('UserActionTree ', () => { </Router> </TestProviders> ); - - await waitFor(() => { - expect( - wrapper - .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`) - .first() - .hasClass('outlined') - ).toBeFalsy(); - + expect( wrapper - .find( - `[data-test-subj="comment-update-action-${ourActions[1].actionId}"] [data-test-subj="move-to-link-${props.data.comments[0].id}"]` - ) + .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`) .first() - .simulate('click'); + .hasClass('outlined') + ).toEqual(false); - wrapper.update(); + wrapper + .find( + `[data-test-subj="comment-update-action-${ourActions[1].actionId}"] [data-test-subj="move-to-link-${props.data.comments[0].id}"]` + ) + .first() + .simulate('click'); + + await waitFor(() => { expect( wrapper .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`) .first() .hasClass('outlined') - ).toBeTruthy(); + ).toEqual(true); }); }); - it('Switches to markdown when edit is clicked and back to panel when canceled', async () => { const ourActions = [getUserAction(['comment'], 'create')]; const props = { @@ -196,46 +196,27 @@ describe.skip('UserActionTree ', () => { </TestProviders> ); - await waitFor(() => { - expect( - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` - ) - .exists() - ).toEqual(false); - - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]` - ) - .first() - .simulate('click'); - - wrapper.update(); - - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]` - ) - .first() - .simulate('click'); - - expect( - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` - ) - .exists() - ).toEqual(true); + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]` + ) + .first() + .simulate('click'); + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]` + ) + .first() + .simulate('click'); - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]` - ) - .first() - .simulate('click'); + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]` + ) + .first() + .simulate('click'); + await waitFor(() => { expect( wrapper .find( @@ -304,11 +285,10 @@ describe.skip('UserActionTree ', () => { }); it('calls update description when description markdown is saved', async () => { - const props = defaultProps; const wrapper = mount( <TestProviders> <Router history={mockHistory}> - <UserActionTree {...props} /> + <UserActionTree {...defaultProps} /> </Router> </TestProviders> ); @@ -327,9 +307,9 @@ describe.skip('UserActionTree ', () => { .find(`[data-test-subj="description-action"] [data-test-subj="user-action-save-markdown"]`) .first() .simulate('click'); + await waitFor(() => { wrapper.update(); - expect( wrapper .find( @@ -337,7 +317,6 @@ describe.skip('UserActionTree ', () => { ) .exists() ).toEqual(false); - expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); }); }); @@ -365,16 +344,13 @@ describe.skip('UserActionTree ', () => { .first() .simulate('click'); + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`) + .first() + .simulate('click'); await waitFor(() => { - wrapper.update(); - - wrapper - .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`) - .first() - .simulate('click'); + expect(setFieldValue).toBeCalledWith('comment', `> ${props.data.description} \n`); }); - - expect(setFieldValue).toBeCalledWith('comment', `> ${props.data.description} \n`); }); it('Outlines comment when url param is provided', async () => { @@ -395,14 +371,11 @@ describe.skip('UserActionTree ', () => { </TestProviders> ); - await waitFor(() => { - wrapper.update(); - expect( - wrapper - .find(`[data-test-subj="comment-create-action-${commentId}"]`) - .first() - .hasClass('outlined') - ).toBeTruthy(); - }); + expect( + wrapper + .find(`[data-test-subj="comment-create-action-${commentId}"]`) + .first() + .hasClass('outlined') + ).toEqual(true); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx similarity index 89% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/index.tsx index f8d6872a4b740..09b024fb2ca3d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx @@ -23,14 +23,14 @@ import * as i18n from './translations'; import { Case, CaseUserActions } from '../../containers/types'; import { useUpdateComment } from '../../containers/use_update_comment'; -import { useCurrentUser } from '../../../common/lib/kibana'; +import { useCurrentUser } from '../../common/lib/kibana'; import { AddComment, AddCommentRefObject } from '../add_comment'; import { ActionConnector, AlertCommentRequestRt, CommentType, ContextTypeUserRt, -} from '../../../../../cases/common/api'; +} from '../../../common'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { parseString } from '../../containers/utils'; import { OnUpdateFields } from '../case_view'; @@ -42,7 +42,6 @@ import { getUpdateAction, getAlertAttachment, getGeneratedAlertsAttachment, - useFetchAlertData, } from './helpers'; import { UserActionAvatar } from './user_action_avatar'; import { UserActionMarkdown } from './user_action_markdown'; @@ -50,17 +49,22 @@ import { UserActionTimestamp } from './user_action_timestamp'; import { UserActionUsername } from './user_action_username'; import { UserActionContentToolbar } from './user_action_content_toolbar'; import { getManualAlertIdsWithNoRuleId } from '../case_view/helpers'; - +import { Ecs } from '../../../common'; export interface UserActionTreeProps { + getCaseDetailHrefWithCommentId: (commentId: string) => string; caseServices: CaseServices; caseUserActions: CaseUserActions[]; connectors: ActionConnector[]; data: Case; + getRuleDetailsHref: (ruleId: string | null | undefined) => string; fetchUserActions: () => void; isLoadingDescription: boolean; isLoadingUserActions: boolean; + onRuleDetailsClick?: (ruleId: string | null | undefined) => void; onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void; + renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element; updateCase: (newCase: Case) => void; + useFetchAlertData: (alertIds: string[]) => [boolean, Record<string, Ecs>]; userCanCrud: boolean; onShowAlertDetails: (alertId: string, index: string) => void; } @@ -111,14 +115,19 @@ const NEW_ID = 'newComment'; export const UserActionTree = React.memo( ({ data: caseData, + getCaseDetailHrefWithCommentId, caseServices, caseUserActions, connectors, + getRuleDetailsHref, fetchUserActions, isLoadingDescription, isLoadingUserActions, + onRuleDetailsClick, onUpdateField, + renderInvestigateInTimelineActionComponent, updateCase, + useFetchAlertData, userCanCrud, onShowAlertDetails, }: UserActionTreeProps) => { @@ -272,6 +281,7 @@ export const UserActionTree = React.memo( }), actions: ( <UserActionContentToolbar + getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId} id={DESCRIPTION_ID} editLabel={i18n.EDIT_DESCRIPTION} quoteLabel={i18n.QUOTE} @@ -285,6 +295,7 @@ export const UserActionTree = React.memo( [ MarkdownDescription, caseData, + getCaseDetailHrefWithCommentId, handleManageMarkdownEditId, handleManageQuote, isLoadingDescription, @@ -296,7 +307,6 @@ export const UserActionTree = React.memo( const userActions: EuiCommentProps[] = useMemo( () => caseUserActions.reduce<EuiCommentProps[]>( - // eslint-disable-next-line complexity (comments, action, index) => { // Comment creation if (action.commentId != null && action.action === 'create') { @@ -346,6 +356,7 @@ export const UserActionTree = React.memo( ), actions: ( <UserActionContentToolbar + getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId} id={comment.id} editLabel={i18n.EDIT_COMMENT} quoteLabel={i18n.QUOTE} @@ -389,8 +400,11 @@ export const UserActionTree = React.memo( getAlertAttachment({ action, alertId, + getCaseDetailHrefWithCommentId, + getRuleDetailsHref, index: alertIndex, loadingAlertData, + onRuleDetailsClick, ruleId, ruleName, onShowAlertDetails, @@ -411,6 +425,10 @@ export const UserActionTree = React.memo( getGeneratedAlertsAttachment({ action, alertIds, + getCaseDetailHrefWithCommentId, + getRuleDetailsHref, + onRuleDetailsClick, + renderInvestigateInTimelineActionComponent, ruleId: comment.rule?.id ?? '', ruleName: comment.rule?.name ?? i18n.UNKNOWN_RULE, }), @@ -421,7 +439,15 @@ export const UserActionTree = React.memo( // Connectors if (action.actionField.length === 1 && action.actionField[0] === 'connector') { const label = getConnectorLabelTitle({ action, connectors }); - return [...comments, getUpdateAction({ action, label, handleOutlineComment })]; + return [ + ...comments, + getUpdateAction({ + action, + label, + getCaseDetailHrefWithCommentId, + handleOutlineComment, + }), + ]; } // Pushed information @@ -474,7 +500,12 @@ export const UserActionTree = React.memo( return [ ...comments, - getUpdateAction({ action, label, handleOutlineComment }), + getUpdateAction({ + action, + label, + getCaseDetailHrefWithCommentId, + handleOutlineComment, + }), ...footers, ]; } @@ -490,7 +521,15 @@ export const UserActionTree = React.memo( field: myField, }); - return [...comments, getUpdateAction({ action, label, handleOutlineComment })]; + return [ + ...comments, + getUpdateAction({ + action, + label, + getCaseDetailHrefWithCommentId, + handleOutlineComment, + }), + ]; } return comments; @@ -498,22 +537,26 @@ export const UserActionTree = React.memo( [descriptionCommentListObj] ), [ - caseData, - caseServices, caseUserActions, - connectors, - handleOutlineComment, descriptionCommentListObj, + caseData.comments, + selectedOutlineCommentId, + manageMarkdownEditIds, handleManageMarkdownEditId, - handleManageQuote, handleSaveComment, + getCaseDetailHrefWithCommentId, + userCanCrud, isLoadingIds, - loadingAlertData, + handleManageQuote, manualAlertsData, - manageMarkdownEditIds, - selectedOutlineCommentId, - userCanCrud, + getRuleDetailsHref, + loadingAlertData, + onRuleDetailsClick, onShowAlertDetails, + renderInvestigateInTimelineActionComponent, + connectors, + handleOutlineComment, + caseServices, ] ); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/schema.ts b/x-pack/plugins/cases/public/components/user_action_tree/schema.ts similarity index 88% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/schema.ts rename to x-pack/plugins/cases/public/components/user_action_tree/schema.ts index c96041219a3e7..8c455818bf910 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/schema.ts +++ b/x-pack/plugins/cases/public/components/user_action_tree/schema.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../shared_imports'; -import * as i18n from '../../translations'; +import { FIELD_TYPES, fieldValidators, FormSchema } from '../../common/shared_imports'; +import * as i18n from '../../common/translations'; const { emptyField } = fieldValidators; export interface Content { diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/translations.ts b/x-pack/plugins/cases/public/components/user_action_tree/translations.ts similarity index 52% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/translations.ts rename to x-pack/plugins/cases/public/components/user_action_tree/translations.ts index 8218712fb359f..256e7ad66eeb6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/translations.ts +++ b/x-pack/plugins/cases/public/components/user_action_tree/translations.ts @@ -10,75 +10,63 @@ import { i18n } from '@kbn/i18n'; export * from '../case_view/translations'; export const ALREADY_PUSHED_TO_SERVICE = (externalService: string) => - i18n.translate('xpack.securitySolution.cases.caseView.alreadyPushedToExternalService', { + i18n.translate('xpack.cases.caseView.alreadyPushedToExternalService', { values: { externalService }, defaultMessage: 'Already pushed to { externalService } incident', }); export const REQUIRED_UPDATE_TO_SERVICE = (externalService: string) => - i18n.translate('xpack.securitySolution.cases.caseView.requiredUpdateToExternalService', { + i18n.translate('xpack.cases.caseView.requiredUpdateToExternalService', { values: { externalService }, defaultMessage: 'Requires update to { externalService } incident', }); -export const COPY_REFERENCE_LINK = i18n.translate( - 'xpack.securitySolution.cases.caseView.copyCommentLinkAria', - { - defaultMessage: 'Copy reference link', - } -); +export const COPY_REFERENCE_LINK = i18n.translate('xpack.cases.caseView.copyCommentLinkAria', { + defaultMessage: 'Copy reference link', +}); -export const MOVE_TO_ORIGINAL_COMMENT = i18n.translate( - 'xpack.securitySolution.cases.caseView.moveToCommentAria', - { - defaultMessage: 'Highlight the referenced comment', - } -); +export const MOVE_TO_ORIGINAL_COMMENT = i18n.translate('xpack.cases.caseView.moveToCommentAria', { + defaultMessage: 'Highlight the referenced comment', +}); export const ALERT_COMMENT_LABEL_TITLE = i18n.translate( - 'xpack.securitySolution.cases.caseView.alertCommentLabelTitle', + 'xpack.cases.caseView.alertCommentLabelTitle', { defaultMessage: 'added an alert from', } ); export const GENERATED_ALERT_COMMENT_LABEL_TITLE = i18n.translate( - 'xpack.securitySolution.cases.caseView.generatedAlertCommentLabelTitle', + 'xpack.cases.caseView.generatedAlertCommentLabelTitle', { defaultMessage: 'were added from', } ); export const GENERATED_ALERT_COUNT_COMMENT_LABEL_TITLE = (totalCount: number) => - i18n.translate('xpack.securitySolution.cases.caseView.generatedAlertCountCommentLabelTitle', { + i18n.translate('xpack.cases.caseView.generatedAlertCountCommentLabelTitle', { values: { totalCount }, defaultMessage: `{totalCount} {totalCount, plural, =1 {alert} other {alerts}}`, }); export const ALERT_RULE_DELETED_COMMENT_LABEL = i18n.translate( - 'xpack.securitySolution.cases.caseView.alertRuleDeletedLabelTitle', + 'xpack.cases.caseView.alertRuleDeletedLabelTitle', { defaultMessage: 'added an alert', } ); -export const SHOW_ALERT_TOOLTIP = i18n.translate( - 'xpack.securitySolution.cases.caseView.showAlertTooltip', - { - defaultMessage: 'Show alert details', - } -); +export const SHOW_ALERT_TOOLTIP = i18n.translate('xpack.cases.caseView.showAlertTooltip', { + defaultMessage: 'Show alert details', +}); export const SEND_ALERT_TO_TIMELINE = i18n.translate( - 'xpack.securitySolution.cases.caseView.sendAlertToTimelineTooltip', + 'xpack.cases.caseView.sendAlertToTimelineTooltip', { defaultMessage: 'Investigate in timeline', } ); -export const UNKNOWN_RULE = i18n.translate( - 'xpack.securitySolution.cases.caseView.unknownRule.label', - { - defaultMessage: 'Unknown rule', - } -); +export const UNKNOWN_RULE = i18n.translate('xpack.cases.caseView.unknownRule.label', { + defaultMessage: 'Unknown rule', +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_alert_comment_event.test.tsx similarity index 76% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_alert_comment_event.test.tsx index 3bfdf2d2c5e62..a049deb264d4c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_alert_comment_event.test.tsx @@ -8,20 +8,23 @@ import React from 'react'; import { mount } from 'enzyme'; -import { TestProviders } from '../../../common/mock'; -import { useKibana } from '../../../common/lib/kibana'; +import { TestProviders } from '../../common/mock'; +import { useKibana } from '../../common/lib/kibana'; import { AlertCommentEvent } from './user_action_alert_comment_event'; -import { CommentType } from '../../../../../cases/common/api'; +import { CommentType } from '../../../common'; const props = { alertId: 'alert-id-1', + getCaseDetailHrefWithCommentId: jest.fn().mockReturnValue('someCaseDetail-withcomment'), + getRuleDetailsHref: jest.fn().mockReturnValue('some-detection-rule-link'), + onRuleDetailsClick: jest.fn(), ruleId: 'rule-id-1', ruleName: 'Awesome rule', alertsCount: 1, commentType: CommentType.alert, }; -jest.mock('../../../common/lib/kibana'); +jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; describe('UserActionAvatar ', () => { @@ -61,15 +64,15 @@ describe('UserActionAvatar ', () => { }); it('navigate to app on link click', async () => { + const onRuleDetailsClick = jest.fn(); + const wrapper = mount( <TestProviders> - <AlertCommentEvent {...props} /> + <AlertCommentEvent {...props} onRuleDetailsClick={onRuleDetailsClick} /> </TestProviders> ); wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().simulate('click'); - expect(navigateToApp).toHaveBeenCalledWith('securitySolution:detections', { - path: '/rules/id/rule-id-1', - }); + expect(onRuleDetailsClick).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_alert_comment_event.tsx similarity index 70% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_alert_comment_event.tsx index a72bebbaf0999..ee962f1407d74 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_alert_comment_event.tsx @@ -9,18 +9,15 @@ import React, { memo, useCallback } from 'react'; import { isEmpty } from 'lodash'; import { EuiText, EuiLoadingSpinner } from '@elastic/eui'; -import { APP_ID } from '../../../../common/constants'; -import { useKibana } from '../../../common/lib/kibana'; -import { getRuleDetailsUrl, useFormatUrl } from '../../../common/components/link_to'; -import { SecurityPageName } from '../../../app/types'; - import * as i18n from './translations'; -import { CommentType } from '../../../../../cases/common/api'; -import { LinkAnchor } from '../../../common/components/links'; +import { CommentType } from '../../../common'; +import { LinkAnchor } from '../links'; interface Props { alertId: string; commentType: CommentType; + getRuleDetailsHref: (ruleId: string | null | undefined) => string; + onRuleDetailsClick?: (ruleId: string | null | undefined) => void; ruleId?: string | null; ruleName?: string | null; alertsCount?: number; @@ -29,24 +26,22 @@ interface Props { const AlertCommentEventComponent: React.FC<Props> = ({ alertId, + getRuleDetailsHref, loadingAlertData = false, + onRuleDetailsClick, ruleId, ruleName, alertsCount, commentType, }) => { - const { navigateToApp } = useKibana().services.application; - const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.detections); - const onLinkClick = useCallback( (ev: { preventDefault: () => void }) => { ev.preventDefault(); - navigateToApp(`${APP_ID}:${SecurityPageName.detections}`, { - path: getRuleDetailsUrl(ruleId ?? ''), - }); + if (onRuleDetailsClick) onRuleDetailsClick(ruleId); }, - [ruleId, navigateToApp] + [ruleId, onRuleDetailsClick] ); + const detectionsRuleDetailsHref = getRuleDetailsHref(ruleId); return commentType !== CommentType.generatedAlert ? ( <> @@ -55,7 +50,7 @@ const AlertCommentEventComponent: React.FC<Props> = ({ {!loadingAlertData && !isEmpty(ruleId) && ( <LinkAnchor onClick={onLinkClick} - href={formatUrl(getRuleDetailsUrl(ruleId ?? '', urlSearch))} + href={detectionsRuleDetailsHref} data-test-subj={`alert-rule-link-${alertId ?? 'deleted'}`} > {ruleName ?? i18n.UNKNOWN_RULE} @@ -71,7 +66,7 @@ const AlertCommentEventComponent: React.FC<Props> = ({ {!loadingAlertData && ruleId !== '' && ( <LinkAnchor onClick={onLinkClick} - href={formatUrl(getRuleDetailsUrl(ruleId ?? '', urlSearch))} + href={detectionsRuleDetailsHref} data-test-subj={`alert-rule-link-${alertId ?? 'deleted'}`} > {ruleName} diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_avatar.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_avatar.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_avatar.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_avatar.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx similarity index 90% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx index 051a5c7fe975c..dc14011087a86 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx @@ -18,9 +18,7 @@ jest.mock('react-router-dom', () => { }; }); -jest.mock('../../../common/components/navigation/use_get_url_search'); - -jest.mock('../../../common/lib/kibana', () => { +jest.mock('../../common/lib/kibana', () => { return { useKibana: () => ({ services: { @@ -33,6 +31,7 @@ jest.mock('../../../common/lib/kibana', () => { }); const props = { + getCaseDetailHrefWithCommentId: jest.fn().mockReturnValue('case-detail-url-with-comment-id-1'), id: '1', editLabel: 'edit', quoteLabel: 'quote', diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx similarity index 85% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx index fd679ced5dd6d..f1f0a0148b9c6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx @@ -13,6 +13,7 @@ import { UserActionPropertyActions } from './user_action_property_actions'; interface UserActionContentToolbarProps { id: string; + getCaseDetailHrefWithCommentId: (commentId: string) => string; editLabel: string; quoteLabel: string; disabled: boolean; @@ -23,6 +24,7 @@ interface UserActionContentToolbarProps { const UserActionContentToolbarComponent = ({ id, + getCaseDetailHrefWithCommentId, editLabel, quoteLabel, disabled, @@ -33,7 +35,10 @@ const UserActionContentToolbarComponent = ({ return ( <EuiFlexGroup> <EuiFlexItem> - <UserActionCopyLink id={id} /> + <UserActionCopyLink + id={id} + getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId} + /> </EuiFlexItem> <EuiFlexItem> <UserActionPropertyActions diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_copy_link.test.tsx similarity index 65% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_copy_link.test.tsx index c1d4894854bd9..51381bee98978 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_copy_link.test.tsx @@ -5,17 +5,15 @@ * 2.0. */ +// TODO: removed dependencies on UrlGetSearch + import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { useParams } from 'react-router-dom'; import copy from 'copy-to-clipboard'; -import { TestProviders } from '../../../common/mock'; +import { TestProviders } from '../../common/mock'; import { UserActionCopyLink } from './user_action_copy_link'; -import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search'; - -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)))'; jest.mock('react-router-dom', () => { const originalModule = jest.requireActual('react-router-dom'); @@ -30,14 +28,12 @@ jest.mock('copy-to-clipboard', () => { return jest.fn(); }); -jest.mock('../../../common/components/navigation/use_get_url_search'); - const mockGetUrlForApp = jest.fn( (appId: string, options?: { path?: string; absolute?: boolean }) => `${appId}${options?.path ?? ''}` ); -jest.mock('../../../common/lib/kibana', () => { +jest.mock('../../common/lib/kibana', () => { return { useKibana: () => ({ services: { @@ -51,6 +47,7 @@ jest.mock('../../../common/lib/kibana', () => { const props = { id: 'comment-id', + getCaseDetailHrefWithCommentId: jest.fn().mockReturnValue('random-url'), }; describe('UserActionCopyLink ', () => { @@ -58,7 +55,6 @@ describe('UserActionCopyLink ', () => { beforeAll(() => { (useParams as jest.Mock).mockReturnValue({ detailName: 'case-1' }); - (useGetUrlSearch as jest.Mock).mockReturnValue(searchURL); wrapper = mount(<UserActionCopyLink {...props} />, { wrappingComponent: TestProviders }); }); @@ -68,8 +64,6 @@ describe('UserActionCopyLink ', () => { it('calls copy clipboard correctly', async () => { wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().simulate('click'); - expect(copy).toHaveBeenCalledWith( - 'securitySolution:case/case-1/comment-id?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))' - ); + expect(copy).toHaveBeenCalledWith('random-url'); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_copy_link.tsx similarity index 59% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_copy_link.tsx index ff4e151197464..0cc837fcb60b5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_copy_link.tsx @@ -7,28 +7,22 @@ import React, { memo, useCallback } from 'react'; import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; -import { useParams } from 'react-router-dom'; import copy from 'copy-to-clipboard'; -import { useFormatUrl, getCaseDetailsUrlWithCommentId } from '../../../common/components/link_to'; -import { SecurityPageName } from '../../../app/types'; import * as i18n from './translations'; interface UserActionCopyLinkProps { id: string; + getCaseDetailHrefWithCommentId: (commentId: string) => string; } -const UserActionCopyLinkComponent = ({ id: commentId }: UserActionCopyLinkProps) => { - const { detailName: caseId, subCaseId } = useParams<{ detailName: string; subCaseId?: string }>(); - const { formatUrl } = useFormatUrl(SecurityPageName.case); - +const UserActionCopyLinkComponent = ({ + id: commentId, + getCaseDetailHrefWithCommentId, +}: UserActionCopyLinkProps) => { const handleAnchorLink = useCallback(() => { - copy( - formatUrl(getCaseDetailsUrlWithCommentId({ id: caseId, commentId, subCaseId }), { - absolute: true, - }) - ); - }, [caseId, commentId, formatUrl, subCaseId]); + copy(getCaseDetailHrefWithCommentId(commentId)); + }, [getCaseDetailHrefWithCommentId, commentId]); return ( <EuiToolTip position="top" content={<p>{i18n.COPY_REFERENCE_LINK}</p>}> diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.test.tsx new file mode 100644 index 0000000000000..6fff3c8f9abe2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.test.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { Router, mockHistory } from '../__mock__/router'; +import { UserActionMarkdown } from './user_action_markdown'; +import { TestProviders } from '../../common/mock'; +import { waitFor } from '@testing-library/react'; +const onChangeEditable = jest.fn(); +const onSaveContent = jest.fn(); + +const hyperlink = `[hyperlink](http://elastic.co)`; +const defaultProps = { + content: `A link to a timeline ${hyperlink}`, + id: 'markdown-id', + isEditable: true, + onChangeEditable, + onSaveContent, +}; + +describe('UserActionMarkdown ', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Renders markdown correctly when not in edit mode', async () => { + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <UserActionMarkdown {...{ ...defaultProps, isEditable: false }} /> + </Router> + </TestProviders> + ); + + expect(wrapper.find(`[data-test-subj="markdown-link"]`).first().text()).toContain('hyperlink'); + }); + + it('Save button click calls onSaveContent and onChangeEditable', async () => { + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <UserActionMarkdown {...defaultProps} /> + </Router> + </TestProviders> + ); + wrapper.find(`[data-test-subj="user-action-save-markdown"]`).first().simulate('click'); + + await waitFor(() => { + expect(onSaveContent).toHaveBeenCalledWith(defaultProps.content); + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + }); + }); + it('Cancel button click calls only onChangeEditable', async () => { + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <UserActionMarkdown {...defaultProps} /> + </Router> + </TestProviders> + ); + wrapper.find(`[data-test-subj="user-action-cancel-markdown"]`).first().simulate('click'); + + await waitFor(() => { + expect(onSaveContent).not.toHaveBeenCalled(); + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx index c5707b0293d0e..19cc804786af1 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx @@ -10,9 +10,9 @@ import React, { useCallback } from 'react'; import styled from 'styled-components'; import * as i18n from '../case_view/translations'; -import { Form, useForm, UseField } from '../../../shared_imports'; +import { Form, useForm, UseField } from '../../common/shared_imports'; import { schema, Content } from './schema'; -import { MarkdownRenderer, MarkdownEditorForm } from '../../../common/components/markdown_editor'; +import { MarkdownRenderer, MarkdownEditorForm } from '../markdown_editor'; const ContentWrapper = styled.div` padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_move_to_reference.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_move_to_reference.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_move_to_reference.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_move_to_reference.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.test.tsx similarity index 69% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.test.tsx index 0e8a30befd000..57958d3d8e5af 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.test.tsx @@ -8,15 +8,16 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { UserActionPropertyActions } from './user_action_property_actions'; - +const onEdit = jest.fn(); +const onQuote = jest.fn(); const props = { id: 'property-actions-id', editLabel: 'edit', quoteLabel: 'quote', disabled: false, isLoading: false, - onEdit: jest.fn(), - onQuote: jest.fn(), + onEdit, + onQuote, }; describe('UserActionPropertyActions ', () => { @@ -26,6 +27,10 @@ describe('UserActionPropertyActions ', () => { wrapper = mount(<UserActionPropertyActions {...props} />); }); + beforeEach(() => { + jest.clearAllMocks(); + }); + it('it renders', async () => { expect( wrapper.find('[data-test-subj="user-action-title-loading"]').first().exists() @@ -40,6 +45,18 @@ describe('UserActionPropertyActions ', () => { wrapper.find('[data-test-subj="property-actions-quote"]').exists(); }); + it('quote click calls onQuote', async () => { + wrapper.find('[data-test-subj="property-actions-ellipses"]').first().simulate('click'); + wrapper.find('[data-test-subj="property-actions-quote"]').first().simulate('click'); + expect(onQuote).toHaveBeenCalledWith(props.id); + }); + + it('pencil click calls onEdit', async () => { + wrapper.find('[data-test-subj="property-actions-ellipses"]').first().simulate('click'); + wrapper.find('[data-test-subj="property-actions-pencil"]').first().simulate('click'); + expect(onEdit).toHaveBeenCalledWith(props.id); + }); + it('it shows the spinner when loading', async () => { wrapper = mount(<UserActionPropertyActions {...props} isLoading={true} />); expect( diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_show_alert.test.tsx similarity index 77% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.test.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_show_alert.test.tsx index 789a6eb68e0fc..d6005a8bd521e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.test.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_show_alert.test.tsx @@ -8,23 +8,11 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { UserActionShowAlert } from './user_action_show_alert'; -import { RuleEcs } from '../../../../common/ecs/rule'; const props = { id: 'action-id', alertId: 'alert-id', index: 'alert-index', - alert: { - _id: 'alert-id', - _index: 'alert-index', - timestamp: '2021-01-07T13:58:31.487Z', - rule: { - id: ['rule-id'], - name: ['Awesome Rule'], - from: ['2021-01-07T13:58:31.487Z'], - to: ['2021-01-07T14:58:31.487Z'], - } as RuleEcs, - }, onShowAlertDetails: jest.fn(), }; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_show_alert.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_show_alert.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_timestamp.test.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_timestamp.test.tsx index 6aa6710cb6ea1..de2dc90ac43e9 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_timestamp.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { TestProviders } from '../../../common/mock'; +import { TestProviders } from '../../common/mock'; import { UserActionTimestamp } from './user_action_timestamp'; jest.mock('@kbn/i18n/react', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_timestamp.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_timestamp.tsx index e51bc261ff800..2e3973458c249 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_timestamp.tsx @@ -9,7 +9,7 @@ import React, { memo } from 'react'; import { EuiTextColor } from '@elastic/eui'; import { FormattedRelative } from '@kbn/i18n/react'; -import { LocalizedDateTooltip } from '../../../common/components/localized_date_tooltip'; +import { LocalizedDateTooltip } from '../../components/localized_date_tooltip'; import * as i18n from './translations'; interface UserActionAvatarProps { diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_username.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_username.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_username.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_username.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_username_with_avatar.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_username_with_avatar.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_username_with_avatar.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx rename to x-pack/plugins/cases/public/components/user_action_tree/user_action_username_with_avatar.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/user_list/index.test.tsx b/x-pack/plugins/cases/public/components/user_list/index.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/cases/components/user_list/index.test.tsx rename to x-pack/plugins/cases/public/components/user_list/index.test.tsx index 9c6509eeabc15..70f9e7d2fbdfc 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_list/index.test.tsx +++ b/x-pack/plugins/cases/public/components/user_list/index.test.tsx @@ -15,11 +15,9 @@ describe('UserList ', () => { const caseLink = 'http://reddit.com'; const user = { username: 'username', fullName: 'Full Name', email: 'testemail@elastic.co' }; const open = jest.fn(); - beforeAll(() => { - window.open = open; - }); beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); + window.open = open; }); it('triggers mailto when email icon clicked', () => { const wrapper = shallow( diff --git a/x-pack/plugins/security_solution/public/cases/components/user_list/index.tsx b/x-pack/plugins/cases/public/components/user_list/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/user_list/index.tsx rename to x-pack/plugins/cases/public/components/user_list/index.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/user_list/translations.ts b/x-pack/plugins/cases/public/components/user_list/translations.ts similarity index 84% rename from x-pack/plugins/security_solution/public/cases/components/user_list/translations.ts rename to x-pack/plugins/cases/public/components/user_list/translations.ts index 81d2c7d50e5d7..73610e5959345 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_list/translations.ts +++ b/x-pack/plugins/cases/public/components/user_list/translations.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; export const SEND_EMAIL_ARIA = (user: string) => - i18n.translate('xpack.securitySolution.cases.caseView.sendEmalLinkAria', { + i18n.translate('xpack.cases.caseView.sendEmalLinkAria', { values: { user }, defaultMessage: 'click to send an email to {user}', }); diff --git a/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar.test.tsx.snap b/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar.test.tsx.snap new file mode 100644 index 0000000000000..f082dc4023e7a --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar.test.tsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UtilityBar it renders 1`] = ` +<UtilityBar> + <UtilityBarSection> + <UtilityBarGroup> + <UtilityBarText> + Test text + </UtilityBarText> + </UtilityBarGroup> + <UtilityBarGroup> + <UtilityBarAction + iconType="" + popoverContent={[Function]} + > + Test action + </UtilityBarAction> + </UtilityBarGroup> + </UtilityBarSection> + <UtilityBarSection> + <UtilityBarGroup> + <UtilityBarAction + iconType="cross" + > + Test action + </UtilityBarAction> + </UtilityBarGroup> + </UtilityBarSection> +</UtilityBar> +`; diff --git a/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_action.test.tsx.snap b/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_action.test.tsx.snap new file mode 100644 index 0000000000000..eb20ac217b300 --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_action.test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UtilityBarAction it renders 1`] = ` +<UtilityBarAction + iconType="alert" +> + Test action +</UtilityBarAction> +`; diff --git a/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_group.test.tsx.snap b/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_group.test.tsx.snap new file mode 100644 index 0000000000000..8ef7ee1cfe842 --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_group.test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UtilityBarGroup it renders 1`] = ` +<UtilityBarGroup> + <UtilityBarText> + Test text + </UtilityBarText> +</UtilityBarGroup> +`; diff --git a/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_section.test.tsx.snap b/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_section.test.tsx.snap new file mode 100644 index 0000000000000..2fe3b8ac5c7aa --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_section.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UtilityBarSection it renders 1`] = ` +<UtilityBarSection> + <UtilityBarGroup> + <UtilityBarText> + Test text + </UtilityBarText> + </UtilityBarGroup> +</UtilityBarSection> +`; diff --git a/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_text.test.tsx.snap b/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_text.test.tsx.snap new file mode 100644 index 0000000000000..cf635ffa49c4c --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_text.test.tsx.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UtilityBarText it renders 1`] = ` +<UtilityBarText> + Test text +</UtilityBarText> +`; diff --git a/x-pack/plugins/cases/public/components/utility_bar/index.ts b/x-pack/plugins/cases/public/components/utility_bar/index.ts new file mode 100644 index 0000000000000..830f3cb043ba9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/index.ts @@ -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 { UtilityBar } from './utility_bar'; +export { UtilityBarAction } from './utility_bar_action'; +export { UtilityBarGroup } from './utility_bar_group'; +export { UtilityBarSection } from './utility_bar_section'; +export { UtilityBarSpacer } from './utility_bar_spacer'; +export { UtilityBarText } from './utility_bar_text'; diff --git a/x-pack/plugins/cases/public/components/utility_bar/styles.tsx b/x-pack/plugins/cases/public/components/utility_bar/styles.tsx new file mode 100644 index 0000000000000..158f0c5ebea15 --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/styles.tsx @@ -0,0 +1,144 @@ +/* + * 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 styled, { css } from 'styled-components'; + +/** + * UTILITY BAR + */ + +export interface BarProps { + border?: boolean; +} + +export interface BarSectionProps { + grow?: boolean; +} + +export interface BarGroupProps { + grow?: boolean; +} + +export const Bar = styled.aside.attrs({ + className: 'casesUtilityBar', +})<BarProps>` + ${({ border, theme }) => css` + ${border && + css` + border-bottom: ${theme.eui.euiBorderThin}; + padding-bottom: ${theme.eui.paddingSizes.s}; + `} + + @media only screen and (min-width: ${theme.eui.euiBreakpoints.l}) { + display: flex; + justify-content: space-between; + } + `} +`; +Bar.displayName = 'Bar'; + +export const BarSection = styled.div.attrs({ + className: 'casesUtilityBar__section', +})<BarSectionProps>` + ${({ grow, theme }) => css` + & + & { + margin-top: ${theme.eui.euiSizeS}; + } + + @media only screen and (min-width: ${theme.eui.euiBreakpoints.m}) { + display: flex; + flex-wrap: wrap; + } + + @media only screen and (min-width: ${theme.eui.euiBreakpoints.l}) { + & + & { + margin-top: 0; + margin-left: ${theme.eui.euiSize}; + } + } + ${grow && + css` + flex: 1; + `} + `} +`; +BarSection.displayName = 'BarSection'; + +export const BarGroup = styled.div.attrs({ + className: 'casesUtilityBar__group', +})<BarGroupProps>` + ${({ grow, theme }) => css` + align-items: flex-start; + display: flex; + flex-wrap: wrap; + + & + & { + margin-top: ${theme.eui.euiSizeS}; + } + + @media only screen and (min-width: ${theme.eui.euiBreakpoints.m}) { + border-right: ${theme.eui.euiBorderThin}; + flex-wrap: nowrap; + margin-right: ${theme.eui.paddingSizes.m}; + padding-right: ${theme.eui.paddingSizes.m}; + + & + & { + margin-top: 0; + } + + &:last-child { + border-right: none; + margin-right: 0; + padding-right: 0; + } + } + + & > * { + margin-right: ${theme.eui.euiSize}; + + &:last-child { + margin-right: 0; + } + } + ${grow && + css` + flex: 1; + `} + `} +`; +BarGroup.displayName = 'BarGroup'; + +export const BarText = styled.p.attrs({ + className: 'casesUtilityBar__text', +})` + ${({ theme }) => css` + color: ${theme.eui.euiTextSubduedColor}; + font-size: ${theme.eui.euiFontSizeXS}; + line-height: ${theme.eui.euiLineHeight}; + white-space: nowrap; + `} +`; +BarText.displayName = 'BarText'; + +export const BarAction = styled.div.attrs({ + className: 'casesUtilityBar__action', +})` + ${({ theme }) => css` + font-size: ${theme.eui.euiFontSizeXS}; + line-height: ${theme.eui.euiLineHeight}; + `} +`; +BarAction.displayName = 'BarAction'; + +export const BarSpacer = styled.div.attrs({ + className: 'casesUtilityBar__spacer', +})` + ${() => css` + flex: 1; + `} +`; +BarSpacer.displayName = 'BarSpacer'; diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar.test.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar.test.tsx new file mode 100644 index 0000000000000..98af25a9af466 --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar.test.tsx @@ -0,0 +1,109 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; +import { TestProviders } from '../../common/mock'; + +import { + UtilityBar, + UtilityBarAction, + UtilityBarGroup, + UtilityBarSection, + UtilityBarText, +} from './index'; + +describe('UtilityBar', () => { + test('it renders', () => { + const wrapper = shallow( + <TestProviders> + <UtilityBar> + <UtilityBarSection> + <UtilityBarGroup> + <UtilityBarText>{'Test text'}</UtilityBarText> + </UtilityBarGroup> + + <UtilityBarGroup> + <UtilityBarAction iconType="" popoverContent={() => <p>{'Test popover'}</p>}> + {'Test action'} + </UtilityBarAction> + </UtilityBarGroup> + </UtilityBarSection> + + <UtilityBarSection> + <UtilityBarGroup> + <UtilityBarAction iconType="cross">{'Test action'}</UtilityBarAction> + </UtilityBarGroup> + </UtilityBarSection> + </UtilityBar> + </TestProviders> + ); + + expect(wrapper.find('UtilityBar')).toMatchSnapshot(); + }); + + test('it applies border styles when border is true', () => { + const wrapper = mount( + <TestProviders> + <UtilityBar border> + <UtilityBarSection> + <UtilityBarGroup> + <UtilityBarText>{'Test text'}</UtilityBarText> + </UtilityBarGroup> + + <UtilityBarGroup> + <UtilityBarAction iconType="" popoverContent={() => <p>{'Test popover'}</p>}> + {'Test action'} + </UtilityBarAction> + </UtilityBarGroup> + </UtilityBarSection> + + <UtilityBarSection> + <UtilityBarGroup> + <UtilityBarAction iconType="cross">{'Test action'}</UtilityBarAction> + </UtilityBarGroup> + </UtilityBarSection> + </UtilityBar> + </TestProviders> + ); + const casesUtilityBar = wrapper.find('.casesUtilityBar').first(); + + expect(casesUtilityBar).toHaveStyleRule('border-bottom', euiDarkVars.euiBorderThin); + expect(casesUtilityBar).toHaveStyleRule('padding-bottom', euiDarkVars.paddingSizes.s); + }); + + test('it DOES NOT apply border styles when border is false', () => { + const wrapper = mount( + <TestProviders> + <UtilityBar> + <UtilityBarSection> + <UtilityBarGroup> + <UtilityBarText>{'Test text'}</UtilityBarText> + </UtilityBarGroup> + + <UtilityBarGroup> + <UtilityBarAction iconType="" popoverContent={() => <p>{'Test popover'}</p>}> + {'Test action'} + </UtilityBarAction> + </UtilityBarGroup> + </UtilityBarSection> + + <UtilityBarSection> + <UtilityBarGroup> + <UtilityBarAction iconType="cross">{'Test action'}</UtilityBarAction> + </UtilityBarGroup> + </UtilityBarSection> + </UtilityBar> + </TestProviders> + ); + const casesUtilityBar = wrapper.find('.casesUtilityBar').first(); + + expect(casesUtilityBar).not.toHaveStyleRule('border-bottom', euiDarkVars.euiBorderThin); + expect(casesUtilityBar).not.toHaveStyleRule('padding-bottom', euiDarkVars.paddingSizes.s); + }); +}); diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar.tsx new file mode 100644 index 0000000000000..ff47459d437be --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar.tsx @@ -0,0 +1,20 @@ +/* + * 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 { Bar, BarProps } from './styles'; + +interface UtilityBarProps extends BarProps { + children: React.ReactNode; +} + +export const UtilityBar = React.memo<UtilityBarProps>(({ border, children }) => ( + <Bar border={border}>{children}</Bar> +)); + +UtilityBar.displayName = 'UtilityBar'; diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.test.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.test.tsx new file mode 100644 index 0000000000000..8fc67cefc0f61 --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.test.tsx @@ -0,0 +1,36 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; + +import { TestProviders } from '../../common/mock'; +import { UtilityBarAction } from './index'; + +describe('UtilityBarAction', () => { + test('it renders', () => { + const wrapper = shallow( + <TestProviders> + <UtilityBarAction iconType="alert">{'Test action'}</UtilityBarAction> + </TestProviders> + ); + + expect(wrapper.find('UtilityBarAction')).toMatchSnapshot(); + }); + + test('it renders a popover', () => { + const wrapper = mount( + <TestProviders> + <UtilityBarAction iconType="alert" popoverContent={() => <p>{'Test popover'}</p>}> + {'Test action'} + </UtilityBarAction> + </TestProviders> + ); + + expect(wrapper.find('.euiPopover').first().exists()).toBe(true); + }); +}); diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.tsx new file mode 100644 index 0000000000000..19cb8ef4f613b --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiPopover } from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; + +import { LinkIcon, LinkIconProps } from '../link_icon'; +import { BarAction } from './styles'; + +const Popover = React.memo<UtilityBarActionProps>( + ({ children, color, iconSide, iconSize, iconType, popoverContent, disabled, ownFocus }) => { + const [popoverState, setPopoverState] = useState(false); + + const closePopover = useCallback(() => setPopoverState(false), [setPopoverState]); + + return ( + <EuiPopover + ownFocus={ownFocus} + button={ + <LinkIcon + color={color} + iconSide={iconSide} + iconSize={iconSize} + iconType={iconType} + onClick={() => setPopoverState(!popoverState)} + disabled={disabled} + > + {children} + </LinkIcon> + } + closePopover={() => setPopoverState(false)} + isOpen={popoverState} + repositionOnScroll + > + {popoverContent?.(closePopover)} + </EuiPopover> + ); + } +); + +Popover.displayName = 'Popover'; + +export interface UtilityBarActionProps extends LinkIconProps { + popoverContent?: (closePopover: () => void) => React.ReactNode; + dataTestSubj?: string; + ownFocus?: boolean; +} + +export const UtilityBarAction = React.memo<UtilityBarActionProps>( + ({ + children, + color, + dataTestSubj, + disabled, + href, + iconSide, + iconSize, + iconType, + ownFocus, + onClick, + popoverContent, + }) => ( + <BarAction data-test-subj={dataTestSubj}> + {popoverContent ? ( + <Popover + disabled={disabled} + color={color} + iconSide={iconSide} + iconSize={iconSize} + iconType={iconType} + ownFocus={ownFocus} + popoverContent={popoverContent} + > + {children} + </Popover> + ) : ( + <LinkIcon + color={color} + disabled={disabled} + href={href} + iconSide={iconSide} + iconSize={iconSize} + iconType={iconType} + onClick={onClick} + > + {children} + </LinkIcon> + )} + </BarAction> + ) +); + +UtilityBarAction.displayName = 'UtilityBarAction'; diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_group.test.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_group.test.tsx new file mode 100644 index 0000000000000..546dcf48bba9a --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_group.test.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; + +import { TestProviders } from '../../common/mock'; +import { UtilityBarGroup, UtilityBarText } from './index'; + +describe('UtilityBarGroup', () => { + test('it renders', () => { + const wrapper = shallow( + <TestProviders> + <UtilityBarGroup> + <UtilityBarText>{'Test text'}</UtilityBarText> + </UtilityBarGroup> + </TestProviders> + ); + + expect(wrapper.find('UtilityBarGroup')).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_group.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_group.tsx new file mode 100644 index 0000000000000..ef83d6effc8a3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_group.tsx @@ -0,0 +1,20 @@ +/* + * 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 { BarGroup, BarGroupProps } from './styles'; + +export interface UtilityBarGroupProps extends BarGroupProps { + children: React.ReactNode; +} + +export const UtilityBarGroup = React.memo<UtilityBarGroupProps>(({ grow, children }) => ( + <BarGroup grow={grow}>{children}</BarGroup> +)); + +UtilityBarGroup.displayName = 'UtilityBarGroup'; diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_section.test.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_section.test.tsx new file mode 100644 index 0000000000000..f06ff651b5419 --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_section.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; + +import { TestProviders } from '../../common/mock'; +import { UtilityBarGroup, UtilityBarSection, UtilityBarText } from './index'; + +describe('UtilityBarSection', () => { + test('it renders', () => { + const wrapper = shallow( + <TestProviders> + <UtilityBarSection> + <UtilityBarGroup> + <UtilityBarText>{'Test text'}</UtilityBarText> + </UtilityBarGroup> + </UtilityBarSection> + </TestProviders> + ); + + expect(wrapper.find('UtilityBarSection')).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_section.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_section.tsx new file mode 100644 index 0000000000000..c84219cc63488 --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_section.tsx @@ -0,0 +1,20 @@ +/* + * 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 { BarSection, BarSectionProps } from './styles'; + +export interface UtilityBarSectionProps extends BarSectionProps { + children: React.ReactNode; +} + +export const UtilityBarSection = React.memo<UtilityBarSectionProps>(({ grow, children }) => ( + <BarSection grow={grow}>{children}</BarSection> +)); + +UtilityBarSection.displayName = 'UtilityBarSection'; diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_spacer.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_spacer.tsx new file mode 100644 index 0000000000000..11b3be8d656e4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_spacer.tsx @@ -0,0 +1,20 @@ +/* + * 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 { BarSpacer } from './styles'; + +export interface UtilityBarSpacerProps { + dataTestSubj?: string; +} + +export const UtilityBarSpacer = React.memo<UtilityBarSpacerProps>(({ dataTestSubj }) => ( + <BarSpacer data-test-subj={dataTestSubj} /> +)); + +UtilityBarSpacer.displayName = 'UtilityBarSpacer'; diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_text.test.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_text.test.tsx new file mode 100644 index 0000000000000..456a1f4bed3be --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_text.test.tsx @@ -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 { shallow } from 'enzyme'; +import React from 'react'; + +import { TestProviders } from '../../common/mock'; +import { UtilityBarText } from './index'; + +describe('UtilityBarText', () => { + test('it renders', () => { + const wrapper = shallow( + <TestProviders> + <UtilityBarText>{'Test text'}</UtilityBarText> + </TestProviders> + ); + + expect(wrapper.find('UtilityBarText')).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_text.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_text.tsx new file mode 100644 index 0000000000000..c0be3cbfbe202 --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_text.tsx @@ -0,0 +1,21 @@ +/* + * 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 { BarText } from './styles'; + +export interface UtilityBarTextProps { + children: string | JSX.Element; + dataTestSubj?: string; +} + +export const UtilityBarText = React.memo<UtilityBarTextProps>(({ children, dataTestSubj }) => ( + <BarText data-test-subj={dataTestSubj}>{children}</BarText> +)); + +UtilityBarText.displayName = 'UtilityBarText'; diff --git a/x-pack/plugins/cases/public/components/wrappers/index.tsx b/x-pack/plugins/cases/public/components/wrappers/index.tsx new file mode 100644 index 0000000000000..3b33e9304da83 --- /dev/null +++ b/x-pack/plugins/cases/public/components/wrappers/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import styled from 'styled-components'; + +export const WhitePageWrapper = styled.div` + background-color: ${({ theme }) => theme.eui.euiColorEmptyShade}; + border-top: ${({ theme }) => theme.eui.euiBorderThin}; + flex: 1 1 auto; +`; + +export const SectionWrapper = styled.div` + box-sizing: content-box; + margin: 0 auto; + max-width: 1175px; + width: 100%; +`; + +export const HeaderWrapper = styled.div` + padding: ${({ theme }) => + `${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} 0 ${theme.eui.paddingSizes.l}`}; +`; diff --git a/x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/__mocks__/api.ts similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts rename to x-pack/plugins/cases/public/containers/__mocks__/api.ts index 11ae4fd6bf178..4dbb10da95b2d 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/__mocks__/api.ts @@ -33,7 +33,7 @@ import { CommentRequest, User, CaseStatuses, -} from '../../../../../cases/common/api'; +} from '../../../common'; export const getCase = async ( caseId: string, diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/api.test.tsx rename to x-pack/plugins/cases/public/containers/api.test.tsx index e6ecf45097a1a..3e71a05df7cc1 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -5,10 +5,10 @@ * 2.0. */ -import { KibanaServices } from '../../common/lib/kibana'; +import { KibanaServices } from '../common/lib/kibana'; -import { ConnectorTypes, CommentType, CaseStatuses } from '../../../../cases/common/api'; -import { CASES_URL } from '../../../../cases/common/constants'; +import { ConnectorTypes, CommentType, CaseStatuses } from '../../common'; +import { CASES_URL } from '../../common'; import { deleteCases, @@ -50,7 +50,7 @@ import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; -jest.mock('../../common/lib/kibana'); +jest.mock('../common/lib/kibana'); const fetchMock = jest.fn(); mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts similarity index 97% rename from x-pack/plugins/security_solution/public/cases/containers/api.ts rename to x-pack/plugins/cases/public/containers/api.ts index 644c7dbf716bf..75263d4d38978 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -18,11 +18,12 @@ import { CaseUserActionsResponse, CommentRequest, CommentType, + StatusAll, SubCasePatchRequest, SubCaseResponse, SubCasesResponse, User, -} from '../../../../cases/common/api'; +} from '../../common'; import { ACTION_TYPES_URL, @@ -32,7 +33,7 @@ import { CASES_URL, SUB_CASE_DETAILS_URL, SUB_CASES_PATCH_DEL_URL, -} from '../../../../cases/common/constants'; +} from '../../common'; import { getCaseCommentsUrl, @@ -41,10 +42,9 @@ import { getCaseUserActionUrl, getSubCaseDetailsUrl, getSubCaseUserActionUrl, -} from '../../../../cases/common/api/helpers'; +} from '../../common'; -import { KibanaServices } from '../../common/lib/kibana'; -import { StatusAll } from '../components/status'; +import { KibanaServices } from '../common/lib/kibana'; import { ActionLicense, diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts similarity index 96% rename from x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts rename to x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts index d9cd81f143816..ea4b92706b4d1 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts @@ -10,7 +10,7 @@ import { CasesConfigureRequest, ActionConnector, ActionTypeConnector, -} from '../../../../../../cases/common/api'; +} from '../../../../common'; import { ApiProps } from '../../types'; import { CaseConfigure } from '../types'; diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts b/x-pack/plugins/cases/public/containers/configure/api.test.ts similarity index 96% rename from x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts rename to x-pack/plugins/cases/public/containers/configure/api.test.ts index 0c7ae422be861..ae749b4391776 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.test.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { KibanaServices } from '../../../common/lib/kibana'; import { fetchConnectors, getCaseConfigure, @@ -20,11 +19,12 @@ import { caseConfigurationResposeMock, caseConfigurationCamelCaseResponseMock, } from './mock'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; +import { ConnectorTypes } from '../../../common'; +import { KibanaServices } from '../../common/lib/kibana'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; -jest.mock('../../../common/lib/kibana'); +jest.mock('../../common/lib/kibana'); const fetchMock = jest.fn(); mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts b/x-pack/plugins/cases/public/containers/configure/api.ts similarity index 94% rename from x-pack/plugins/security_solution/public/cases/containers/configure/api.ts rename to x-pack/plugins/cases/public/containers/configure/api.ts index 943724ef08398..ca8b7e3a05734 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.ts @@ -7,19 +7,16 @@ import { isEmpty } from 'lodash/fp'; import { + ACTION_TYPES_URL, ActionConnector, ActionTypeConnector, - CasesConfigurePatch, - CasesConfigureResponse, - CasesConfigureRequest, -} from '../../../../../cases/common/api'; -import { KibanaServices } from '../../../common/lib/kibana'; - -import { CASE_CONFIGURE_CONNECTORS_URL, CASE_CONFIGURE_URL, - ACTION_TYPES_URL, -} from '../../../../../cases/common/constants'; + CasesConfigurePatch, + CasesConfigureRequest, + CasesConfigureResponse, +} from '../../../common'; +import { KibanaServices } from '../../common/lib/kibana'; import { ApiProps } from '../types'; import { convertToCamelCase, decodeCaseConfigureResponse } from '../utils'; diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts b/x-pack/plugins/cases/public/containers/configure/mock.ts similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts rename to x-pack/plugins/cases/public/containers/configure/mock.ts index 4e71c9a990ece..766452e3e58e7 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts +++ b/x-pack/plugins/cases/public/containers/configure/mock.ts @@ -11,7 +11,7 @@ import { CasesConfigureResponse, CasesConfigureRequest, ConnectorTypes, -} from '../../../../../cases/common/api'; +} from '../../../common'; import { CaseConfigure, CaseConnectorMapping } from './types'; export const mappings: CaseConnectorMapping[] = [ diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/translations.ts b/x-pack/plugins/cases/public/containers/configure/translations.ts similarity index 64% rename from x-pack/plugins/security_solution/public/cases/containers/configure/translations.ts rename to x-pack/plugins/cases/public/containers/configure/translations.ts index 455293b217679..e77b9f57c8f4c 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/translations.ts +++ b/x-pack/plugins/cases/public/containers/configure/translations.ts @@ -9,9 +9,6 @@ import { i18n } from '@kbn/i18n'; export * from '../translations'; -export const SUCCESS_CONFIGURE = i18n.translate( - 'xpack.securitySolution.cases.configure.successSaveToast', - { - defaultMessage: 'Saved external connection settings', - } -); +export const SUCCESS_CONFIGURE = i18n.translate('xpack.cases.configure.successSaveToast', { + defaultMessage: 'Saved external connection settings', +}); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts b/x-pack/plugins/cases/public/containers/configure/types.ts similarity index 95% rename from x-pack/plugins/security_solution/public/cases/containers/configure/types.ts rename to x-pack/plugins/cases/public/containers/configure/types.ts index aa86d1bfdb0b1..b021ae2163fa2 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts +++ b/x-pack/plugins/cases/public/containers/configure/types.ts @@ -15,7 +15,7 @@ import { CasesConfigure, ClosureType, ThirdPartyField, -} from '../../../../../cases/common/api'; +} from '../../../common'; export { ActionConnector, diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_action_types.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.test.tsx rename to x-pack/plugins/cases/public/containers/configure/use_action_types.test.tsx index 25017f7931db8..fad84617ee140 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.test.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_action_types.test.tsx @@ -11,6 +11,7 @@ import { actionTypesMock } from './mock'; import * as api from './api'; jest.mock('./api'); +jest.mock('../../common/lib/kibana'); describe('useActionTypes', () => { beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx b/x-pack/plugins/cases/public/containers/configure/use_action_types.tsx similarity index 85% rename from x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx rename to x-pack/plugins/cases/public/containers/configure/use_action_types.tsx index 3590fffdef5b2..eaaadd65d29d1 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_action_types.tsx @@ -7,10 +7,10 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { useStateToaster, errorToToaster } from '../../../common/components/toasters'; import * as i18n from '../translations'; import { fetchActionTypes } from './api'; import { ActionTypeConnector } from './types'; +import { useToasts } from '../../common/lib/kibana'; export interface UseActionTypesResponse { loading: boolean; @@ -19,7 +19,7 @@ export interface UseActionTypesResponse { } export const useActionTypes = (): UseActionTypesResponse => { - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const [loading, setLoading] = useState(true); const [actionTypes, setActionTypes] = useState<ActionTypeConnector[]>([]); const isCancelledRef = useRef(false); @@ -43,14 +43,12 @@ export const useActionTypes = (): UseActionTypesResponse => { if (!isCancelledRef.current) { setLoading(false); setActionTypes([]); - errorToToaster({ + toasts.addError(error.body && error.body.message ? new Error(error.body.message) : error, { title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, }); } } - }, [dispatchToaster]); + }, [toasts]); useEffect(() => { if (queryFirstTime.current) { diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.test.tsx rename to x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx index 44a503cd089ef..968afcc6ecfb3 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.test.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx @@ -14,15 +14,21 @@ import { } from './use_configure'; import { mappings, caseConfigurationCamelCaseResponseMock } from './mock'; import * as api from './api'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; +import { ConnectorTypes } from '../../../common'; +const mockErrorToast = jest.fn(); +const mockSuccessToast = jest.fn(); jest.mock('./api'); -const mockErrorToToaster = jest.fn(); -jest.mock('../../../common/components/toasters', () => { - const original = jest.requireActual('../../../common/components/toasters'); +jest.mock('../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../common/lib/kibana'); return { - ...original, - errorToToaster: () => mockErrorToToaster(), + ...originalModule, + useToasts: () => { + return { + addError: mockErrorToast, + addSuccess: mockSuccessToast, + }; + }, }; }); const configuration: ConnectorConfiguration = { @@ -164,7 +170,7 @@ describe('useConfigure', () => { ); await waitForNextUpdate(); await waitForNextUpdate(); - expect(mockErrorToToaster).not.toHaveBeenCalled(); + expect(mockErrorToast).not.toHaveBeenCalled(); result.current.persistCaseConfigure(configuration); @@ -190,7 +196,7 @@ describe('useConfigure', () => { ); await waitForNextUpdate(); await waitForNextUpdate(); - expect(mockErrorToToaster).toHaveBeenCalled(); + expect(mockErrorToast).toHaveBeenCalled(); }); }); @@ -219,12 +225,12 @@ describe('useConfigure', () => { ); await waitForNextUpdate(); await waitForNextUpdate(); - expect(mockErrorToToaster).not.toHaveBeenCalled(); + expect(mockErrorToast).not.toHaveBeenCalled(); result.current.persistCaseConfigure(configuration); - expect(mockErrorToToaster).not.toHaveBeenCalled(); + expect(mockErrorToast).not.toHaveBeenCalled(); await waitForNextUpdate(); - expect(mockErrorToToaster).toHaveBeenCalled(); + expect(mockErrorToast).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx b/x-pack/plugins/cases/public/containers/configure/use_configure.tsx similarity index 89% rename from x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx rename to x-pack/plugins/cases/public/containers/configure/use_configure.tsx index 2ec2a73363bfe..c4b3db5956cd7 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_configure.tsx @@ -8,14 +8,10 @@ import { useEffect, useCallback, useReducer, useRef } from 'react'; import { getCaseConfigure, patchCaseConfigure, postCaseConfigure } from './api'; -import { - useStateToaster, - errorToToaster, - displaySuccessToast, -} from '../../../common/components/toasters'; import * as i18n from './translations'; import { ClosureType, CaseConfigure, CaseConnector, CaseConnectorMapping } from './types'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; +import { ConnectorTypes } from '../../../common'; +import { useToasts } from '../../common/lib/kibana'; export type ConnectorConfiguration = { connector: CaseConnector } & { closureType: CaseConfigure['closureType']; @@ -149,7 +145,7 @@ export const initialState: State = { export const useCaseConfigure = (): ReturnUseCaseConfigure => { const [state, dispatch] = useReducer(configureCasesReducer, initialState); - + const toasts = useToasts(); const setCurrentConfiguration = useCallback((configuration: ConnectorConfiguration) => { dispatch({ currentConfiguration: configuration, @@ -206,7 +202,6 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { }); }, []); - const [, dispatchToaster] = useStateToaster(); const isCancelledRefetchRef = useRef(false); const abortCtrlRefetchRef = useRef(new AbortController()); @@ -243,9 +238,7 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { } } if (res.error != null) { - errorToToaster({ - dispatchToaster, - error: new Error(res.error), + toasts.addError(new Error(res.error), { title: i18n.ERROR_TITLE, }); } @@ -255,11 +248,10 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { } catch (error) { if (!isCancelledRefetchRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - dispatchToaster, - error: error.body && error.body.message ? new Error(error.body.message) : error, - title: i18n.ERROR_TITLE, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } setLoading(false); } @@ -290,7 +282,6 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { }, abortCtrlPersistRef.current.signal ); - if (!isCancelledPersistRef.current) { setConnector(res.connector); if (setClosureType) { @@ -307,23 +298,22 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { }); } if (res.error != null) { - errorToToaster({ - dispatchToaster, - error: new Error(res.error), + toasts.addError(new Error(res.error), { title: i18n.ERROR_TITLE, }); } - displaySuccessToast(i18n.SUCCESS_CONFIGURE, dispatchToaster); + toasts.addSuccess(i18n.SUCCESS_CONFIGURE); setPersistLoading(false); } } catch (error) { if (!isCancelledPersistRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { + title: i18n.ERROR_TITLE, + } + ); } setConnector(state.currentConfiguration.connector); setPersistLoading(false); @@ -331,14 +321,15 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { } }, [ - dispatchToaster, setClosureType, setConnector, setCurrentConfiguration, setMappings, setPersistLoading, setVersion, - state, + state.currentConfiguration.connector, + state.version, + toasts, ] ); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_connectors.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.test.tsx rename to x-pack/plugins/cases/public/containers/configure/use_connectors.test.tsx index ed1dfcbc40c87..e3d2650fee025 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.test.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_connectors.test.tsx @@ -11,6 +11,7 @@ import { connectorsMock } from './mock'; import * as api from './api'; jest.mock('./api'); +jest.mock('../../common/lib/kibana'); describe('useConnectors', () => { beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx b/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx similarity index 68% rename from x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx rename to x-pack/plugins/cases/public/containers/configure/use_connectors.tsx index 338d04f702c63..3b91c77d0235a 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx @@ -7,10 +7,10 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { useStateToaster, errorToToaster } from '../../../common/components/toasters'; import * as i18n from '../translations'; import { fetchConnectors } from './api'; import { ActionConnector } from './types'; +import { useToasts } from '../../common/lib/kibana'; export interface UseConnectorsResponse { loading: boolean; @@ -19,9 +19,14 @@ export interface UseConnectorsResponse { } export const useConnectors = (): UseConnectorsResponse => { - const [, dispatchToaster] = useStateToaster(); - const [loading, setLoading] = useState(true); - const [connectors, setConnectors] = useState<ActionConnector[]>([]); + const toasts = useToasts(); + const [state, setState] = useState<{ + loading: boolean; + connectors: ActionConnector[]; + }>({ + loading: true, + connectors: [], + }); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -30,26 +35,30 @@ export const useConnectors = (): UseConnectorsResponse => { isCancelledRef.current = false; abortCtrlRef.current.abort(); abortCtrlRef.current = new AbortController(); - - setLoading(true); + setState({ + ...state, + loading: true, + }); const res = await fetchConnectors({ signal: abortCtrlRef.current.signal }); if (!isCancelledRef.current) { - setLoading(false); - setConnectors(res); + setState({ + loading: false, + connectors: res, + }); } } catch (error) { if (!isCancelledRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } - - setLoading(false); - setConnectors([]); + setState({ + loading: false, + connectors: [], + }); } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -65,8 +74,8 @@ export const useConnectors = (): UseConnectorsResponse => { }, []); return { - loading, - connectors, + loading: state.loading, + connectors: state.connectors, refetchConnectors, }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/constants.ts b/x-pack/plugins/cases/public/containers/constants.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/containers/constants.ts rename to x-pack/plugins/cases/public/containers/constants.ts diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/mock.ts rename to x-pack/plugins/cases/public/containers/mock.ts index 6e937fe7760cd..1e7cec29de56b 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -8,21 +8,21 @@ import { ActionLicense, AllCases, Case, CasesStatus, CaseUserActions, Comment } from './types'; import { - CommentResponse, - CaseStatuses, - UserAction, - UserActionField, + AssociationType, CaseResponse, + CasesFindResponse, + CasesResponse, CasesStatusResponse, + CaseStatuses, + CaseType, CaseUserActionsResponse, - CasesResponse, - CasesFindResponse, + CommentResponse, CommentType, - AssociationType, - CaseType, -} from '../../../../cases/common/api'; + ConnectorTypes, + UserAction, + UserActionField, +} from '../../common'; import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; -import { ConnectorTypes } from '../../../../cases/common/api/connectors'; export { connectorsMock } from './configure/mock'; export const basicCaseId = 'basic-case-id'; diff --git a/x-pack/plugins/security_solution/public/cases/containers/translations.ts b/x-pack/plugins/cases/public/containers/translations.ts similarity index 64% rename from x-pack/plugins/security_solution/public/cases/containers/translations.ts rename to x-pack/plugins/cases/public/containers/translations.ts index 4c7afc9224445..966a5e158923f 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/translations.ts +++ b/x-pack/plugins/cases/public/containers/translations.ts @@ -7,27 +7,24 @@ import { i18n } from '@kbn/i18n'; -export * from '../translations'; +export * from '../common/translations'; -export const ERROR_TITLE = i18n.translate('xpack.securitySolution.containers.cases.errorTitle', { +export const ERROR_TITLE = i18n.translate('xpack.cases.containers.errorTitle', { defaultMessage: 'Error fetching data', }); -export const ERROR_DELETING = i18n.translate( - 'xpack.securitySolution.containers.cases.errorDeletingTitle', - { - defaultMessage: 'Error deleting data', - } -); +export const ERROR_DELETING = i18n.translate('xpack.cases.containers.errorDeletingTitle', { + defaultMessage: 'Error deleting data', +}); export const UPDATED_CASE = (caseTitle: string) => - i18n.translate('xpack.securitySolution.containers.cases.updatedCase', { + i18n.translate('xpack.cases.containers.updatedCase', { values: { caseTitle }, defaultMessage: 'Updated "{caseTitle}"', }); export const DELETED_CASES = (totalCases: number, caseTitle?: string) => - i18n.translate('xpack.securitySolution.containers.cases.deletedCases', { + i18n.translate('xpack.cases.containers.deletedCases', { values: { caseTitle, totalCases }, defaultMessage: 'Deleted {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}}', }); @@ -39,7 +36,7 @@ export const CLOSED_CASES = ({ totalCases: number; caseTitle?: string; }) => - i18n.translate('xpack.securitySolution.containers.cases.closedCases', { + i18n.translate('xpack.cases.containers.closedCases', { values: { caseTitle, totalCases }, defaultMessage: 'Closed {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}}', }); @@ -51,7 +48,7 @@ export const REOPENED_CASES = ({ totalCases: number; caseTitle?: string; }) => - i18n.translate('xpack.securitySolution.containers.cases.reopenedCases', { + i18n.translate('xpack.cases.containers.reopenedCases', { values: { caseTitle, totalCases }, defaultMessage: 'Opened {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}}', }); @@ -63,33 +60,30 @@ export const MARK_IN_PROGRESS_CASES = ({ totalCases: number; caseTitle?: string; }) => - i18n.translate('xpack.securitySolution.containers.cases.markInProgressCases', { + i18n.translate('xpack.cases.containers.markInProgressCases', { values: { caseTitle, totalCases }, defaultMessage: 'Marked {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}} as in progress', }); export const SUCCESS_SEND_TO_EXTERNAL_SERVICE = (serviceName: string) => - i18n.translate('xpack.securitySolution.containers.cases.pushToExternalService', { + i18n.translate('xpack.cases.containers.pushToExternalService', { values: { serviceName }, defaultMessage: 'Successfully sent to { serviceName }', }); -export const ERROR_GET_FIELDS = i18n.translate( - 'xpack.securitySolution.cases.configure.errorGetFields', - { - defaultMessage: 'Error getting fields from service', - } -); +export const ERROR_GET_FIELDS = i18n.translate('xpack.cases.configure.errorGetFields', { + defaultMessage: 'Error getting fields from service', +}); export const SYNC_CASE = (caseTitle: string) => - i18n.translate('xpack.securitySolution.containers.cases.syncCase', { + i18n.translate('xpack.cases.containers.syncCase', { values: { caseTitle }, defaultMessage: 'Alerts in "{caseTitle}" have been synced', }); export const STATUS_CHANGED_TOASTER_TEXT = i18n.translate( - 'xpack.securitySolution.cases.containers.statusChangeToasterText', + 'xpack.cases.containers.statusChangeToasterText', { defaultMessage: 'Alerts in this case have been also had their status updated', } diff --git a/x-pack/plugins/cases/public/containers/types.ts b/x-pack/plugins/cases/public/containers/types.ts new file mode 100644 index 0000000000000..62a5f9299498e --- /dev/null +++ b/x-pack/plugins/cases/public/containers/types.ts @@ -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/ui'; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.test.tsx b/x-pack/plugins/cases/public/containers/use_bulk_update_case.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.test.tsx rename to x-pack/plugins/cases/public/containers/use_bulk_update_case.test.tsx index d5562afec1d26..67f202e6adbad 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_bulk_update_case.test.tsx @@ -6,12 +6,13 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { CaseStatuses } from '../../../../cases/common/api'; +import { CaseStatuses } from '../../common'; import { useUpdateCases, UseUpdateCases } from './use_bulk_update_case'; import { basicCase } from './mock'; import * as api from './api'; jest.mock('./api'); +jest.mock('../common/lib/kibana'); describe('useUpdateCases', () => { const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx b/x-pack/plugins/cases/public/containers/use_bulk_update_case.tsx similarity index 89% rename from x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx rename to x-pack/plugins/cases/public/containers/use_bulk_update_case.tsx index d39da93a06a48..ae2d09deafb04 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx +++ b/x-pack/plugins/cases/public/containers/use_bulk_update_case.tsx @@ -6,15 +6,11 @@ */ import { useCallback, useReducer, useRef, useEffect } from 'react'; -import { CaseStatuses } from '../../../../cases/common/api'; -import { - displaySuccessToast, - errorToToaster, - useStateToaster, -} from '../../common/components/toasters'; +import { CaseStatuses } from '../../common'; import * as i18n from './translations'; import { patchCasesStatus } from './api'; import { BulkUpdateStatus, Case } from './types'; +import { useToasts } from '../common/lib/kibana'; interface UpdateState { isUpdated: boolean; @@ -86,7 +82,7 @@ export const useUpdateCases = (): UseUpdateCases => { isError: false, isUpdated: false, }); - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -112,16 +108,15 @@ export const useUpdateCases = (): UseUpdateCases => { const message = action === 'status' ? getStatusToasterMessage(patchResponse[0].status, messageArgs) : ''; - displaySuccessToast(message, dispatchToaster); + toasts.addSuccess(message); } } catch (error) { if (!isCancelledRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } dispatch({ type: 'FETCH_FAILURE' }); } diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.test.tsx b/x-pack/plugins/cases/public/containers/use_delete_cases.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.test.tsx rename to x-pack/plugins/cases/public/containers/use_delete_cases.test.tsx index b4fa816412c68..e86ed0c036974 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_delete_cases.test.tsx @@ -7,11 +7,12 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { CaseType } from '../../../../cases/common/api'; +import { CaseType } from '../../common'; import { useDeleteCases, UseDeleteCase } from './use_delete_cases'; import * as api from './api'; jest.mock('./api'); +jest.mock('../common/lib/kibana'); describe('useDeleteCases', () => { const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx b/x-pack/plugins/cases/public/containers/use_delete_cases.tsx similarity index 90% rename from x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx rename to x-pack/plugins/cases/public/containers/use_delete_cases.tsx index f3d59a2883f2a..81a44004b2441 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx +++ b/x-pack/plugins/cases/public/containers/use_delete_cases.tsx @@ -6,14 +6,10 @@ */ import { useCallback, useReducer, useRef, useEffect } from 'react'; -import { - displaySuccessToast, - errorToToaster, - useStateToaster, -} from '../../common/components/toasters'; import * as i18n from './translations'; import { deleteCases, deleteSubCases } from './api'; import { DeleteCase } from './types'; +import { useToasts } from '../common/lib/kibana'; interface DeleteState { isDisplayConfirmDeleteModal: boolean; @@ -77,7 +73,7 @@ export const useDeleteCases = (): UseDeleteCase => { isError: false, isDeleted: false, }); - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -98,19 +94,17 @@ export const useDeleteCases = (): UseDeleteCase => { if (!isCancelledRef.current) { dispatch({ type: 'FETCH_SUCCESS', payload: true }); - displaySuccessToast( - i18n.DELETED_CASES(cases.length, cases.length === 1 ? cases[0].title : ''), - dispatchToaster + toasts.addSuccess( + i18n.DELETED_CASES(cases.length, cases.length === 1 ? cases[0].title : '') ); } } catch (error) { if (!isCancelledRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_DELETING, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_DELETING } + ); } dispatch({ type: 'FETCH_FAILURE' }); } diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx b/x-pack/plugins/cases/public/containers/use_get_action_license.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx rename to x-pack/plugins/cases/public/containers/use_get_action_license.test.tsx index 4c6cbae0c8981..ae6a884514161 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_action_license.test.tsx @@ -11,6 +11,7 @@ import { actionLicenses } from './mock'; import * as api from './api'; jest.mock('./api'); +jest.mock('../common/lib/kibana'); describe('useGetActionLicense', () => { const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx b/x-pack/plugins/cases/public/containers/use_get_action_license.tsx similarity index 86% rename from x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx rename to x-pack/plugins/cases/public/containers/use_get_action_license.tsx index 9b10247794c8d..4f28d88c14b25 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_action_license.tsx @@ -7,7 +7,7 @@ import { useCallback, useEffect, useState, useRef } from 'react'; -import { errorToToaster, useStateToaster } from '../../common/components/toasters'; +import { useToasts } from '../common/lib/kibana'; import { getActionLicense } from './api'; import * as i18n from './translations'; import { ActionLicense } from './types'; @@ -28,7 +28,7 @@ const MINIMUM_LICENSE_REQUIRED_CONNECTOR = '.jira'; export const useGetActionLicense = (): ActionLicenseState => { const [actionLicenseState, setActionLicensesState] = useState<ActionLicenseState>(initialData); - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -54,11 +54,10 @@ export const useGetActionLicense = (): ActionLicenseState => { } catch (error) { if (!isCancelledRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } setActionLicensesState({ diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/use_get_case.test.tsx rename to x-pack/plugins/cases/public/containers/use_get_case.test.tsx index a3d64a17727e5..75d9ac74a8ccf 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case.test.tsx @@ -11,6 +11,7 @@ import { basicCase } from './mock'; import * as api from './api'; jest.mock('./api'); +jest.mock('../common/lib/kibana'); describe('useGetCase', () => { const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/cases/public/containers/use_get_case.tsx similarity index 89% rename from x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx rename to x-pack/plugins/cases/public/containers/use_get_case.tsx index 70e202b5d6bdf..7b59f8e06b7af 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case.tsx @@ -9,7 +9,7 @@ import { useEffect, useReducer, useCallback, useRef } from 'react'; import { Case } from './types'; import * as i18n from './translations'; -import { errorToToaster, useStateToaster } from '../../common/components/toasters'; +import { useToasts } from '../common/lib/kibana'; import { getCase, getSubCase } from './api'; interface CaseState { @@ -66,7 +66,7 @@ export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => { isError: false, data: null, }); - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -91,11 +91,10 @@ export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => { } catch (error) { if (!isCancelledRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } dispatch({ type: 'FETCH_FAILURE' }); } diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.test.tsx rename to x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx index 1c8096198007e..62b4cf92434cd 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx @@ -23,6 +23,7 @@ import { import * as api from './api'; jest.mock('./api'); +jest.mock('../common/lib/kibana'); describe('useGetCaseUserActions', () => { const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx rename to x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx index 3b28c20d9a4df..66aa93154b318 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx @@ -9,12 +9,17 @@ import { isEmpty, uniqBy } from 'lodash/fp'; import { useCallback, useEffect, useState, useRef } from 'react'; import deepEqual from 'fast-deep-equal'; -import { errorToToaster, useStateToaster } from '../../common/components/toasters'; -import { CaseFullExternalService } from '../../../../cases/common/api/cases'; +import { + CaseFullExternalService, + CaseConnector, + CaseExternalService, + CaseUserActions, + ElasticUser, +} from '../../common'; import { getCaseUserActions, getSubCaseUserActions } from './api'; import * as i18n from './translations'; -import { CaseConnector, CaseExternalService, CaseUserActions, ElasticUser } from './types'; import { convertToCamelCase, parseString } from './utils'; +import { useToasts } from '../common/lib/kibana'; export interface CaseService extends CaseExternalService { firstPushIndex: number; @@ -246,7 +251,7 @@ export const useGetCaseUserActions = ( ); const abortCtrlRef = useRef(new AbortController()); const isCancelledRef = useRef(false); - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const fetchCaseUserActions = useCallback( async (thisCaseId: string, thisCaseConnectorId: string, thisSubCaseId?: string) => { @@ -288,11 +293,10 @@ export const useGetCaseUserActions = ( } catch (error) { if (!isCancelledRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } setCaseUserActionsState({ diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/use_get_cases.test.tsx rename to x-pack/plugins/cases/public/containers/use_get_cases.test.tsx index 3a62ae70b82de..b07fec4984eb1 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx @@ -6,7 +6,7 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { CaseStatuses } from '../../../../cases/common/api'; +import { CaseStatuses } from '../../common'; import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS, @@ -19,6 +19,7 @@ import { allCases, basicCase } from './mock'; import * as api from './api'; jest.mock('./api'); +jest.mock('../common/lib/kibana'); describe('useGetCases', () => { const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.tsx similarity index 79% rename from x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx rename to x-pack/plugins/cases/public/containers/use_get_cases.tsx index d27bb5ab1b462..ec1abd6214926 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.tsx @@ -7,11 +7,18 @@ import { useCallback, useEffect, useReducer, useRef } from 'react'; import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants'; -import { AllCases, SortFieldCase, FilterOptions, QueryParams, Case, UpdateByKey } from './types'; -import { errorToToaster, useStateToaster } from '../../common/components/toasters'; +import { + AllCases, + Case, + FilterOptions, + QueryParams, + SortFieldCase, + StatusAll, + UpdateByKey, +} from './types'; +import { useToasts } from '../common/lib/kibana'; import * as i18n from './translations'; import { getCases, patchCase } from './api'; -import { StatusAll } from '../components/status'; export interface UseGetCasesState { data: AllCases; @@ -130,19 +137,20 @@ export interface UseGetCases extends UseGetCasesState { setSelectedCases: (mySelectedCases: Case[]) => void; } +const empty = {}; export const useGetCases = ( - initialQueryParams?: QueryParams, - initialFilterOptions?: FilterOptions + initialQueryParams: Partial<QueryParams> = empty, + initialFilterOptions: Partial<FilterOptions> = empty ): UseGetCases => { const [state, dispatch] = useReducer(dataFetchReducer, { data: initialData, - filterOptions: initialFilterOptions ?? DEFAULT_FILTER_OPTIONS, + filterOptions: { ...DEFAULT_FILTER_OPTIONS, ...initialFilterOptions }, isError: false, loading: [], - queryParams: initialQueryParams ?? DEFAULT_QUERY_PARAMS, + queryParams: { ...DEFAULT_QUERY_PARAMS, ...initialQueryParams }, selectedCases: [], }); - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const didCancelFetchCases = useRef(false); const didCancelUpdateCases = useRef(false); const abortCtrlFetchCases = useRef(new AbortController()); @@ -160,39 +168,40 @@ export const useGetCases = ( dispatch({ type: 'UPDATE_FILTER_OPTIONS', payload: newFilters }); }, []); - const fetchCases = useCallback(async (filterOptions: FilterOptions, queryParams: QueryParams) => { - try { - didCancelFetchCases.current = false; - abortCtrlFetchCases.current.abort(); - abortCtrlFetchCases.current = new AbortController(); - dispatch({ type: 'FETCH_INIT', payload: 'cases' }); - - const response = await getCases({ - filterOptions, - queryParams, - signal: abortCtrlFetchCases.current.signal, - }); - - if (!didCancelFetchCases.current) { - dispatch({ - type: 'FETCH_CASES_SUCCESS', - payload: response, + const fetchCases = useCallback( + async (filterOptions: FilterOptions, queryParams: QueryParams) => { + try { + didCancelFetchCases.current = false; + abortCtrlFetchCases.current.abort(); + abortCtrlFetchCases.current = new AbortController(); + dispatch({ type: 'FETCH_INIT', payload: 'cases' }); + + const response = await getCases({ + filterOptions, + queryParams, + signal: abortCtrlFetchCases.current.signal, }); - } - } catch (error) { - if (!didCancelFetchCases.current) { - if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, + + if (!didCancelFetchCases.current) { + dispatch({ + type: 'FETCH_CASES_SUCCESS', + payload: response, }); } - dispatch({ type: 'FETCH_FAILURE', payload: 'cases' }); + } catch (error) { + if (!didCancelFetchCases.current) { + if (error.name !== 'AbortError') { + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); + } + dispatch({ type: 'FETCH_FAILURE', payload: 'cases' }); + } } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, + [toasts] + ); const dispatchUpdateCaseProperty = useCallback( async ({ updateKey, updateValue, caseId, refetchCasesStatus, version }: UpdateCase) => { @@ -218,7 +227,7 @@ export const useGetCases = ( } catch (error) { if (!didCancelUpdateCases.current) { if (error.name !== 'AbortError') { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + toasts.addError(error, { title: i18n.ERROR_TITLE }); } dispatch({ type: 'FETCH_FAILURE', payload: 'caseUpdate' }); } diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases_status.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.test.tsx rename to x-pack/plugins/cases/public/containers/use_get_cases_status.test.tsx index 30714a2d8d938..f795d5cc60e71 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases_status.test.tsx @@ -11,6 +11,7 @@ import { casesStatus } from './mock'; import * as api from './api'; jest.mock('./api'); +jest.mock('../common/lib/kibana'); describe('useGetCasesStatus', () => { const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx b/x-pack/plugins/cases/public/containers/use_get_cases_status.tsx similarity index 86% rename from x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx rename to x-pack/plugins/cases/public/containers/use_get_cases_status.tsx index 087f7ef455cba..c3244bb38f151 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases_status.tsx @@ -7,10 +7,10 @@ import { useCallback, useEffect, useState, useRef } from 'react'; -import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getCasesStatus } from './api'; import * as i18n from './translations'; import { CasesStatus } from './types'; +import { useToasts } from '../common/lib/kibana'; interface CasesStatusState extends CasesStatus { isLoading: boolean; @@ -31,7 +31,7 @@ export interface UseGetCasesStatus extends CasesStatusState { export const useGetCasesStatus = (): UseGetCasesStatus => { const [casesStatusState, setCasesStatusState] = useState<CasesStatusState>(initialData); - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -57,11 +57,10 @@ export const useGetCasesStatus = (): UseGetCasesStatus => { } catch (error) { if (!isCancelledRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } setCasesStatusState({ countClosedCases: 0, diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.test.tsx b/x-pack/plugins/cases/public/containers/use_get_reporters.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.test.tsx rename to x-pack/plugins/cases/public/containers/use_get_reporters.test.tsx index ff1c5a3eb4de7..8345ddf107872 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_reporters.test.tsx @@ -11,6 +11,7 @@ import { reporters, respReporters } from './mock'; import * as api from './api'; jest.mock('./api'); +jest.mock('../common/lib/kibana'); describe('useGetReporters', () => { const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx b/x-pack/plugins/cases/public/containers/use_get_reporters.tsx similarity index 82% rename from x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx rename to x-pack/plugins/cases/public/containers/use_get_reporters.tsx index 10c2d26d6b33d..a9d28de33cb41 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_reporters.tsx @@ -8,10 +8,10 @@ import { useCallback, useEffect, useState, useRef } from 'react'; import { isEmpty } from 'lodash/fp'; -import { User } from '../../../../cases/common/api'; -import { errorToToaster, useStateToaster } from '../../common/components/toasters'; +import { User } from '../../common'; import { getReporters } from './api'; import * as i18n from './translations'; +import { useToasts } from '../common/lib/kibana'; interface ReportersState { reporters: string[]; @@ -34,7 +34,7 @@ export interface UseGetReporters extends ReportersState { export const useGetReporters = (): UseGetReporters => { const [reportersState, setReporterState] = useState<ReportersState>(initialData); - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -64,11 +64,10 @@ export const useGetReporters = (): UseGetReporters => { } catch (error) { if (!isCancelledRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } setReporterState({ @@ -79,8 +78,7 @@ export const useGetReporters = (): UseGetReporters => { }); } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportersState]); + }, [reportersState, toasts]); useEffect(() => { fetchReporters(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.test.tsx b/x-pack/plugins/cases/public/containers/use_get_tags.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/use_get_tags.test.tsx rename to x-pack/plugins/cases/public/containers/use_get_tags.test.tsx index 8042e560df350..3fecfb51b958c 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_tags.test.tsx @@ -11,6 +11,7 @@ import { tags } from './mock'; import * as api from './api'; jest.mock('./api'); +jest.mock('../common/lib/kibana'); describe('useGetTags', () => { const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx b/x-pack/plugins/cases/public/containers/use_get_tags.tsx similarity index 87% rename from x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx rename to x-pack/plugins/cases/public/containers/use_get_tags.tsx index 4a7a298e2cd86..4368b025baa38 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_tags.tsx @@ -6,7 +6,7 @@ */ import { useEffect, useReducer, useRef, useCallback } from 'react'; -import { errorToToaster, useStateToaster } from '../../common/components/toasters'; +import { useToasts } from '../common/lib/kibana'; import { getTags } from './api'; import * as i18n from './translations'; @@ -57,7 +57,7 @@ export const useGetTags = (): UseGetTags => { isError: false, tags: initialData, }); - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -76,11 +76,10 @@ export const useGetTags = (): UseGetTags => { } catch (error) { if (!isCancelledRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } dispatch({ type: 'FETCH_FAILURE' }); } diff --git a/x-pack/plugins/cases/public/containers/use_messages_storage.test.tsx b/x-pack/plugins/cases/public/containers/use_messages_storage.test.tsx new file mode 100644 index 0000000000000..73bfc49f077ae --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_messages_storage.test.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useMessagesStorage, UseMessagesStorage } from './use_messages_storage'; + +describe('useLocalStorage', () => { + beforeEach(() => { + localStorage.clear(); + }); + afterEach(() => { + localStorage.clear(); + }); + + it('should return an empty array when there is no messages', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() => + useMessagesStorage() + ); + await waitForNextUpdate(); + const { getMessages } = result.current; + expect(getMessages('case')).toEqual([]); + }); + }); + + it('should add a message', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() => + useMessagesStorage() + ); + await waitForNextUpdate(); + const { getMessages, addMessage } = result.current; + addMessage('case', 'id-1'); + expect(getMessages('case')).toEqual(['id-1']); + }); + }); + + it('should add multiple messages', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() => + useMessagesStorage() + ); + await waitForNextUpdate(); + const { getMessages, addMessage } = result.current; + addMessage('case', 'id-1'); + addMessage('case', 'id-2'); + expect(getMessages('case')).toEqual(['id-1', 'id-2']); + }); + }); + + it('should remove a message', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() => + useMessagesStorage() + ); + await waitForNextUpdate(); + const { getMessages, addMessage, removeMessage } = result.current; + addMessage('case', 'id-1'); + addMessage('case', 'id-2'); + removeMessage('case', 'id-2'); + expect(getMessages('case')).toEqual(['id-1']); + }); + }); + + it('should return presence of a message', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() => + useMessagesStorage() + ); + await waitForNextUpdate(); + const { hasMessage, addMessage, removeMessage } = result.current; + addMessage('case', 'id-1'); + addMessage('case', 'id-2'); + removeMessage('case', 'id-2'); + expect(hasMessage('case', 'id-1')).toEqual(true); + expect(hasMessage('case', 'id-2')).toEqual(false); + }); + }); + + it('should clear all messages', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() => + useMessagesStorage() + ); + await waitForNextUpdate(); + const { getMessages, addMessage, clearAllMessages } = result.current; + addMessage('case', 'id-1'); + addMessage('case', 'id-2'); + clearAllMessages('case'); + expect(getMessages('case')).toEqual([]); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_messages_storage.tsx b/x-pack/plugins/cases/public/containers/use_messages_storage.tsx new file mode 100644 index 0000000000000..c7eed3cbd881b --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_messages_storage.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useMemo } from 'react'; +import { Storage } from '../../../../../src/plugins/kibana_utils/public'; + +export interface UseMessagesStorage { + getMessages: (plugin: string) => string[]; + addMessage: (plugin: string, id: string) => void; + removeMessage: (plugin: string, id: string) => void; + clearAllMessages: (plugin: string) => void; + hasMessage: (plugin: string, id: string) => boolean; +} + +// TODO: Removed const { storage } = useKibana().services; in favor of using the util directly +export const useMessagesStorage = (): UseMessagesStorage => { + const storage = useMemo(() => new Storage(localStorage), []); + + const getMessages = useCallback( + (plugin: string): string[] => storage.get(`${plugin}-messages`) ?? [], + [storage] + ); + + const addMessage = useCallback( + (plugin: string, id: string) => { + const pluginStorage = storage.get(`${plugin}-messages`) ?? []; + storage.set(`${plugin}-messages`, [...pluginStorage, id]); + }, + [storage] + ); + + const hasMessage = useCallback( + (plugin: string, id: string): boolean => { + const pluginStorage = storage.get(`${plugin}-messages`) ?? []; + return pluginStorage.filter((val: string) => val === id).length > 0; + }, + [storage] + ); + + const removeMessage = useCallback( + (plugin: string, id: string) => { + const pluginStorage = storage.get(`${plugin}-messages`) ?? []; + storage.set(`${plugin}-messages`, [...pluginStorage.filter((val: string) => val !== id)]); + }, + [storage] + ); + + const clearAllMessages = useCallback( + (plugin: string): string[] => storage.remove(`${plugin}-messages`), + [storage] + ); + + return { + getMessages, + addMessage, + clearAllMessages, + removeMessage, + hasMessage, + }; +}; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx b/x-pack/plugins/cases/public/containers/use_post_case.test.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx rename to x-pack/plugins/cases/public/containers/use_post_case.test.tsx index 3731af4d73db5..f7f7f1419c713 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_post_case.test.tsx @@ -8,10 +8,11 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { usePostCase, UsePostCase } from './use_post_case'; import * as api from './api'; -import { ConnectorTypes } from '../../../../cases/common/api/connectors'; +import { ConnectorTypes } from '../../common'; import { basicCasePost } from './mock'; jest.mock('./api'); +jest.mock('../common/lib/kibana'); describe('usePostCase', () => { const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx b/x-pack/plugins/cases/public/containers/use_post_case.tsx similarity index 85% rename from x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx rename to x-pack/plugins/cases/public/containers/use_post_case.tsx index 35c2b66156456..f3c92fc1ab336 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx +++ b/x-pack/plugins/cases/public/containers/use_post_case.tsx @@ -6,11 +6,11 @@ */ import { useReducer, useCallback, useRef, useEffect } from 'react'; -import { CasePostRequest } from '../../../../cases/common/api'; -import { errorToToaster, useStateToaster } from '../../common/components/toasters'; +import { CasePostRequest } from '../../common'; import { postCase } from './api'; import * as i18n from './translations'; import { Case } from './types'; +import { useToasts } from '../common/lib/kibana'; interface NewCaseState { isLoading: boolean; isError: boolean; @@ -49,7 +49,7 @@ export const usePostCase = (): UsePostCase => { isLoading: false, isError: false, }); - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -69,11 +69,10 @@ export const usePostCase = (): UsePostCase => { } catch (error) { if (!isCancelledRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } dispatch({ type: 'FETCH_FAILURE' }); } diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx b/x-pack/plugins/cases/public/containers/use_post_comment.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx rename to x-pack/plugins/cases/public/containers/use_post_comment.test.tsx index 4d4ac5d071fa5..5b927f55c9e91 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_post_comment.test.tsx @@ -7,12 +7,13 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { CommentType } from '../../../../cases/common/api'; +import { CommentType } from '../../common'; import { usePostComment, UsePostComment } from './use_post_comment'; import { basicCaseId, basicSubCaseId } from './mock'; import * as api from './api'; jest.mock('./api'); +jest.mock('../common/lib/kibana'); describe('usePostComment', () => { const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx b/x-pack/plugins/cases/public/containers/use_post_comment.tsx similarity index 85% rename from x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx rename to x-pack/plugins/cases/public/containers/use_post_comment.tsx index 252059514da8e..15cf398a2fdb2 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx +++ b/x-pack/plugins/cases/public/containers/use_post_comment.tsx @@ -6,12 +6,12 @@ */ import { useReducer, useCallback, useRef, useEffect } from 'react'; -import { CommentRequest } from '../../../../cases/common/api'; -import { errorToToaster, useStateToaster } from '../../common/components/toasters'; +import { CommentRequest } from '../../common'; import { postComment } from './api'; import * as i18n from './translations'; import { Case } from './types'; +import { useToasts } from '../common/lib/kibana'; interface NewCommentState { isLoading: boolean; @@ -56,7 +56,7 @@ export const usePostComment = (): UsePostComment => { isLoading: false, isError: false, }); - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -79,17 +79,16 @@ export const usePostComment = (): UsePostComment => { } catch (error) { if (!isCancelledRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } dispatch({ type: 'FETCH_FAILURE' }); } } }, - [dispatchToaster] + [toasts] ); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx b/x-pack/plugins/cases/public/containers/use_post_push_to_service.test.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx rename to x-pack/plugins/cases/public/containers/use_post_push_to_service.test.tsx index e008927019987..18e3c4be493b8 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_post_push_to_service.test.tsx @@ -9,9 +9,10 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { usePostPushToService, UsePostPushToService } from './use_post_push_to_service'; import { pushedCase } from './mock'; import * as api from './api'; -import { CaseConnector, ConnectorTypes } from '../../../../cases/common/api'; +import { CaseConnector, ConnectorTypes } from '../../common'; jest.mock('./api'); +jest.mock('../common/lib/kibana'); describe('usePostPushToService', () => { const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx b/x-pack/plugins/cases/public/containers/use_post_push_to_service.tsx similarity index 81% rename from x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx rename to x-pack/plugins/cases/public/containers/use_post_push_to_service.tsx index 9fd0fda5c9723..bee89e21b4283 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx +++ b/x-pack/plugins/cases/public/containers/use_post_push_to_service.tsx @@ -6,16 +6,12 @@ */ import { useReducer, useCallback, useRef, useEffect } from 'react'; -import { CaseConnector } from '../../../../cases/common/api'; -import { - errorToToaster, - useStateToaster, - displaySuccessToast, -} from '../../common/components/toasters'; +import { CaseConnector } from '../../common'; import { pushCase } from './api'; import * as i18n from './translations'; import { Case } from './types'; +import { useToasts } from '../common/lib/kibana'; interface PushToServiceState { isLoading: boolean; @@ -65,7 +61,7 @@ export const usePostPushToService = (): UsePostPushToService => { isLoading: false, isError: false, }); - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const cancel = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -81,21 +77,17 @@ export const usePostPushToService = (): UsePostPushToService => { if (!cancel.current) { dispatch({ type: 'FETCH_SUCCESS' }); - displaySuccessToast( - i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE(connector.name), - dispatchToaster - ); + toasts.addSuccess(i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE(connector.name)); } return response; } catch (error) { if (!cancel.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } dispatch({ type: 'FETCH_FAILURE' }); } diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.test.tsx b/x-pack/plugins/cases/public/containers/use_update_case.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/cases/containers/use_update_case.test.tsx rename to x-pack/plugins/cases/public/containers/use_update_case.test.tsx index 65309d6d29e05..666e8df0c2413 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_update_case.test.tsx @@ -12,6 +12,7 @@ import * as api from './api'; import { UpdateKey } from './types'; jest.mock('./api'); +jest.mock('../common/lib/kibana'); describe('useUpdateCase', () => { const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx b/x-pack/plugins/cases/public/containers/use_update_case.tsx similarity index 86% rename from x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx rename to x-pack/plugins/cases/public/containers/use_update_case.tsx index 9a79699d8f919..b6ea580cf542a 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx +++ b/x-pack/plugins/cases/public/containers/use_update_case.tsx @@ -7,9 +7,9 @@ import { useReducer, useCallback, useRef, useEffect } from 'react'; -import { errorToToaster, useStateToaster } from '../../common/components/toasters'; +import { useToasts } from '../common/lib/kibana'; import { patchCase, patchSubCase } from './api'; -import { UpdateKey, UpdateByKey, CaseStatuses } from './types'; +import { UpdateKey, UpdateByKey, CaseStatuses } from '../../common'; import * as i18n from './translations'; import { createUpdateSuccessToaster } from './utils'; @@ -68,7 +68,7 @@ export const useUpdateCase = ({ isError: false, updateKey: null, }); - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -111,10 +111,9 @@ export const useUpdateCase = ({ updateCase(response[0]); } dispatch({ type: 'FETCH_SUCCESS' }); - dispatchToaster({ - type: 'addToaster', - toast: createUpdateSuccessToaster(caseData, response[0], updateKey, updateValue), - }); + toasts.addSuccess( + createUpdateSuccessToaster(caseData, response[0], updateKey, updateValue) + ); if (onSuccess) { onSuccess(); @@ -123,11 +122,10 @@ export const useUpdateCase = ({ } catch (error) { if (!isCancelledRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } dispatch({ type: 'FETCH_FAILURE' }); if (onError) { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.test.tsx b/x-pack/plugins/cases/public/containers/use_update_comment.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/cases/containers/use_update_comment.test.tsx rename to x-pack/plugins/cases/public/containers/use_update_comment.test.tsx index 9ff266ad9c988..b936eb126f0d4 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_update_comment.test.tsx @@ -11,6 +11,7 @@ import { basicCase, basicCaseCommentPatch, basicSubCaseId } from './mock'; import * as api from './api'; jest.mock('./api'); +jest.mock('../common/lib/kibana'); describe('useUpdateComment', () => { const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx b/x-pack/plugins/cases/public/containers/use_update_comment.tsx similarity index 90% rename from x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx rename to x-pack/plugins/cases/public/containers/use_update_comment.tsx index 81bce248852fe..512b5b50a22b9 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx +++ b/x-pack/plugins/cases/public/containers/use_update_comment.tsx @@ -6,7 +6,7 @@ */ import { useReducer, useCallback, useRef, useEffect } from 'react'; -import { errorToToaster, useStateToaster } from '../../common/components/toasters'; +import { useToasts } from '../common/lib/kibana'; import { patchComment } from './api'; import * as i18n from './translations'; import { Case } from './types'; @@ -69,7 +69,7 @@ export const useUpdateComment = (): UseUpdateComment => { isLoadingIds: [], isError: false, }); - const [, dispatchToaster] = useStateToaster(); + const toasts = useToasts(); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -106,11 +106,10 @@ export const useUpdateComment = (): UseUpdateComment => { } catch (error) { if (!isCancelledRef.current) { if (error.name !== 'AbortError') { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } dispatch({ type: 'FETCH_FAILURE', payload: commentId }); } diff --git a/x-pack/plugins/security_solution/public/cases/containers/utils.test.ts b/x-pack/plugins/cases/public/containers/utils.test.ts similarity index 77% rename from x-pack/plugins/security_solution/public/cases/containers/utils.test.ts rename to x-pack/plugins/cases/public/containers/utils.test.ts index 6c1fb60298938..3ee6182cb053d 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/utils.test.ts +++ b/x-pack/plugins/cases/public/containers/utils.test.ts @@ -50,25 +50,18 @@ describe('utils', () => { describe('createUpdateSuccessToaster', () => { it('creates the correct toast when sync alerts is turned on and case has alerts', () => { // We remove the id as is randomly generated - const { id, ...toast } = createUpdateSuccessToaster( - caseBeforeUpdate, - caseAfterUpdate, - 'settings', - { - syncAlerts: true, - } - ); + const toast = createUpdateSuccessToaster(caseBeforeUpdate, caseAfterUpdate, 'settings', { + syncAlerts: true, + }); expect(toast).toEqual({ - color: 'success', - iconType: 'check', title: 'Alerts in "My case" have been synced', }); }); it('creates the correct toast when sync alerts is turned on and case does NOT have alerts', () => { // We remove the id as is randomly generated - const { id, ...toast } = createUpdateSuccessToaster( + const toast = createUpdateSuccessToaster( { ...caseBeforeUpdate, comments: [] }, caseAfterUpdate, 'settings', @@ -78,33 +71,24 @@ describe('utils', () => { ); expect(toast).toEqual({ - color: 'success', - iconType: 'check', title: 'Updated "My case"', }); }); it('creates the correct toast when sync alerts is turned off and case has alerts', () => { // We remove the id as is randomly generated - const { id, ...toast } = createUpdateSuccessToaster( - caseBeforeUpdate, - caseAfterUpdate, - 'settings', - { - syncAlerts: false, - } - ); + const toast = createUpdateSuccessToaster(caseBeforeUpdate, caseAfterUpdate, 'settings', { + syncAlerts: false, + }); expect(toast).toEqual({ - color: 'success', - iconType: 'check', title: 'Updated "My case"', }); }); it('creates the correct toast when the status change, case has alerts, and sync alerts is on', () => { // We remove the id as is randomly generated - const { id, ...toast } = createUpdateSuccessToaster( + const toast = createUpdateSuccessToaster( caseBeforeUpdate, caseAfterUpdate, 'status', @@ -112,8 +96,6 @@ describe('utils', () => { ); expect(toast).toEqual({ - color: 'success', - iconType: 'check', title: 'Updated "My case"', text: 'Alerts in this case have been also had their status updated', }); @@ -121,7 +103,7 @@ describe('utils', () => { it('creates the correct toast when the status change, case has alerts, and sync alerts is off', () => { // We remove the id as is randomly generated - const { id, ...toast } = createUpdateSuccessToaster( + const toast = createUpdateSuccessToaster( { ...caseBeforeUpdate, settings: { syncAlerts: false } }, caseAfterUpdate, 'status', @@ -129,15 +111,13 @@ describe('utils', () => { ); expect(toast).toEqual({ - color: 'success', - iconType: 'check', title: 'Updated "My case"', }); }); it('creates the correct toast when the status change, case does NOT have alerts, and sync alerts is on', () => { // We remove the id as is randomly generated - const { id, ...toast } = createUpdateSuccessToaster( + const toast = createUpdateSuccessToaster( { ...caseBeforeUpdate, comments: [] }, caseAfterUpdate, 'status', @@ -145,15 +125,13 @@ describe('utils', () => { ); expect(toast).toEqual({ - color: 'success', - iconType: 'check', title: 'Updated "My case"', }); }); it('creates the correct toast if not a status or a setting', () => { // We remove the id as is randomly generated - const { id, ...toast } = createUpdateSuccessToaster( + const toast = createUpdateSuccessToaster( caseBeforeUpdate, caseAfterUpdate, 'title', @@ -161,8 +139,6 @@ describe('utils', () => { ); expect(toast).toEqual({ - color: 'success', - iconType: 'check', title: 'Updated "My case"', }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/utils.ts b/x-pack/plugins/cases/public/containers/utils.ts similarity index 92% rename from x-pack/plugins/security_solution/public/cases/containers/utils.ts rename to x-pack/plugins/cases/public/containers/utils.ts index 7c33e4481b2aa..5ef30aa800f90 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/utils.ts +++ b/x-pack/plugins/cases/public/containers/utils.ts @@ -5,13 +5,13 @@ * 2.0. */ -import uuid from 'uuid'; import { set } from '@elastic/safer-lodash-set'; import { camelCase, isArray, isObject } from 'lodash'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; +import { ToastInputFields } from 'kibana/public'; import { CasesFindResponse, CasesFindResponseRt, @@ -28,8 +28,7 @@ import { CaseUserActionsResponseRt, CommentType, CasePatchRequest, -} from '../../../../cases/common/api'; -import { AppToast, ToasterError } from '../../common/components/toasters'; +} from '../../common'; import { AllCases, Case, UpdateByKey } from './types'; import * as i18n from './translations'; @@ -115,20 +114,26 @@ export const valueToUpdateIsStatus = ( value: UpdateByKey['updateValue'] ): value is CasePatchRequest['status'] => key === 'status'; +export class ToasterError extends Error { + public readonly messages: string[]; + + constructor(messages: string[]) { + super(messages[0]); + this.name = 'ToasterError'; + this.messages = messages; + } +} export const createUpdateSuccessToaster = ( caseBeforeUpdate: Case, caseAfterUpdate: Case, key: UpdateByKey['updateKey'], value: UpdateByKey['updateValue'] -): AppToast => { +): ToastInputFields => { const caseHasAlerts = caseBeforeUpdate.comments.some( (comment) => comment.type === CommentType.alert ); - const toast: AppToast = { - id: uuid.v4(), - color: 'success', - iconType: 'check', + const toast: ToastInputFields = { title: i18n.UPDATED_CASE(caseAfterUpdate.title), }; diff --git a/x-pack/plugins/cases/public/index.tsx b/x-pack/plugins/cases/public/index.tsx new file mode 100644 index 0000000000000..e8589152b7ca8 --- /dev/null +++ b/x-pack/plugins/cases/public/index.tsx @@ -0,0 +1,17 @@ +/* + * 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 { PluginInitializerContext } from 'kibana/public'; +import { CasesUiPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new CasesUiPlugin(initializerContext); +} + +export { CasesUiPlugin }; +export * from './plugin'; +export * from './types'; diff --git a/x-pack/plugins/cases/public/methods/get_all_cases.tsx b/x-pack/plugins/cases/public/methods/get_all_cases.tsx new file mode 100644 index 0000000000000..d3e7a924788f3 --- /dev/null +++ b/x-pack/plugins/cases/public/methods/get_all_cases.tsx @@ -0,0 +1,17 @@ +/* + * 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 { EuiLoadingSpinner } from '@elastic/eui'; +import React, { lazy, Suspense } from 'react'; +import { AllCasesProps } from '../components/all_cases'; + +const AllCasesLazy = lazy(() => import('../components/all_cases')); +export const getAllCasesLazy = (props: AllCasesProps) => ( + <Suspense fallback={<EuiLoadingSpinner />}> + <AllCasesLazy {...props} /> + </Suspense> +); diff --git a/x-pack/plugins/cases/public/methods/get_all_cases_selector_modal.tsx b/x-pack/plugins/cases/public/methods/get_all_cases_selector_modal.tsx new file mode 100644 index 0000000000000..b6caae39c284a --- /dev/null +++ b/x-pack/plugins/cases/public/methods/get_all_cases_selector_modal.tsx @@ -0,0 +1,17 @@ +/* + * 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, { lazy, Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { AllCasesSelectorModalProps } from '../components/all_cases/selector_modal'; + +const AllCasesSelectorModalLazy = lazy(() => import('../components/all_cases/selector_modal')); +export const getAllCasesSelectorModalLazy = (props: AllCasesSelectorModalProps) => ( + <Suspense fallback={<EuiLoadingSpinner />}> + <AllCasesSelectorModalLazy {...props} /> + </Suspense> +); diff --git a/x-pack/plugins/cases/public/methods/get_case_view.tsx b/x-pack/plugins/cases/public/methods/get_case_view.tsx new file mode 100644 index 0000000000000..00fe2438a1a7d --- /dev/null +++ b/x-pack/plugins/cases/public/methods/get_case_view.tsx @@ -0,0 +1,17 @@ +/* + * 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, { lazy, Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { CaseViewProps } from '../components/case_view'; + +const CaseViewLazy = lazy(() => import('../components/case_view')); +export const getCaseViewLazy = (props: CaseViewProps) => ( + <Suspense fallback={<EuiLoadingSpinner />}> + <CaseViewLazy {...props} /> + </Suspense> +); diff --git a/x-pack/plugins/cases/public/methods/get_configure_cases.tsx b/x-pack/plugins/cases/public/methods/get_configure_cases.tsx new file mode 100644 index 0000000000000..96a3dbd55d7de --- /dev/null +++ b/x-pack/plugins/cases/public/methods/get_configure_cases.tsx @@ -0,0 +1,17 @@ +/* + * 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 { EuiLoadingSpinner } from '@elastic/eui'; +import React, { lazy, Suspense } from 'react'; +import { ConfigureCasesProps } from '../components/configure_cases'; + +const ConfigureCasesLazy = lazy(() => import('../components/configure_cases')); +export const getConfigureCasesLazy = (props: ConfigureCasesProps) => ( + <Suspense fallback={<EuiLoadingSpinner />}> + <ConfigureCasesLazy {...props} /> + </Suspense> +); diff --git a/x-pack/plugins/cases/public/methods/get_create_case.tsx b/x-pack/plugins/cases/public/methods/get_create_case.tsx new file mode 100644 index 0000000000000..b030ed669b663 --- /dev/null +++ b/x-pack/plugins/cases/public/methods/get_create_case.tsx @@ -0,0 +1,17 @@ +/* + * 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, { lazy, Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { CreateCaseProps } from '../components/create'; + +const CreateCaseLazy = lazy(() => import('../components/create')); +export const getCreateCaseLazy = (props: CreateCaseProps) => ( + <Suspense fallback={<EuiLoadingSpinner />}> + <CreateCaseLazy {...props} /> + </Suspense> +); diff --git a/x-pack/plugins/cases/public/methods/get_recent_cases.tsx b/x-pack/plugins/cases/public/methods/get_recent_cases.tsx new file mode 100644 index 0000000000000..e87db9320ca3d --- /dev/null +++ b/x-pack/plugins/cases/public/methods/get_recent_cases.tsx @@ -0,0 +1,17 @@ +/* + * 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 { EuiLoadingSpinner } from '@elastic/eui'; +import React, { lazy, Suspense } from 'react'; +import { RecentCasesProps } from '../components/recent_cases'; + +const RecentCasesLazy = lazy(() => import('../components/recent_cases')); +export const getRecentCasesLazy = (props: RecentCasesProps) => ( + <Suspense fallback={<EuiLoadingSpinner />}> + <RecentCasesLazy {...props} /> + </Suspense> +); diff --git a/x-pack/plugins/cases/public/methods/index.ts b/x-pack/plugins/cases/public/methods/index.ts new file mode 100644 index 0000000000000..1d91e7c4df6d2 --- /dev/null +++ b/x-pack/plugins/cases/public/methods/index.ts @@ -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 * from './get_all_cases'; +export * from './get_create_case'; +export * from './get_case_view'; +export * from './get_configure_cases'; +export * from './get_recent_cases'; +export * from './get_all_cases_selector_modal'; diff --git a/x-pack/plugins/cases/public/plugin.ts b/x-pack/plugins/cases/public/plugin.ts new file mode 100644 index 0000000000000..8c9105961c130 --- /dev/null +++ b/x-pack/plugins/cases/public/plugin.ts @@ -0,0 +1,93 @@ +/* + * 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 { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; +import { CasesUiStart, SetupPlugins, StartPlugins } from './types'; +import { KibanaServices } from './common/lib/kibana'; +import { getCaseConnectorUi } from './components/connectors'; +import { + getAllCasesLazy, + getCaseViewLazy, + getConfigureCasesLazy, + getCreateCaseLazy, + getRecentCasesLazy, + getAllCasesSelectorModalLazy, +} from './methods'; +import { ENABLE_CASE_CONNECTOR } from '../common'; + +/** + * @public + * A plugin for retrieving Cases UI components + */ +export class CasesUiPlugin implements Plugin<void, CasesUiStart, SetupPlugins, StartPlugins> { + private kibanaVersion: string; + + constructor(initializerContext: PluginInitializerContext) { + this.kibanaVersion = initializerContext.env.packageInfo.version; + } + public setup(core: CoreSetup, plugins: SetupPlugins) { + if (ENABLE_CASE_CONNECTOR) { + plugins.triggersActionsUi.actionTypeRegistry.register(getCaseConnectorUi()); + } + } + + public start(core: CoreStart, plugins: StartPlugins): CasesUiStart { + KibanaServices.init({ ...core, ...plugins, kibanaVersion: this.kibanaVersion }); + return { + /** + * Get the all cases table + * @param props AllCasesProps + * @return {ReactElement<AllCasesProps>} + */ + getAllCases: (props) => { + return getAllCasesLazy(props); + }, + /** + * Get the case view component + * @param props CaseViewProps + * @return {ReactElement<CaseViewProps>} + */ + getCaseView: (props) => { + return getCaseViewLazy(props); + }, + /** + * Get the configure case component + * @param props ConfigureCasesProps + * @return {ReactElement<ConfigureCasesProps>} + */ + getConfigureCases: (props) => { + return getConfigureCasesLazy(props); + }, + /** + * Get the create case form + * @param props CreateCaseProps + * @return {ReactElement<CreateCaseProps>} + */ + getCreateCase: (props) => { + return getCreateCaseLazy(props); + }, + /** + * Get the recent cases component + * @param props RecentCasesProps + * @return {ReactElement<RecentCasesProps>} + */ + getRecentCases: (props) => { + return getRecentCasesLazy(props); + }, + /** + * use Modal hook for all cases selector + * @param props UseAllCasesSelectorModalProps + * @return UseAllCasesSelectorModalReturnedValues + */ + getAllCasesSelectorModal: (props) => { + return getAllCasesSelectorModalLazy(props); + }, + }; + } + + public stop() {} +} diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts new file mode 100644 index 0000000000000..269d1773b3404 --- /dev/null +++ b/x-pack/plugins/cases/public/types.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreStart } from 'kibana/public'; +import { ReactElement } from 'react'; +import { SecurityPluginSetup } from '../../security/public'; +import { + TriggersAndActionsUIPublicPluginSetup as TriggersActionsSetup, + TriggersAndActionsUIPublicPluginStart as TriggersActionsStart, +} from '../../triggers_actions_ui/public'; +import { AllCasesProps } from './components/all_cases'; +import { CaseViewProps } from './components/case_view'; +import { ConfigureCasesProps } from './components/configure_cases'; +import { CreateCaseProps } from './components/create'; +import { RecentCasesProps } from './components/recent_cases'; +import { AllCasesSelectorModalProps } from './components/all_cases/selector_modal'; + +export interface SetupPlugins { + security: SecurityPluginSetup; + triggersActionsUi: TriggersActionsSetup; +} + +export interface StartPlugins { + triggersActionsUi: TriggersActionsStart; +} + +/** + * TODO: The extra security service is one that should be implemented in the kibana context of the consuming application. + * Security is needed for access to authc for the `useCurrentUser` hook. Security_Solution currently passes it via renderApp in public/plugin.tsx + * Leaving it out currently in lieu of RBAC changes + */ + +export type StartServices = CoreStart & + StartPlugins & { + security: SecurityPluginSetup; + }; + +export interface CasesUiStart { + getAllCases: (props: AllCasesProps) => ReactElement<AllCasesProps>; + getAllCasesSelectorModal: ( + props: AllCasesSelectorModalProps + ) => ReactElement<AllCasesSelectorModalProps>; + getCaseView: (props: CaseViewProps) => ReactElement<CaseViewProps>; + getConfigureCases: (props: ConfigureCasesProps) => ReactElement<ConfigureCasesProps>; + getCreateCase: (props: CreateCaseProps) => ReactElement<CreateCaseProps>; + getRecentCases: (props: RecentCasesProps) => ReactElement<RecentCasesProps>; +} diff --git a/x-pack/plugins/cases/public/utils/use_mount_appended.ts b/x-pack/plugins/cases/public/utils/use_mount_appended.ts new file mode 100644 index 0000000000000..d43b0455f47da --- /dev/null +++ b/x-pack/plugins/cases/public/utils/use_mount_appended.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import { mount } from 'enzyme'; + +type WrapperOf<F extends (...args: any) => any> = (...args: Parameters<F>) => ReturnType<F>; +export type MountAppended = WrapperOf<typeof mount>; + +export const useMountAppended = () => { + let root: HTMLElement; + + beforeEach(() => { + root = document.createElement('div'); + root.id = 'root'; + document.body.appendChild(root); + }); + + afterEach(() => { + document.body.removeChild(root); + }); + + const mountAppended: MountAppended = (node, options) => + mount(node, { ...options, attachTo: root }); + + return mountAppended; +}; diff --git a/x-pack/plugins/cases/server/client/alerts/update_status.test.ts b/x-pack/plugins/cases/server/client/alerts/update_status.test.ts index 5dfe6060da1db..d6456cb3183ef 100644 --- a/x-pack/plugins/cases/server/client/alerts/update_status.test.ts +++ b/x-pack/plugins/cases/server/client/alerts/update_status.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CaseStatuses } from '../../../common/api'; +import { CaseStatuses } from '../../../common'; import { createMockSavedObjectsRepository } from '../../routes/api/__fixtures__'; import { createCasesClientWithMockSavedObjectsClient } from '../mocks'; diff --git a/x-pack/plugins/cases/server/client/cases/create.test.ts b/x-pack/plugins/cases/server/client/cases/create.test.ts index fe301dcca37ac..9cbe2a448d3b4 100644 --- a/x-pack/plugins/cases/server/client/cases/create.test.ts +++ b/x-pack/plugins/cases/server/client/cases/create.test.ts @@ -5,12 +5,7 @@ * 2.0. */ -import { - ConnectorTypes, - CaseStatuses, - CaseType, - CasesClientPostRequest, -} from '../../../common/api'; +import { ConnectorTypes, CaseStatuses, CaseType, CasesClientPostRequest } from '../../../common'; import { isCaseError } from '../../common/error'; import { diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index 650b9aa81c990..fae60743073c1 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -22,7 +22,7 @@ import { CasePostRequest, CaseType, User, -} from '../../../common/api'; +} from '../../../common'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { getConnectorFromConfiguration, diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index 50725879278e4..08fa96a3bbe6f 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -7,7 +7,7 @@ import { SavedObjectsClientContract, Logger, SavedObject } from 'kibana/server'; import { flattenCaseSavedObject } from '../../routes/api/utils'; -import { CaseResponseRt, CaseResponse, ESCaseAttributes } from '../../../common/api'; +import { CaseResponseRt, CaseResponse, ESCaseAttributes } from '../../../common'; import { CaseServiceSetup } from '../../services'; import { countAlertsForID } from '../../common'; import { createCaseError } from '../../common/error'; diff --git a/x-pack/plugins/cases/server/client/cases/mock.ts b/x-pack/plugins/cases/server/client/cases/mock.ts index 490519187f49e..0e589b901c8d1 100644 --- a/x-pack/plugins/cases/server/client/cases/mock.ts +++ b/x-pack/plugins/cases/server/client/cases/mock.ts @@ -12,7 +12,7 @@ import { CaseUserActionsResponse, AssociationType, CommentResponseAlertsType, -} from '../../../common/api'; +} from '../../../common'; import { BasicParams } from './types'; diff --git a/x-pack/plugins/cases/server/client/cases/push.ts b/x-pack/plugins/cases/server/client/cases/push.ts index 216ef109534fb..92a9d2910d4a3 100644 --- a/x-pack/plugins/cases/server/client/cases/push.ts +++ b/x-pack/plugins/cases/server/client/cases/push.ts @@ -29,7 +29,7 @@ import { User, ESCasesConfigureAttributes, CaseType, -} from '../../../common/api'; +} from '../../../common'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { createIncident, getCommentContextFromAttributes } from './utils'; diff --git a/x-pack/plugins/cases/server/client/cases/types.ts b/x-pack/plugins/cases/server/client/cases/types.ts index f1d56e7132bd1..fb400675136ef 100644 --- a/x-pack/plugins/cases/server/client/cases/types.ts +++ b/x-pack/plugins/cases/server/client/cases/types.ts @@ -19,7 +19,7 @@ import { PushToServiceApiParamsSIR as ServiceNowSIRPushToServiceApiParams, ServiceNowITSMIncident, } from '../../../../actions/server/builtin_action_types/servicenow/types'; -import { CaseResponse, ConnectorMappingsAttributes } from '../../../common/api'; +import { CaseResponse, ConnectorMappingsAttributes } from '../../../common'; export type Incident = JiraIncident | ResilientIncident | ServiceNowITSMIncident; export type PushToServiceApiParams = diff --git a/x-pack/plugins/cases/server/client/cases/update.test.ts b/x-pack/plugins/cases/server/client/cases/update.test.ts index 79c3b2838c3b2..18b4e8d9d7b66 100644 --- a/x-pack/plugins/cases/server/client/cases/update.test.ts +++ b/x-pack/plugins/cases/server/client/cases/update.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConnectorTypes, CasesPatchRequest, CaseStatuses } from '../../../common/api'; +import { ConnectorTypes, CasesPatchRequest, CaseStatuses } from '../../../common'; import { isCaseError } from '../../common/error'; import { createMockSavedObjectsRepository, diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index b39bfe6ec4eb7..b9926ff6cbb14 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -38,7 +38,7 @@ import { AssociationType, CommentAttributes, User, -} from '../../../common/api'; +} from '../../../common'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { getCaseToUpdate, diff --git a/x-pack/plugins/cases/server/client/cases/utils.test.ts b/x-pack/plugins/cases/server/client/cases/utils.test.ts index 859114a5e8fb0..c24812048376e 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.test.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.test.ts @@ -539,7 +539,7 @@ describe('utils', () => { commentId: 'comment-user-1', }, { - comment: 'Elastic Security Alerts attached to the case: 3', + comment: 'Elastic Alerts attached to the case: 3', commentId: 'mock-id-1-total-alerts', }, ]); @@ -569,7 +569,7 @@ describe('utils', () => { commentId: 'comment-user-1', }, { - comment: 'Elastic Security Alerts attached to the case: 4', + comment: 'Elastic Alerts attached to the case: 4', commentId: 'mock-id-1-total-alerts', }, ]); diff --git a/x-pack/plugins/cases/server/client/cases/utils.ts b/x-pack/plugins/cases/server/client/cases/utils.ts index 7e77bf4ac84cc..9bfad7ddcec3c 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.ts @@ -9,26 +9,26 @@ import { i18n } from '@kbn/i18n'; import { flow } from 'lodash'; import { ActionConnector, - CaseResponse, CaseFullExternalService, + CaseResponse, CaseUserActionsResponse, + CommentAttributes, + CommentRequestAlertType, + CommentRequestUserType, CommentResponse, CommentResponseAlertsType, CommentType, ConnectorMappingsAttributes, ConnectorTypes, - CommentAttributes, - CommentRequestUserType, - CommentRequestAlertType, -} from '../../../common/api'; +} from '../../../common'; import { ActionsClient } from '../../../../actions/server'; import { externalServiceFormatters, FormatterConnectorTypes } from '../../connectors'; import { CasesClientGetAlertsResponse } from '../../client/alerts/types'; import { BasicParams, EntityInformation, - ExternalServiceParams, ExternalServiceComment, + ExternalServiceParams, Incident, MapIncident, PipedField, @@ -184,7 +184,7 @@ export const createIncident = async ({ if (totalAlerts > 0) { comments.push({ - comment: `Elastic Security Alerts attached to the case: ${totalAlerts}`, + comment: `Elastic Alerts attached to the case: ${totalAlerts}`, commentId: `${theCase.id}-total-alerts`, }); } diff --git a/x-pack/plugins/cases/server/client/client.ts b/x-pack/plugins/cases/server/client/client.ts index 8f9058654d6fd..3bd25b6b61bc5 100644 --- a/x-pack/plugins/cases/server/client/client.ts +++ b/x-pack/plugins/cases/server/client/client.ts @@ -31,7 +31,7 @@ import { CaseUserActionServiceSetup, AlertServiceContract, } from '../services'; -import { CasesPatchRequest, CasePostRequest, User } from '../../common/api'; +import { CasesPatchRequest, CasePostRequest, User } from '../../common'; import { get } from './cases/get'; import { get as getUserActions } from './user_actions/get'; import { get as getAlerts } from './alerts/get'; diff --git a/x-pack/plugins/cases/server/client/comments/add.test.ts b/x-pack/plugins/cases/server/client/comments/add.test.ts index 23b7bc37dc814..bd04e0ea6ef14 100644 --- a/x-pack/plugins/cases/server/client/comments/add.test.ts +++ b/x-pack/plugins/cases/server/client/comments/add.test.ts @@ -6,7 +6,7 @@ */ import { omit } from 'lodash/fp'; -import { CommentType } from '../../../common/api'; +import { CommentType } from '../../../common'; import { isCaseError } from '../../common/error'; import { createMockSavedObjectsRepository, diff --git a/x-pack/plugins/cases/server/client/comments/add.ts b/x-pack/plugins/cases/server/client/comments/add.ts index 5a119432b3ccb..376e0e2c8868e 100644 --- a/x-pack/plugins/cases/server/client/comments/add.ts +++ b/x-pack/plugins/cases/server/client/comments/add.ts @@ -25,7 +25,7 @@ import { User, CommentRequestAlertType, AlertCommentRequestRt, -} from '../../../common/api'; +} from '../../../common'; import { buildCaseUserActionItem, buildCommentUserActionItem, @@ -36,10 +36,7 @@ import { CommentableCase, createAlertUpdateRequest } from '../../common'; import { CasesClientHandler } from '..'; import { createCaseError } from '../../common/error'; import { CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; -import { - ENABLE_CASE_CONNECTOR, - MAX_GENERATED_ALERTS_PER_SUB_CASE, -} from '../../../common/constants'; +import { ENABLE_CASE_CONNECTOR, MAX_GENERATED_ALERTS_PER_SUB_CASE } from '../../../common'; async function getSubCase({ caseService, diff --git a/x-pack/plugins/cases/server/client/configure/get_fields.test.ts b/x-pack/plugins/cases/server/client/configure/get_fields.test.ts index 2e2973516d0fd..c474361293da4 100644 --- a/x-pack/plugins/cases/server/client/configure/get_fields.test.ts +++ b/x-pack/plugins/cases/server/client/configure/get_fields.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConnectorTypes } from '../../../common/api'; +import { ConnectorTypes } from '../../../common'; import { createMockSavedObjectsRepository, mockCaseMappings } from '../../routes/api/__fixtures__'; import { createCasesClientWithMockSavedObjectsClient } from '../mocks'; diff --git a/x-pack/plugins/cases/server/client/configure/get_fields.ts b/x-pack/plugins/cases/server/client/configure/get_fields.ts index deabae33810b2..8d899f0df1a76 100644 --- a/x-pack/plugins/cases/server/client/configure/get_fields.ts +++ b/x-pack/plugins/cases/server/client/configure/get_fields.ts @@ -7,7 +7,7 @@ import Boom from '@hapi/boom'; -import { GetFieldsResponse } from '../../../common/api'; +import { GetFieldsResponse } from '../../../common'; import { ConfigureFields } from '../types'; import { createDefaultMapping, formatFields } from './utils'; diff --git a/x-pack/plugins/cases/server/client/configure/get_mappings.test.ts b/x-pack/plugins/cases/server/client/configure/get_mappings.test.ts index 0ec2fc8b4621d..8f75e60260873 100644 --- a/x-pack/plugins/cases/server/client/configure/get_mappings.test.ts +++ b/x-pack/plugins/cases/server/client/configure/get_mappings.test.ts @@ -5,9 +5,13 @@ * 2.0. */ -import { ConnectorTypes } from '../../../common/api'; +import { ConnectorTypes } from '../../../common'; -import { createMockSavedObjectsRepository, mockCaseMappings } from '../../routes/api/__fixtures__'; +import { + createMockSavedObjectsRepository, + mockCaseMappingsResilient, + mockCaseMappingsBad, +} from '../../routes/api/__fixtures__'; import { createCasesClientWithMockSavedObjectsClient } from '../mocks'; import { actionsClientMock } from '../../../../actions/server/actions_client.mock'; import { mappings, mockGetFieldsResponse } from './mock'; @@ -26,7 +30,7 @@ describe('get_mappings', () => { describe('happy path', () => { test('it gets existing mappings', async () => { const savedObjectsClient = createMockSavedObjectsRepository({ - caseMappingsSavedObject: mockCaseMappings, + caseMappingsSavedObject: mockCaseMappingsResilient, }); const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await casesClient.client.getMappings({ @@ -35,7 +39,7 @@ describe('get_mappings', () => { connectorId: '123', }); - expect(res).toEqual(mappings[ConnectorTypes.jira]); + expect(res).toEqual(mappings[ConnectorTypes.resilient]); }); test('it creates new mappings', async () => { const savedObjectsClient = createMockSavedObjectsRepository({ @@ -48,6 +52,21 @@ describe('get_mappings', () => { connectorId: '123', }); + expect(res).toEqual(mappings[ConnectorTypes.jira]); + }); + }); + describe('unhappy path', () => { + test('it gets existing mappings, but attributes object is empty so it creates new mappings', async () => { + const savedObjectsClient = createMockSavedObjectsRepository({ + caseMappingsSavedObject: mockCaseMappingsBad, + }); + const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); + const res = await casesClient.client.getMappings({ + actionsClient: actionsMock, + connectorType: ConnectorTypes.jira, + connectorId: '123', + }); + expect(res).toEqual(mappings[ConnectorTypes.jira]); }); }); diff --git a/x-pack/plugins/cases/server/client/configure/get_mappings.ts b/x-pack/plugins/cases/server/client/configure/get_mappings.ts index 558c961f89e5b..3560bf1dcd067 100644 --- a/x-pack/plugins/cases/server/client/configure/get_mappings.ts +++ b/x-pack/plugins/cases/server/client/configure/get_mappings.ts @@ -7,7 +7,7 @@ import { SavedObjectsClientContract, Logger } from 'src/core/server'; import { ActionsClient } from '../../../../actions/server'; -import { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common/api'; +import { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server/saved_objects'; import { ConnectorMappingsServiceSetup } from '../../services'; @@ -48,7 +48,11 @@ export const getMappings = async ({ }); let theMapping; // Create connector mappings if there are none - if (myConnectorMappings.total === 0) { + if ( + myConnectorMappings.total === 0 || + (myConnectorMappings.total > 0 && + !myConnectorMappings.saved_objects[0].attributes.hasOwnProperty('mappings')) + ) { const res = await casesClient.getFields({ actionsClient, connectorId, diff --git a/x-pack/plugins/cases/server/client/configure/mock.ts b/x-pack/plugins/cases/server/client/configure/mock.ts index ee214de9b51d4..ad982a5cc1243 100644 --- a/x-pack/plugins/cases/server/client/configure/mock.ts +++ b/x-pack/plugins/cases/server/client/configure/mock.ts @@ -5,11 +5,7 @@ * 2.0. */ -import { - ConnectorField, - ConnectorMappingsAttributes, - ConnectorTypes, -} from '../../../common/api/connectors'; +import { ConnectorField, ConnectorMappingsAttributes, ConnectorTypes } from '../../../common'; import { JiraGetFieldsResponse, ResilientGetFieldsResponse, diff --git a/x-pack/plugins/cases/server/client/configure/utils.ts b/x-pack/plugins/cases/server/client/configure/utils.ts index 10c3e1fd3c1a9..24efb6ca54b3a 100644 --- a/x-pack/plugins/cases/server/client/configure/utils.ts +++ b/x-pack/plugins/cases/server/client/configure/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConnectorField, ConnectorMappingsAttributes, ConnectorTypes } from '../../../common/api'; +import { ConnectorField, ConnectorMappingsAttributes, ConnectorTypes } from '../../../common'; import { JiraGetFieldsResponse, ResilientGetFieldsResponse, diff --git a/x-pack/plugins/cases/server/client/types.ts b/x-pack/plugins/cases/server/client/types.ts index c62b3913da763..3311b7ac6f921 100644 --- a/x-pack/plugins/cases/server/client/types.ts +++ b/x-pack/plugins/cases/server/client/types.ts @@ -18,7 +18,7 @@ import { GetFieldsResponse, CaseUserActionsResponse, User, -} from '../../common/api'; +} from '../../common'; import { AlertInfo } from '../common'; import { CaseConfigureServiceSetup, diff --git a/x-pack/plugins/cases/server/client/user_actions/get.ts b/x-pack/plugins/cases/server/client/user_actions/get.ts index f6371b8e8b1e7..79b8ef25ab0f6 100644 --- a/x-pack/plugins/cases/server/client/user_actions/get.ts +++ b/x-pack/plugins/cases/server/client/user_actions/get.ts @@ -11,7 +11,7 @@ import { CASE_COMMENT_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT, } from '../../saved_object_types'; -import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common/api'; +import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common'; import { CaseUserActionServiceSetup } from '../../services'; interface GetParams { diff --git a/x-pack/plugins/cases/server/common/models/commentable_case.ts b/x-pack/plugins/cases/server/common/models/commentable_case.ts index 1ff5b7beadcaf..3daccf87bdc19 100644 --- a/x-pack/plugins/cases/server/common/models/commentable_case.ts +++ b/x-pack/plugins/cases/server/common/models/commentable_case.ts @@ -27,7 +27,7 @@ import { ESCaseAttributes, SubCaseAttributes, User, -} from '../../../common/api'; +} from '../../../common'; import { transformESConnectorToCaseConnector } from '../../routes/api/cases/helpers'; import { flattenCommentSavedObjects, diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index 5e6a86358de25..df16fe4f0a67d 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -6,7 +6,7 @@ */ import { SavedObjectsFindResponse } from 'kibana/server'; -import { AssociationType, CommentAttributes, CommentRequest, CommentType } from '../../common/api'; +import { AssociationType, CommentAttributes, CommentRequest, CommentType } from '../../common'; import { transformNewComment } from '../routes/api/utils'; import { combineFilters, countAlerts, countAlertsForID, groupTotalAlertsByID } from './utils'; diff --git a/x-pack/plugins/cases/server/common/utils.ts b/x-pack/plugins/cases/server/common/utils.ts index dce26f3d5998a..d3bc3850e4210 100644 --- a/x-pack/plugins/cases/server/common/utils.ts +++ b/x-pack/plugins/cases/server/common/utils.ts @@ -6,13 +6,7 @@ */ import { SavedObjectsFindResult, SavedObjectsFindResponse } from 'kibana/server'; -import { - CaseStatuses, - CommentAttributes, - CommentRequest, - CommentType, - User, -} from '../../common/api'; +import { CaseStatuses, CommentAttributes, CommentRequest, CommentType, User } from '../../common'; import { UpdateAlertRequest } from '../client/types'; import { getAlertInfoFromComments } from '../routes/api/utils'; diff --git a/x-pack/plugins/cases/server/connectors/case/index.test.ts b/x-pack/plugins/cases/server/connectors/case/index.test.ts index 8a025ed0f79b7..2415569392125 100644 --- a/x-pack/plugins/cases/server/connectors/case/index.test.ts +++ b/x-pack/plugins/cases/server/connectors/case/index.test.ts @@ -18,7 +18,7 @@ import { AssociationType, CaseResponse, CasesResponse, -} from '../../../common/api'; +} from '../../../common'; import { connectorMappingsServiceMock, createCaseServiceMock, diff --git a/x-pack/plugins/cases/server/connectors/case/index.ts b/x-pack/plugins/cases/server/connectors/case/index.ts index c5eb609e260ae..be519f97f2343 100644 --- a/x-pack/plugins/cases/server/connectors/case/index.ts +++ b/x-pack/plugins/cases/server/connectors/case/index.ts @@ -8,12 +8,7 @@ import { curry } from 'lodash'; import { Logger } from 'src/core/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; -import { - CasePatchRequest, - CasePostRequest, - CommentRequest, - CommentType, -} from '../../../common/api'; +import { CasePatchRequest, CasePostRequest, CommentRequest, CommentType } from '../../../common'; import { createExternalCasesClient } from '../../client'; import { CaseExecutorParamsSchema, CaseConfigurationSchema, CommentSchemaType } from './schema'; import { diff --git a/x-pack/plugins/cases/server/connectors/case/schema.ts b/x-pack/plugins/cases/server/connectors/case/schema.ts index 1637cec7520be..803b01cbbdc57 100644 --- a/x-pack/plugins/cases/server/connectors/case/schema.ts +++ b/x-pack/plugins/cases/server/connectors/case/schema.ts @@ -6,7 +6,7 @@ */ import { schema } from '@kbn/config-schema'; -import { CommentType } from '../../../common/api'; +import { CommentType } from '../../../common'; import { validateConnector } from './validators'; // Reserved for future implementation diff --git a/x-pack/plugins/cases/server/connectors/case/types.ts b/x-pack/plugins/cases/server/connectors/case/types.ts index 6a7dfd9c2e687..a71007f0b4946 100644 --- a/x-pack/plugins/cases/server/connectors/case/types.ts +++ b/x-pack/plugins/cases/server/connectors/case/types.ts @@ -16,7 +16,7 @@ import { ConnectorSchema, CommentSchema, } from './schema'; -import { CaseResponse, CasesResponse } from '../../../common/api'; +import { CaseResponse, CasesResponse } from '../../../common'; export type CaseConfiguration = TypeOf<typeof CaseConfigurationSchema>; export type Connector = TypeOf<typeof ConnectorSchema>; diff --git a/x-pack/plugins/cases/server/connectors/index.ts b/x-pack/plugins/cases/server/connectors/index.ts index a6b6e193361be..ecf04e4f7b0f1 100644 --- a/x-pack/plugins/cases/server/connectors/index.ts +++ b/x-pack/plugins/cases/server/connectors/index.ts @@ -17,7 +17,7 @@ import { serviceNowITSMExternalServiceFormatter } from './servicenow/itsm_format import { serviceNowSIRExternalServiceFormatter } from './servicenow/sir_formatter'; import { jiraExternalServiceFormatter } from './jira/external_service_formatter'; import { resilientExternalServiceFormatter } from './resilient/external_service_formatter'; -import { CommentRequest, CommentType } from '../../common/api'; +import { CommentRequest, CommentType } from '../../common'; export * from './types'; export { transformConnectorComment } from './case'; diff --git a/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.test.ts b/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.test.ts index 0bfaf7cdbd9e3..f5d76aeddf313 100644 --- a/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.test.ts +++ b/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CaseResponse } from '../../../common/api'; +import { CaseResponse } from '../../../common'; import { jiraExternalServiceFormatter } from './external_service_formatter'; describe('Jira formatter', () => { diff --git a/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.ts b/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.ts index 74376d295fea5..15ee2fd468dda 100644 --- a/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.ts +++ b/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { JiraFieldsType, ConnectorJiraTypeFields } from '../../../common/api'; +import { JiraFieldsType, ConnectorJiraTypeFields } from '../../../common'; import { ExternalServiceFormatter } from '../types'; interface ExternalServiceParams extends JiraFieldsType { diff --git a/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.test.ts b/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.test.ts index 01280e9692b5e..b7096179b0fab 100644 --- a/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.test.ts +++ b/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CaseResponse } from '../../../common/api'; +import { CaseResponse } from '../../../common'; import { resilientExternalServiceFormatter } from './external_service_formatter'; describe('IBM Resilient formatter', () => { diff --git a/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.ts b/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.ts index 76554dce32797..6dea452565d7c 100644 --- a/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.ts +++ b/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ResilientFieldsType, ConnectorResillientTypeFields } from '../../../common/api'; +import { ResilientFieldsType, ConnectorResillientTypeFields } from '../../../common'; import { ExternalServiceFormatter } from '../types'; const format: ExternalServiceFormatter<ResilientFieldsType>['format'] = (theCase) => { diff --git a/x-pack/plugins/cases/server/connectors/servicenow/itsm_formatter.ts b/x-pack/plugins/cases/server/connectors/servicenow/itsm_formatter.ts index b49eed6a4ad26..a4fa8a198fea7 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/itsm_formatter.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/itsm_formatter.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ServiceNowITSMFieldsType, ConnectorServiceNowITSMTypeFields } from '../../../common/api'; +import { ServiceNowITSMFieldsType, ConnectorServiceNowITSMTypeFields } from '../../../common'; import { ExternalServiceFormatter } from '../types'; const format: ExternalServiceFormatter<ServiceNowITSMFieldsType>['format'] = (theCase) => { diff --git a/x-pack/plugins/cases/server/connectors/servicenow/itsm_formmater.test.ts b/x-pack/plugins/cases/server/connectors/servicenow/itsm_formmater.test.ts index ea3a4e41e17b8..78242e4c3848a 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/itsm_formmater.test.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/itsm_formmater.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CaseResponse } from '../../../common/api'; +import { CaseResponse } from '../../../common'; import { serviceNowITSMExternalServiceFormatter } from './itsm_formatter'; describe('ITSM formatter', () => { diff --git a/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.test.ts b/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.test.ts index 4faca62c6e706..1f7716424cfa9 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.test.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CaseResponse } from '../../../common/api'; +import { CaseResponse } from '../../../common'; import { serviceNowSIRExternalServiceFormatter } from './sir_formatter'; describe('ITSM formatter', () => { diff --git a/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.ts b/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.ts index d2458e6c7ae53..1c528cd2b47bf 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.ts @@ -5,7 +5,7 @@ * 2.0. */ import { get } from 'lodash/fp'; -import { ConnectorServiceNowSIRTypeFields } from '../../../common/api'; +import { ConnectorServiceNowSIRTypeFields } from '../../../common'; import { ExternalServiceFormatter } from '../types'; interface ExternalServiceParams { dest_ip: string | null; diff --git a/x-pack/plugins/cases/server/connectors/types.ts b/x-pack/plugins/cases/server/connectors/types.ts index f6c284b74667b..fae1ec2976bc0 100644 --- a/x-pack/plugins/cases/server/connectors/types.ts +++ b/x-pack/plugins/cases/server/connectors/types.ts @@ -13,7 +13,7 @@ import { ActionType, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../actions/server/types'; -import { CaseResponse, ConnectorTypes } from '../../common/api'; +import { CaseResponse, ConnectorTypes } from '../../common'; import { CasesClientGetAlertsResponse } from '../client/alerts/types'; import { CaseServiceSetup, diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 8b53fd77d98a5..407d6583e5f3f 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -10,7 +10,7 @@ import { CoreSetup, CoreStart } from 'src/core/server'; import { SecurityPluginSetup } from '../../security/server'; import { PluginSetupContract as ActionsPluginSetup } from '../../actions/server'; -import { APP_ID, ENABLE_CASE_CONNECTOR } from '../common/constants'; +import { APP_ID, ENABLE_CASE_CONNECTOR } from '../common'; import { ConfigType } from './config'; import { initCaseApi } from './routes/api'; diff --git a/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts index f2318c45e6ed3..0026ee9ce4827 100644 --- a/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -17,7 +17,7 @@ import { ConnectorTypes, ESCaseAttributes, ESCasesConfigureAttributes, -} from '../../../../common/api'; +} from '../../../../common'; import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, CASE_USER_ACTION_SAVED_OBJECT, @@ -485,6 +485,26 @@ export const mockCaseMappings: Array<SavedObject<ConnectorMappings>> = [ }, ]; +export const mockCaseMappingsResilient: Array<SavedObject<ConnectorMappings>> = [ + { + type: CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + id: 'mock-mappings-1', + attributes: { + mappings: mappings[ConnectorTypes.resilient], + }, + references: [], + }, +]; + +export const mockCaseMappingsBad: Array<SavedObject<Partial<ConnectorMappings>>> = [ + { + type: CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + id: 'mock-mappings-bad', + attributes: {}, + references: [], + }, +]; + export const mockUserActions: Array<SavedObject<CaseUserActionAttributes>> = [ { type: CASE_USER_ACTION_SAVED_OBJECT, diff --git a/x-pack/plugins/cases/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/cases/server/routes/api/__mocks__/request_responses.ts index ae14b44e7dffe..9df94cd0923c9 100644 --- a/x-pack/plugins/cases/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/plugins/cases/server/routes/api/__mocks__/request_responses.ts @@ -10,7 +10,7 @@ import { CasePostRequest, CasesConfigureRequest, ConnectorTypes, -} from '../../../../common/api'; +} from '../../../../common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FindActionResult } from '../../../../../actions/server/types'; diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts index 7f6cfb224fada..1e7e875a53df3 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts @@ -10,8 +10,7 @@ import { schema } from '@kbn/config-schema'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common/constants'; -import { AssociationType } from '../../../../../common/api'; +import { AssociationType, CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common'; export function initDeleteAllCommentsApi({ caseService, diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.test.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.test.ts index dcbcd7b9e246d..d0968c3232459 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.test.ts @@ -16,7 +16,7 @@ import { mockCaseComments, } from '../../__fixtures__'; import { initDeleteCommentApi } from './delete_comment'; -import { CASE_COMMENT_DETAILS_URL } from '../../../../../common/constants'; +import { CASE_COMMENT_DETAILS_URL } from '../../../../../common'; describe('DELETE comment', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts index 9468b2b01fe37..654b8d532830a 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts @@ -19,10 +19,10 @@ import { CommentsResponseRt, SavedObjectFindOptionsRt, throwErrors, -} from '../../../../../common/api'; +} from '../../../../../common'; import { RouteDeps } from '../../types'; import { escapeHatch, transformComments, wrapError } from '../../utils'; -import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common/constants'; +import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common'; import { defaultPage, defaultPerPage } from '../..'; const FindQueryParamsRt = rt.partial({ diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts index 2699f7a0307f7..580bb3163bb7d 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts @@ -9,10 +9,10 @@ import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; import { SavedObjectsFindResponse } from 'kibana/server'; -import { AllCommentsResponseRt, CommentAttributes } from '../../../../../common/api'; +import { AllCommentsResponseRt, CommentAttributes } from '../../../../../common'; import { RouteDeps } from '../../types'; import { flattenCommentSavedObjects, wrapError } from '../../utils'; -import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common/constants'; +import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common'; import { defaultSortField } from '../../../../common'; export function initGetAllCommentsApi({ caseService, router, logger }: RouteDeps) { diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.test.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.test.ts index 8ee43eaba8a82..46accdc58d460 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.test.ts @@ -17,7 +17,7 @@ import { } from '../../__fixtures__'; import { flattenCommentSavedObject } from '../../utils'; import { initGetCommentApi } from './get_comment'; -import { CASE_COMMENT_DETAILS_URL } from '../../../../../common/constants'; +import { CASE_COMMENT_DETAILS_URL } from '../../../../../common'; describe('GET comment', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts index 9dedfccd3a250..f86f733306043 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts @@ -7,10 +7,10 @@ import { schema } from '@kbn/config-schema'; -import { CommentResponseRt } from '../../../../../common/api'; +import { CommentResponseRt } from '../../../../../common'; import { RouteDeps } from '../../types'; import { flattenCommentSavedObject, wrapError } from '../../utils'; -import { CASE_COMMENT_DETAILS_URL } from '../../../../../common/constants'; +import { CASE_COMMENT_DETAILS_URL } from '../../../../../common'; export function initGetCommentApi({ caseService, router, logger }: RouteDeps) { router.get( diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.test.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.test.ts index 9cc0575f9bb94..32a0133d455c2 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.test.ts @@ -17,8 +17,8 @@ import { mockCases, } from '../../__fixtures__'; import { initPatchCommentApi } from './patch_comment'; -import { CASE_COMMENTS_URL } from '../../../../../common/constants'; -import { CommentType } from '../../../../../common/api'; +import { CASE_COMMENTS_URL } from '../../../../../common'; +import { CommentType } from '../../../../../common'; describe('PATCH comment', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts index 519692d2d78a1..366fb887066f8 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts @@ -14,12 +14,12 @@ import Boom from '@hapi/boom'; import { SavedObjectsClientContract, Logger } from 'kibana/server'; import { CommentableCase } from '../../../../common'; -import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common/api'; +import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { escapeHatch, wrapError, decodeCommentRequest } from '../../utils'; -import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common/constants'; +import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common'; import { CaseServiceSetup } from '../../../../services'; interface CombinedCaseParams { diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.test.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.test.ts index 807ec0d089a52..27d5c47d47399 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.test.ts @@ -17,8 +17,8 @@ import { mockCaseComments, } from '../../__fixtures__'; import { initPostCommentApi } from './post_comment'; -import { CASE_COMMENTS_URL } from '../../../../../common/constants'; -import { CommentType } from '../../../../../common/api'; +import { CASE_COMMENTS_URL } from '../../../../../common'; +import { CommentType } from '../../../../../common'; describe('POST comment', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.ts index 8658f9ba0aac5..8af4b86762d33 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.ts @@ -9,8 +9,7 @@ import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; import { escapeHatch, wrapError } from '../../utils'; import { RouteDeps } from '../../types'; -import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common/constants'; -import { CommentRequest } from '../../../../../common/api'; +import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR, CommentRequest } from '../../../../../common'; export function initPostCommentApi({ router, logger }: RouteDeps) { router.post( diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.test.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.test.ts index f328844acfd00..626f53cdf4263 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.test.ts @@ -17,9 +17,8 @@ import { } from '../../__fixtures__'; import { initGetCaseConfigure } from './get_configure'; -import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; +import { CASE_CONFIGURE_URL, ConnectorTypes } from '../../../../../common'; import { mappings } from '../../../../client/configure/mock'; -import { ConnectorTypes } from '../../../../../common/api/connectors'; import { CasesClient } from '../../../../client'; describe('GET configuration', () => { diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts index c916bd8f4140b..03ac3dd8b13b3 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts @@ -6,10 +6,10 @@ */ import Boom from '@hapi/boom'; -import { CaseConfigureResponseRt, ConnectorMappingsAttributes } from '../../../../../common/api'; +import { CaseConfigureResponseRt, ConnectorMappingsAttributes } from '../../../../../common'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; +import { CASE_CONFIGURE_URL } from '../../../../../common'; import { transformESConnectorToCaseConnector } from '../helpers'; export function initGetCaseConfigure({ caseConfigureService, router, logger }: RouteDeps) { diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.test.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.test.ts index 3fa0fe2f83f79..082adf7b4803f 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.test.ts @@ -17,7 +17,7 @@ import { } from '../../__fixtures__'; import { initCaseConfigureGetActionConnector } from './get_connectors'; -import { CASE_CONFIGURE_CONNECTORS_URL } from '../../../../../common/constants'; +import { CASE_CONFIGURE_CONNECTORS_URL } from '../../../../../common'; import { getActions } from '../../__mocks__/request_responses'; describe('GET connectors', () => { diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts index 81ffc06355ff5..7aec7e4f086b4 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts @@ -12,10 +12,7 @@ import { ActionType } from '../../../../../../actions/common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FindActionResult } from '../../../../../../actions/server/types'; -import { - CASE_CONFIGURE_CONNECTORS_URL, - SUPPORTED_CONNECTORS, -} from '../../../../../common/constants'; +import { CASE_CONFIGURE_CONNECTORS_URL, SUPPORTED_CONNECTORS } from '../../../../../common'; const isConnectorSupported = ( action: FindActionResult, diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.test.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.test.ts index 48d88e0f622f5..c4e2b6af1cd6b 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.test.ts @@ -17,8 +17,7 @@ import { import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects'; import { initPatchCaseConfigure } from './patch_configure'; -import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; -import { ConnectorTypes } from '../../../../../common/api/connectors'; +import { CASE_CONFIGURE_URL, ConnectorTypes } from '../../../../../common'; import { CasesClient } from '../../../../client'; describe('PATCH configuration', () => { diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts index ba0ea6eb17936..5fe38cf0efe48 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts @@ -15,10 +15,10 @@ import { CaseConfigureResponseRt, throwErrors, ConnectorMappingsAttributes, -} from '../../../../../common/api'; +} from '../../../../../common'; import { RouteDeps } from '../../types'; import { wrapError, escapeHatch } from '../../utils'; -import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; +import { CASE_CONFIGURE_URL } from '../../../../../common'; import { transformCaseConnectorToEsConnector, transformESConnectorToCaseConnector, diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.test.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.test.ts index 882a10742d733..35b662078fe9c 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.test.ts @@ -18,8 +18,7 @@ import { import { initPostCaseConfigure } from './post_configure'; import { newConfiguration } from '../../__mocks__/request_responses'; -import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; -import { ConnectorTypes } from '../../../../../common/api/connectors'; +import { CASE_CONFIGURE_URL, ConnectorTypes } from '../../../../../common'; import { CasesClient } from '../../../../client'; describe('POST configuration', () => { diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts index 469151a126898..74ad02f47e178 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts @@ -15,10 +15,10 @@ import { CaseConfigureResponseRt, throwErrors, ConnectorMappingsAttributes, -} from '../../../../../common/api'; +} from '../../../../../common'; import { RouteDeps } from '../../types'; import { wrapError, escapeHatch } from '../../utils'; -import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; +import { CASE_CONFIGURE_URL } from '../../../../../common'; import { transformCaseConnectorToEsConnector, transformESConnectorToCaseConnector, diff --git a/x-pack/plugins/cases/server/routes/api/cases/delete_cases.test.ts b/x-pack/plugins/cases/server/routes/api/cases/delete_cases.test.ts index a441a027769bf..7748a079ceb4d 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/delete_cases.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/delete_cases.test.ts @@ -17,7 +17,7 @@ import { mockCaseComments, } from '../__fixtures__'; import { initDeleteCasesApi } from './delete_cases'; -import { CASES_URL } from '../../../../common/constants'; +import { CASES_URL } from '../../../../common'; describe('DELETE case', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts index d91859d4e8cbb..d0cfc03e69f7c 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts @@ -11,7 +11,7 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; -import { CASES_URL, ENABLE_CASE_CONNECTOR } from '../../../../common/constants'; +import { CASES_URL, ENABLE_CASE_CONNECTOR } from '../../../../common'; import { CaseServiceSetup } from '../../../services'; async function deleteSubCases({ diff --git a/x-pack/plugins/cases/server/routes/api/cases/find_cases.test.ts b/x-pack/plugins/cases/server/routes/api/cases/find_cases.test.ts index ca9f731ca5010..75586896390fc 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/find_cases.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/find_cases.test.ts @@ -15,7 +15,7 @@ import { mockCases, } from '../__fixtures__'; import { initFindCasesApi } from './find_cases'; -import { CASES_URL } from '../../../../common/constants'; +import { CASES_URL } from '../../../../common'; import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; describe('FIND all cases', () => { diff --git a/x-pack/plugins/cases/server/routes/api/cases/find_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/find_cases.ts index 10406d0edcd46..77b1d6b23f912 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/find_cases.ts @@ -16,10 +16,10 @@ import { CasesFindRequestRt, throwErrors, caseStatuses, -} from '../../../../common/api'; +} from '../../../../common'; import { transformCases, wrapError, escapeHatch } from '../utils'; import { RouteDeps } from '../types'; -import { CASES_URL } from '../../../../common/constants'; +import { CASES_URL } from '../../../../common'; import { constructQueryOptions } from './helpers'; export function initFindCasesApi({ caseService, router, logger }: RouteDeps) { diff --git a/x-pack/plugins/cases/server/routes/api/cases/get_case.test.ts b/x-pack/plugins/cases/server/routes/api/cases/get_case.test.ts index b9312331b4df2..768bbca62f3fe 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/get_case.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/get_case.test.ts @@ -8,7 +8,7 @@ import { kibanaResponseFactory, RequestHandler, SavedObject } from 'src/core/server'; import { httpServerMock } from 'src/core/server/mocks'; -import { ConnectorTypes, ESCaseAttributes } from '../../../../common/api'; +import { ConnectorTypes, ESCaseAttributes } from '../../../../common'; import { createMockSavedObjectsRepository, createRoute, @@ -21,7 +21,7 @@ import { } from '../__fixtures__'; import { flattenCaseSavedObject } from '../utils'; import { initGetCaseApi } from './get_case'; -import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { CASE_DETAILS_URL } from '../../../../common'; describe('GET case', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/get_case.ts b/x-pack/plugins/cases/server/routes/api/cases/get_case.ts index e8e35d875f42f..c69eae7fb1f94 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/get_case.ts @@ -10,7 +10,7 @@ import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; -import { CASE_DETAILS_URL, ENABLE_CASE_CONNECTOR } from '../../../../common/constants'; +import { CASE_DETAILS_URL, ENABLE_CASE_CONNECTOR } from '../../../../common'; export function initGetCaseApi({ router, logger }: RouteDeps) { router.get( diff --git a/x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts b/x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts index f7cfebeaea749..a1d25aa295799 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts @@ -11,7 +11,7 @@ import { ConnectorTypes, ESCaseConnector, ESCasesConfigureAttributes, -} from '../../../../common/api'; +} from '../../../../common'; import { mockCaseConfigure } from '../__fixtures__'; import { transformCaseConnectorToEsConnector, diff --git a/x-pack/plugins/cases/server/routes/api/cases/helpers.ts b/x-pack/plugins/cases/server/routes/api/cases/helpers.ts index 4e6c07d05bc17..5f51c9b1f8d8c 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/helpers.ts @@ -11,15 +11,15 @@ import deepEqual from 'fast-deep-equal'; import { SavedObjectsFindResponse } from 'kibana/server'; import { CaseConnector, - ESCaseConnector, - ESCasesConfigureAttributes, - ConnectorTypeFields, - ConnectorTypes, CaseStatuses, CaseType, + ConnectorTypeFields, + ConnectorTypes, + ESCaseConnector, + ESCasesConfigureAttributes, + ESConnectorFields, SavedObjectFindOptions, -} from '../../../../common/api'; -import { ESConnectorFields } from '../../../../common/api/connectors'; +} from '../../../../common'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../saved_object_types'; import { sortToSnake } from '../utils'; import { combineFilters } from '../../../common'; diff --git a/x-pack/plugins/cases/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/cases/server/routes/api/cases/patch_cases.test.ts index b3f87211c9547..96a891441ea5f 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/patch_cases.test.ts @@ -17,7 +17,7 @@ import { } from '../__fixtures__'; import { initPatchCasesApi } from './patch_cases'; import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; -import { CaseStatuses } from '../../../../common/api'; +import { CaseStatuses } from '../../../../common'; describe('PATCH cases', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts index 8e779087bcafe..092f88c1a8a20 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts @@ -7,8 +7,8 @@ import { escapeHatch, wrapError } from '../utils'; import { RouteDeps } from '../types'; -import { CASES_URL } from '../../../../common/constants'; -import { CasesPatchRequest } from '../../../../common/api'; +import { CASES_URL } from '../../../../common'; +import { CasesPatchRequest } from '../../../../common'; export function initPatchCasesApi({ router, logger }: RouteDeps) { router.patch( diff --git a/x-pack/plugins/cases/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/cases/server/routes/api/cases/post_case.test.ts index e1669203d3ded..669d3a5e58874 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/post_case.test.ts @@ -15,9 +15,9 @@ import { mockCases, } from '../__fixtures__'; import { initPostCaseApi } from './post_case'; -import { CASES_URL } from '../../../../common/constants'; +import { CASES_URL } from '../../../../common'; import { mockCaseConfigure } from '../__fixtures__/mock_saved_objects'; -import { ConnectorTypes, CaseStatuses } from '../../../../common/api'; +import { ConnectorTypes, CaseStatuses } from '../../../../common'; describe('POST cases', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/post_case.ts b/x-pack/plugins/cases/server/routes/api/cases/post_case.ts index e2d71c5837353..a7951a1a71344 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/post_case.ts @@ -8,8 +8,8 @@ import { wrapError, escapeHatch } from '../utils'; import { RouteDeps } from '../types'; -import { CASES_URL } from '../../../../common/constants'; -import { CasePostRequest } from '../../../../common/api'; +import { CASES_URL } from '../../../../common'; +import { CasePostRequest } from '../../../../common'; export function initPostCaseApi({ router, logger }: RouteDeps) { router.post( diff --git a/x-pack/plugins/cases/server/routes/api/cases/push_case.test.ts b/x-pack/plugins/cases/server/routes/api/cases/push_case.test.ts index fb0ba5e3b5d9a..378d092c8be0b 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/push_case.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/push_case.test.ts @@ -20,7 +20,7 @@ import { } from '../__fixtures__'; import { initPushCaseApi } from './push_case'; import { CasesRequestHandlerContext } from '../../../types'; -import { getCasePushUrl } from '../../../../common/api/helpers'; +import { getCasePushUrl } from '../../../../common'; describe('Push case', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/push_case.ts b/x-pack/plugins/cases/server/routes/api/cases/push_case.ts index 7395758210cf4..9bfb30e0d63ad 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/push_case.ts @@ -12,9 +12,9 @@ import { identity } from 'fp-ts/lib/function'; import { wrapError, escapeHatch } from '../utils'; -import { throwErrors, CasePushRequestParamsRt } from '../../../../common/api'; +import { throwErrors, CasePushRequestParamsRt } from '../../../../common'; import { RouteDeps } from '../types'; -import { CASE_PUSH_URL } from '../../../../common/constants'; +import { CASE_PUSH_URL } from '../../../../common'; export function initPushCaseApi({ router, logger }: RouteDeps) { router.post( diff --git a/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts b/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts index e5433f4972239..53fdc298ef267 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { UsersRt } from '../../../../../common/api'; +import { UsersRt } from '../../../../../common'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_REPORTERS_URL } from '../../../../../common/constants'; +import { CASE_REPORTERS_URL } from '../../../../../common'; export function initGetReportersApi({ caseService, router, logger }: RouteDeps) { router.get( diff --git a/x-pack/plugins/cases/server/routes/api/cases/status/get_status.test.ts b/x-pack/plugins/cases/server/routes/api/cases/status/get_status.test.ts index 1c399a415e470..60ad0c60f944f 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/status/get_status.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/status/get_status.test.ts @@ -15,8 +15,8 @@ import { mockCases, } from '../../__fixtures__'; import { initGetCasesStatusApi } from './get_status'; -import { CASE_STATUS_URL } from '../../../../../common/constants'; -import { CaseType } from '../../../../../common/api'; +import { CASE_STATUS_URL } from '../../../../../common'; +import { CaseType } from '../../../../../common'; describe('GET status', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts index d0addfff09124..73642fdee0eac 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts @@ -8,8 +8,8 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CasesStatusResponseRt, caseStatuses } from '../../../../../common/api'; -import { CASE_STATUS_URL } from '../../../../../common/constants'; +import { CasesStatusResponseRt, caseStatuses } from '../../../../../common'; +import { CASE_STATUS_URL } from '../../../../../common'; import { constructQueryOptions } from '../helpers'; export function initGetCasesStatusApi({ caseService, router, logger }: RouteDeps) { diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts index fd33afbd7df8e..ef60c743ec822 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts @@ -10,7 +10,7 @@ import { schema } from '@kbn/config-schema'; import { buildCaseUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common'; import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; export function initDeleteSubCasesApi({ diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts index e7f9f8b4f2d73..e069ceda14df9 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts @@ -17,10 +17,10 @@ import { SubCasesFindRequestRt, SubCasesFindResponseRt, throwErrors, -} from '../../../../../common/api'; +} from '../../../../../common'; import { RouteDeps } from '../../types'; import { escapeHatch, transformSubCases, wrapError } from '../../utils'; -import { SUB_CASES_URL } from '../../../../../common/constants'; +import { SUB_CASES_URL } from '../../../../../common'; import { constructQueryOptions } from '../helpers'; import { defaultPage, defaultPerPage } from '../..'; diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts index 32dcc924e1a08..b5ebfb4de348b 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts @@ -7,10 +7,10 @@ import { schema } from '@kbn/config-schema'; -import { SubCaseResponseRt } from '../../../../../common/api'; +import { SubCaseResponseRt } from '../../../../../common'; import { RouteDeps } from '../../types'; import { flattenSubCaseSavedObject, wrapError } from '../../utils'; -import { SUB_CASE_DETAILS_URL } from '../../../../../common/constants'; +import { SUB_CASE_DETAILS_URL } from '../../../../../common'; import { countAlertsForID } from '../../../../common'; export function initGetSubCaseApi({ caseService, router, logger }: RouteDeps) { diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts index 08836615e1d39..0b142fb5279e5 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts @@ -35,8 +35,8 @@ import { SubCasesResponseRt, User, CommentAttributes, -} from '../../../../../common/api'; -import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; +} from '../../../../../common'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common'; import { RouteDeps } from '../../types'; import { escapeHatch, diff --git a/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts b/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts index f066aa70ec472..d70d6e0b57ee9 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts @@ -7,7 +7,7 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_TAGS_URL } from '../../../../../common/constants'; +import { CASE_TAGS_URL } from '../../../../../common'; export function initGetTagsApi({ caseService, router }: RouteDeps) { router.get( diff --git a/x-pack/plugins/cases/server/routes/api/cases/user_actions/get_all_user_actions.ts b/x-pack/plugins/cases/server/routes/api/cases/user_actions/get_all_user_actions.ts index b5c564648c185..48393b6af34ae 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/user_actions/get_all_user_actions.ts @@ -9,7 +9,7 @@ import { schema } from '@kbn/config-schema'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_USER_ACTIONS_URL, SUB_CASE_USER_ACTIONS_URL } from '../../../../../common/constants'; +import { CASE_USER_ACTIONS_URL, SUB_CASE_USER_ACTIONS_URL } from '../../../../../common'; export function initGetAllCaseUserActionsApi({ router, logger }: RouteDeps) { router.get( diff --git a/x-pack/plugins/cases/server/routes/api/utils.test.ts b/x-pack/plugins/cases/server/routes/api/utils.test.ts index f6bc1e4f71897..2df17e3abacfa 100644 --- a/x-pack/plugins/cases/server/routes/api/utils.test.ts +++ b/x-pack/plugins/cases/server/routes/api/utils.test.ts @@ -30,7 +30,7 @@ import { AssociationType, CaseType, CaseResponse, -} from '../../../common/api'; +} from '../../../common'; describe('Utils', () => { describe('transformNewCase', () => { diff --git a/x-pack/plugins/cases/server/routes/api/utils.ts b/x-pack/plugins/cases/server/routes/api/utils.ts index 8e8862f4157f1..9234472c13f5d 100644 --- a/x-pack/plugins/cases/server/routes/api/utils.ts +++ b/x-pack/plugins/cases/server/routes/api/utils.ts @@ -41,7 +41,7 @@ import { SubCasesFindResponse, User, AlertCommentRequestRt, -} from '../../../common/api'; +} from '../../../common'; import { transformESConnectorToCaseConnector } from './cases/helpers'; import { SortFieldCase } from './types'; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations.ts b/x-pack/plugins/cases/server/saved_object_types/migrations.ts index bf9694d7e6bb0..8bbc481124870 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations.ts @@ -14,7 +14,7 @@ import { CaseType, AssociationType, ESConnectorFields, -} from '../../common/api'; +} from '../../common'; interface UnsanitizedCaseConnector { connector_id: string; diff --git a/x-pack/plugins/cases/server/scripts/sub_cases/index.ts b/x-pack/plugins/cases/server/scripts/sub_cases/index.ts index ba3bcaa65091c..56f842c10e8f5 100644 --- a/x-pack/plugins/cases/server/scripts/sub_cases/index.ts +++ b/x-pack/plugins/cases/server/scripts/sub_cases/index.ts @@ -8,9 +8,7 @@ import yargs from 'yargs'; import { ToolingLog } from '@kbn/dev-utils'; import { KbnClient } from '@kbn/test'; -import { CaseResponse, CaseType, ConnectorTypes } from '../../../common/api'; -import { CommentType } from '../../../common/api/cases/comment'; -import { CASES_URL } from '../../../common/constants'; +import { CaseResponse, CaseType, CommentType, ConnectorTypes, CASES_URL } from '../../../common'; import { ActionResult, ActionTypeExecutorResult } from '../../../../actions/common'; import { ContextTypeGeneratedAlertType, createAlertsString } from '../../connectors'; diff --git a/x-pack/plugins/cases/server/services/alerts/index.test.ts b/x-pack/plugins/cases/server/services/alerts/index.test.ts index 042e415b77e43..28c3a6278d544 100644 --- a/x-pack/plugins/cases/server/services/alerts/index.test.ts +++ b/x-pack/plugins/cases/server/services/alerts/index.test.ts @@ -6,7 +6,7 @@ */ import { KibanaRequest } from 'kibana/server'; -import { CaseStatuses } from '../../../common/api'; +import { CaseStatuses } from '../../../common'; import { AlertService, AlertServiceContract } from '.'; import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; diff --git a/x-pack/plugins/cases/server/services/alerts/index.ts b/x-pack/plugins/cases/server/services/alerts/index.ts index db8e841f45ee4..81afaf5363e1f 100644 --- a/x-pack/plugins/cases/server/services/alerts/index.ts +++ b/x-pack/plugins/cases/server/services/alerts/index.ts @@ -10,7 +10,7 @@ import { isEmpty } from 'lodash'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { ElasticsearchClient, Logger } from 'kibana/server'; -import { MAX_ALERTS_PER_SUB_CASE } from '../../../common/constants'; +import { MAX_ALERTS_PER_SUB_CASE } from '../../../common'; import { UpdateAlertRequest } from '../../client/types'; import { AlertInfo } from '../../common'; import { createCaseError } from '../../common/error'; diff --git a/x-pack/plugins/cases/server/services/configure/index.ts b/x-pack/plugins/cases/server/services/configure/index.ts index 46dca4d9a0d0e..0ca63bce2d1d0 100644 --- a/x-pack/plugins/cases/server/services/configure/index.ts +++ b/x-pack/plugins/cases/server/services/configure/index.ts @@ -13,7 +13,7 @@ import { SavedObjectsUpdateResponse, } from 'kibana/server'; -import { ESCasesConfigureAttributes, SavedObjectFindOptions } from '../../../common/api'; +import { ESCasesConfigureAttributes, SavedObjectFindOptions } from '../../../common'; import { CASE_CONFIGURE_SAVED_OBJECT } from '../../saved_object_types'; interface ClientArgs { diff --git a/x-pack/plugins/cases/server/services/connector_mappings/index.ts b/x-pack/plugins/cases/server/services/connector_mappings/index.ts index d4fda10276d2b..82f37190b4ecc 100644 --- a/x-pack/plugins/cases/server/services/connector_mappings/index.ts +++ b/x-pack/plugins/cases/server/services/connector_mappings/index.ts @@ -13,7 +13,7 @@ import { SavedObjectsFindResponse, } from 'kibana/server'; -import { ConnectorMappings, SavedObjectFindOptions } from '../../../common/api'; +import { ConnectorMappings, SavedObjectFindOptions } from '../../../common'; import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT } from '../../saved_object_types'; interface ClientArgs { diff --git a/x-pack/plugins/cases/server/services/index.ts b/x-pack/plugins/cases/server/services/index.ts index 48a1a1ed68432..a27a8860e96b5 100644 --- a/x-pack/plugins/cases/server/services/index.ts +++ b/x-pack/plugins/cases/server/services/index.ts @@ -20,6 +20,7 @@ import { import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; import { + ENABLE_CASE_CONNECTOR, ESCaseAttributes, CommentAttributes, SavedObjectFindOptions, @@ -33,8 +34,7 @@ import { CaseResponse, caseTypeField, CasesFindRequest, -} from '../../common/api'; -import { ENABLE_CASE_CONNECTOR } from '../../common/constants'; +} from '../../common'; import { combineFilters, defaultSortField, groupTotalAlertsByID } from '../common'; import { defaultPage, defaultPerPage } from '../routes/api'; import { diff --git a/x-pack/plugins/cases/server/services/reporters/read_reporters.ts b/x-pack/plugins/cases/server/services/reporters/read_reporters.ts index d2708780b2ccf..b47fa185ff78e 100644 --- a/x-pack/plugins/cases/server/services/reporters/read_reporters.ts +++ b/x-pack/plugins/cases/server/services/reporters/read_reporters.ts @@ -7,7 +7,7 @@ import { SavedObject, SavedObjectsClientContract } from 'kibana/server'; -import { CaseAttributes, User } from '../../../common/api'; +import { CaseAttributes, User } from '../../../common'; import { CASE_SAVED_OBJECT } from '../../saved_object_types'; export const convertToReporters = (caseObjects: Array<SavedObject<CaseAttributes>>): User[] => diff --git a/x-pack/plugins/cases/server/services/tags/read_tags.ts b/x-pack/plugins/cases/server/services/tags/read_tags.ts index 4c4a948453730..a00b0b6f26fb7 100644 --- a/x-pack/plugins/cases/server/services/tags/read_tags.ts +++ b/x-pack/plugins/cases/server/services/tags/read_tags.ts @@ -7,7 +7,7 @@ import { SavedObject, SavedObjectsClientContract } from 'kibana/server'; -import { CaseAttributes } from '../../../common/api'; +import { CaseAttributes } from '../../../common'; import { CASE_SAVED_OBJECT } from '../../saved_object_types'; export const convertToTags = (tagObjects: Array<SavedObject<CaseAttributes>>): string[] => diff --git a/x-pack/plugins/cases/server/services/user_actions/helpers.ts b/x-pack/plugins/cases/server/services/user_actions/helpers.ts index c600a96234b3d..be32717039d9d 100644 --- a/x-pack/plugins/cases/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/cases/server/services/user_actions/helpers.ts @@ -17,7 +17,7 @@ import { User, UserActionFieldType, SubCaseAttributes, -} from '../../../common/api'; +} from '../../../common'; import { isTwoArraysDifference, transformESConnectorToCaseConnector, diff --git a/x-pack/plugins/cases/server/services/user_actions/index.ts b/x-pack/plugins/cases/server/services/user_actions/index.ts index 785c81021b584..a038d843a5331 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.ts @@ -12,7 +12,7 @@ import { SavedObjectReference, } from 'kibana/server'; -import { CaseUserActionAttributes } from '../../../common/api'; +import { CaseUserActionAttributes } from '../../../common'; import { CASE_USER_ACTION_SAVED_OBJECT, CASE_SAVED_OBJECT, diff --git a/x-pack/plugins/cases/server/types.ts b/x-pack/plugins/cases/server/types.ts index 31d73ea999163..420890c6f80fe 100644 --- a/x-pack/plugins/cases/server/types.ts +++ b/x-pack/plugins/cases/server/types.ts @@ -6,7 +6,6 @@ */ import type { IRouter, RequestHandlerContext } from 'src/core/server'; -import type { AppRequestContext } from '../../security_solution/server'; import type { ActionsApiRequestHandlerContext } from '../../actions/server'; import { CasesClient } from './client'; @@ -20,9 +19,6 @@ export interface CaseRequestContext { export interface CasesRequestHandlerContext extends RequestHandlerContext { cases: CaseRequestContext; actions: ActionsApiRequestHandlerContext; - // TODO: Remove when triggers_ui do not import case's types. - // PR https://github.com/elastic/kibana/pull/84587. - securitySolution: AppRequestContext; } /** diff --git a/x-pack/plugins/cases/tsconfig.json b/x-pack/plugins/cases/tsconfig.json new file mode 100644 index 0000000000000..493fe6430efa7 --- /dev/null +++ b/x-pack/plugins/cases/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + + // optionalPlugins from ./kibana.json + { "path": "../security/tsconfig.json" }, + { "path": "../spaces/tsconfig.json" }, + + // Required from './kibana.json' + { "path": "../actions/tsconfig.json" }, + { "path": "../triggers_actions_ui/tsconfig.json"}, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" } + ] +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/account_header/account_header.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/account_header/account_header.tsx index 87ee108f21c73..92a936fcdbefe 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/account_header/account_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/account_header/account_header.tsx @@ -27,12 +27,7 @@ import { getWorkplaceSearchUrl } from '../../../../shared/enterprise_search_url' import { EuiButtonEmptyTo } from '../../../../shared/react_router_helpers'; import { AppLogic } from '../../../app_logic'; import { WORKPLACE_SEARCH_TITLE, ACCOUNT_NAV } from '../../../constants'; -import { - ALPHA_PATH, - PERSONAL_SOURCES_PATH, - LOGOUT_ROUTE, - KIBANA_ACCOUNT_ROUTE, -} from '../../../routes'; +import { PERSONAL_SOURCES_PATH, LOGOUT_ROUTE, KIBANA_ACCOUNT_ROUTE } from '../../../routes'; export const AccountHeader: React.FC = () => { const [isPopoverOpen, setPopover] = useState(false); @@ -84,9 +79,7 @@ export const AccountHeader: React.FC = () => { </EuiHeaderSection> <EuiHeaderSection grow={false} side="right"> <EuiHeaderLinks> - {isAdmin && ( - <EuiButtonEmptyTo to={ALPHA_PATH}>{ACCOUNT_NAV.ORG_DASHBOARD}</EuiButtonEmptyTo> - )} + {isAdmin && <EuiButtonEmptyTo to="/">{ACCOUNT_NAV.ORG_DASHBOARD}</EuiButtonEmptyTo>} <EuiPopover id="accountSubNav" button={accountButton} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx index 2cd47f1c1b597..188949b2539c2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx @@ -10,7 +10,6 @@ import React from 'react'; import { shallow } from 'enzyme'; import { externalUrl } from '../../../shared/enterprise_search_url'; -import { WORKPLACE_SEARCH_URL_PREFIX } from '../../constants'; import { WorkplaceSearchHeaderActions } from './'; @@ -30,7 +29,6 @@ describe('WorkplaceSearchHeaderActions', () => { expect(wrapper.find('[data-test-subj="PersonalDashboardButton"]').prop('to')).toEqual( '/p/sources' ); - expect(wrapper.find('[data-test-subj="PersonalDashboardMVPButton"]')).toHaveLength(0); }); it('renders a link to the search application', () => { @@ -41,15 +39,4 @@ describe('WorkplaceSearchHeaderActions', () => { 'http://localhost:3002/ws/search' ); }); - - it('renders an MVP link back to the legacy dashboard on the MVP page', () => { - window.history.pushState({}, 'Overview', WORKPLACE_SEARCH_URL_PREFIX); - externalUrl.enterpriseSearchUrl = ENT_SEARCH_URL; - const wrapper = shallow(<WorkplaceSearchHeaderActions />); - - expect(wrapper.find('[data-test-subj="PersonalDashboardMVPButton"]').prop('href')).toEqual( - `${ENT_SEARCH_URL}/ws/sources` - ); - expect(wrapper.find('[data-test-subj="PersonalDashboardButton"]')).toHaveLength(0); - }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx index 7d594ce66aea1..0875e8cf0ec08 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx @@ -7,52 +7,39 @@ import React from 'react'; -import { EuiButtonEmpty, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiButtonEmpty, EuiText, EuiFlexGroup, EuiFlexItem, EuiHeaderLinks } from '@elastic/eui'; import { externalUrl, getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; import { EuiButtonEmptyTo } from '../../../shared/react_router_helpers'; -import { NAV, WORKPLACE_SEARCH_URL_PREFIX } from '../../constants'; +import { NAV } from '../../constants'; import { PERSONAL_SOURCES_PATH } from '../../routes'; export const WorkplaceSearchHeaderActions: React.FC = () => { if (!externalUrl.enterpriseSearchUrl) return null; - const isMVP = window.location.pathname.endsWith(WORKPLACE_SEARCH_URL_PREFIX); - - const personalDashboardMVPButton = ( - <EuiButtonEmpty - data-test-subj="PersonalDashboardMVPButton" - iconType="user" - href={getWorkplaceSearchUrl('/sources')} - target="_blank" - > - <EuiText size="s">{NAV.PERSONAL_DASHBOARD}</EuiText> - </EuiButtonEmpty> - ); - - const personalDashboardButton = ( - <EuiButtonEmptyTo - data-test-subj="PersonalDashboardButton" - iconType="user" - to={PERSONAL_SOURCES_PATH} - > - <EuiText size="s">{NAV.PERSONAL_DASHBOARD}</EuiText> - </EuiButtonEmptyTo> - ); - return ( - <EuiFlexGroup gutterSize="s"> - <EuiFlexItem>{isMVP ? personalDashboardMVPButton : personalDashboardButton}</EuiFlexItem> - <EuiFlexItem> - <EuiButtonEmpty - data-test-subj="HeaderSearchButton" - href={getWorkplaceSearchUrl('/search')} - target="_blank" - iconType="search" - > - <EuiText size="s">{NAV.SEARCH}</EuiText> - </EuiButtonEmpty> - </EuiFlexItem> - </EuiFlexGroup> + <EuiHeaderLinks> + <EuiFlexGroup gutterSize="s"> + <EuiFlexItem> + <EuiButtonEmptyTo + data-test-subj="PersonalDashboardButton" + iconType="user" + to={PERSONAL_SOURCES_PATH} + > + <EuiText size="s">{NAV.PERSONAL_DASHBOARD}</EuiText> + </EuiButtonEmptyTo> + </EuiFlexItem> + <EuiFlexItem> + <EuiButtonEmpty + data-test-subj="HeaderSearchButton" + href={getWorkplaceSearchUrl('/search')} + target="_blank" + iconType="search" + > + <EuiText size="s">{NAV.SEARCH}</EuiText> + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + </EuiHeaderLinks> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx index bac27bddf075a..8f37f608f4e28 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx @@ -13,8 +13,6 @@ import { shallow } from 'enzyme'; import { SideNav, SideNavLink } from '../../../shared/layout'; -import { ALPHA_PATH } from '../../routes'; - import { WorkplaceSearchNav } from './'; describe('WorkplaceSearchNav', () => { @@ -22,7 +20,7 @@ describe('WorkplaceSearchNav', () => { const wrapper = shallow(<WorkplaceSearchNav />); expect(wrapper.find(SideNav)).toHaveLength(1); - expect(wrapper.find(SideNavLink).first().prop('to')).toEqual(ALPHA_PATH); + expect(wrapper.find(SideNavLink).first().prop('to')).toEqual('/'); expect(wrapper.find(SideNavLink)).toHaveLength(6); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index 51cdcc688e682..fb3c8556029b2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -13,7 +13,6 @@ import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; import { SideNav, SideNavLink } from '../../../shared/layout'; import { NAV } from '../../constants'; import { - ALPHA_PATH, SOURCES_PATH, SECURITY_PATH, ROLE_MAPPINGS_PATH, @@ -33,7 +32,7 @@ export const WorkplaceSearchNav: React.FC<Props> = ({ settingsSubNav, }) => ( <SideNav product={WORKPLACE_SEARCH_PLUGIN}> - <SideNavLink to={ALPHA_PATH} isRoot> + <SideNavLink to="/" isRoot> {NAV.OVERVIEW} </SideNavLink> <SideNavLink to={SOURCES_PATH} subNav={sourcesSubNav}> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index a2c0ec18def4b..2c2859e8f4427 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -18,7 +18,7 @@ import { Layout } from '../shared/layout'; import { WorkplaceSearchHeaderActions } from './components/layout'; import { SourceAdded } from './views/content_sources/components/source_added'; import { ErrorState } from './views/error_state'; -import { Overview as OverviewMVP } from './views/overview_mvp'; +import { Overview } from './views/overview'; import { SetupGuide } from './views/setup_guide'; import { WorkplaceSearch, WorkplaceSearchUnconfigured, WorkplaceSearchConfigured } from './'; @@ -61,7 +61,7 @@ describe('WorkplaceSearchConfigured', () => { const wrapper = shallow(<WorkplaceSearchConfigured />); expect(wrapper.find(Layout).first().prop('readOnlyMode')).toBeFalsy(); - expect(wrapper.find(OverviewMVP)).toHaveLength(1); + expect(wrapper.find(Overview)).toHaveLength(1); expect(mockKibanaValues.setChromeIsVisible).toHaveBeenCalledWith(true); expect(mockKibanaValues.renderHeaderActions).toHaveBeenCalledWith(WorkplaceSearchHeaderActions); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index a8d6fc54f7924..54085a9cd4467 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -20,7 +20,6 @@ import { NotFound } from '../shared/not_found'; import { AppLogic } from './app_logic'; import { WorkplaceSearchNav, WorkplaceSearchHeaderActions } from './components/layout'; import { - ALPHA_PATH, GROUPS_PATH, SETUP_GUIDE_PATH, SOURCES_PATH, @@ -38,7 +37,6 @@ import { ErrorState } from './views/error_state'; import { GroupsRouter } from './views/groups'; import { GroupSubNav } from './views/groups/components/group_sub_nav'; import { Overview } from './views/overview'; -import { Overview as OverviewMVP } from './views/overview_mvp'; import { RoleMappingsRouter } from './views/role_mappings'; import { Security } from './views/security'; import { SettingsRouter } from './views/settings'; @@ -92,7 +90,13 @@ export const WorkplaceSearchConfigured: React.FC<InitialAppData> = (props) => { <SourceAdded /> </Route> <Route exact path="/"> - {errorConnecting ? <ErrorState /> : <OverviewMVP />} + {errorConnecting ? ( + <ErrorState /> + ) : ( + <Layout navigation={<WorkplaceSearchNav />} restrictWidth readOnlyMode={readOnlyMode}> + <Overview /> + </Layout> + )} </Route> <Route path={PERSONAL_SOURCES_PATH}> <PrivateSourcesLayout restrictWidth readOnlyMode={readOnlyMode}> @@ -108,11 +112,6 @@ export const WorkplaceSearchConfigured: React.FC<InitialAppData> = (props) => { <SourcesRouter /> </Layout> </Route> - <Route path={ALPHA_PATH}> - <Layout navigation={<WorkplaceSearchNav />} restrictWidth readOnlyMode={readOnlyMode}> - <Overview /> - </Layout> - </Route> <Route path={GROUPS_PATH}> <Layout navigation={<WorkplaceSearchNav groupsSubNav={showGroupsSubnav && <GroupSubNav />} />} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index 59e43b103db40..0a6b6ef89b2a4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -60,7 +60,6 @@ export const GROUPS_PATH = '/groups'; export const GROUP_PATH = `${GROUPS_PATH}/:groupId`; export const GROUP_SOURCE_PRIORITIZATION_PATH = `${GROUPS_PATH}/:groupId/source_prioritization`; -export const ALPHA_PATH = '/alpha'; export const SOURCES_PATH = '/sources'; export const PERSONAL_SOURCES_PATH = `${PERSONAL_PATH}${SOURCES_PATH}`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/overview_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/overview_logic.mock.ts deleted file mode 100644 index 787354974cb31..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/overview_logic.mock.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { DEFAULT_INITIAL_APP_DATA } from '../../../../../../common/__mocks__'; -import { setMockValues as setMockKeaValues, setMockActions } from '../../../../__mocks__/kea.mock'; - -const { workplaceSearch: mockAppValues } = DEFAULT_INITIAL_APP_DATA; - -export const mockOverviewValues = { - accountsCount: 0, - activityFeed: [], - canCreateContentSources: false, - hasOrgSources: false, - hasUsers: false, - isOldAccount: false, - pendingInvitationsCount: 0, - personalSourcesCount: 0, - sourcesCount: 0, - dataLoading: true, -}; - -export const mockActions = { - initializeOverview: jest.fn(() => ({})), -}; - -const mockValues = { ...mockOverviewValues, ...mockAppValues, isFederatedAuth: true }; - -setMockActions({ ...mockActions }); -setMockKeaValues({ ...mockValues }); - -export const setMockValues = (values: object) => { - setMockKeaValues({ ...mockValues, ...values }); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.test.tsx deleted file mode 100644 index 68dece976a09c..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import '../../../__mocks__/kea.mock'; -import '../../../__mocks__/enterprise_search_url.mock'; -import { mockTelemetryActions } from '../../../__mocks__'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiEmptyPrompt, EuiButton, EuiButtonEmpty } from '@elastic/eui'; - -import { OnboardingCard } from './onboarding_card'; - -const cardProps = { - title: 'My card', - icon: 'icon', - description: 'this is a card', - actionTitle: 'action', - testSubj: 'actionButton', -}; - -describe('OnboardingCard', () => { - it('renders', () => { - const wrapper = shallow(<OnboardingCard {...cardProps} />); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); - }); - - it('renders an action button', () => { - const wrapper = shallow(<OnboardingCard {...cardProps} actionPath="/some_path" />); - const prompt = wrapper.find(EuiEmptyPrompt).dive(); - - expect(prompt.find(EuiButton)).toHaveLength(1); - expect(prompt.find(EuiButtonEmpty)).toHaveLength(0); - - const button = prompt.find('[data-test-subj="actionButton"]'); - expect(button.prop('href')).toBe('http://localhost:3002/ws/some_path'); - - button.simulate('click'); - expect(mockTelemetryActions.sendWorkplaceSearchTelemetry).toHaveBeenCalled(); - }); - - it('renders an empty button when onboarding is completed', () => { - const wrapper = shallow(<OnboardingCard {...cardProps} complete />); - const prompt = wrapper.find(EuiEmptyPrompt).dive(); - - expect(prompt.find(EuiButton)).toHaveLength(0); - expect(prompt.find(EuiButtonEmpty)).toHaveLength(1); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.tsx deleted file mode 100644 index 2f8d06b71fc27..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { useActions } from 'kea'; - -import { - EuiButton, - EuiButtonEmpty, - EuiFlexItem, - EuiPanel, - EuiEmptyPrompt, - IconType, - EuiButtonProps, - EuiButtonEmptyProps, - EuiLinkProps, -} from '@elastic/eui'; - -import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; -import { TelemetryLogic } from '../../../shared/telemetry'; - -interface OnboardingCardProps { - title: React.ReactNode; - icon: React.ReactNode; - description: React.ReactNode; - actionTitle: React.ReactNode; - testSubj: string; - actionPath?: string; - complete?: boolean; -} - -export const OnboardingCard: React.FC<OnboardingCardProps> = ({ - title, - icon, - description, - actionTitle, - testSubj, - actionPath, - complete, -}) => { - const { sendWorkplaceSearchTelemetry } = useActions(TelemetryLogic); - - const onClick = () => - sendWorkplaceSearchTelemetry({ - action: 'clicked', - metric: 'onboarding_card_button', - }); - const buttonActionProps = actionPath - ? { - onClick, - href: getWorkplaceSearchUrl(actionPath), - target: '_blank', - 'data-test-subj': testSubj, - } - : { - 'data-test-subj': testSubj, - }; - - const emptyButtonProps = { - ...buttonActionProps, - } as EuiButtonEmptyProps & EuiLinkProps; - const fillButtonProps = { - ...buttonActionProps, - color: 'secondary', - fill: true, - } as EuiButtonProps & EuiLinkProps; - - return ( - <EuiFlexItem> - <EuiPanel> - <EuiEmptyPrompt - iconType={complete ? 'checkInCircleFilled' : (icon as IconType)} - iconColor={complete ? 'secondary' : 'subdued'} - title={<h3>{title}</h3>} - body={description} - actions={ - complete ? ( - <EuiButtonEmpty {...emptyButtonProps}>{actionTitle}</EuiButtonEmpty> - ) : ( - <EuiButton {...fillButtonProps}>{actionTitle}</EuiButton> - ) - } - /> - </EuiPanel> - </EuiFlexItem> - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.test.tsx deleted file mode 100644 index 5059533519a6f..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { mockTelemetryActions } from '../../../__mocks__'; -import { setMockValues } from './__mocks__'; -import './__mocks__/overview_logic.mock'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { SOURCES_PATH, USERS_PATH } from '../../routes'; - -import { OnboardingCard } from './onboarding_card'; -import { OnboardingSteps, OrgNameOnboarding } from './onboarding_steps'; - -const account = { - id: '1', - isAdmin: true, - canCreatePersonalSources: true, - groups: [], - isCurated: false, - canCreateInvitations: true, -}; - -describe('OnboardingSteps', () => { - describe('Shared Sources', () => { - it('renders 0 sources state', () => { - setMockValues({ canCreateContentSources: true }); - const wrapper = shallow(<OnboardingSteps />); - - expect(wrapper.find(OnboardingCard)).toHaveLength(1); - expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(SOURCES_PATH); - expect(wrapper.find(OnboardingCard).prop('description')).toBe( - 'Add shared sources for your organization to start searching.' - ); - }); - - it('renders completed sources state', () => { - setMockValues({ sourcesCount: 2, hasOrgSources: true }); - const wrapper = shallow(<OnboardingSteps />); - - expect(wrapper.find(OnboardingCard).prop('description')).toEqual( - 'You have added 2 shared sources. Happy searching.' - ); - }); - - it('disables link when the user cannot create sources', () => { - setMockValues({ canCreateContentSources: false }); - const wrapper = shallow(<OnboardingSteps />); - - expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(undefined); - }); - }); - - describe('Users & Invitations', () => { - it('renders 0 users when not on federated auth', () => { - setMockValues({ - isFederatedAuth: false, - account, - accountsCount: 0, - hasUsers: false, - }); - const wrapper = shallow(<OnboardingSteps />); - - expect(wrapper.find(OnboardingCard)).toHaveLength(2); - expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(USERS_PATH); - expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual( - 'Invite your colleagues into this organization to search with you.' - ); - }); - - it('renders completed users state', () => { - setMockValues({ - isFederatedAuth: false, - account, - accountsCount: 1, - hasUsers: true, - }); - const wrapper = shallow(<OnboardingSteps />); - - expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual( - 'Nice, you’ve invited colleagues to search with you.' - ); - }); - - it('disables link when the user cannot create invitations', () => { - setMockValues({ - isFederatedAuth: false, - account: { - ...account, - canCreateInvitations: false, - }, - }); - const wrapper = shallow(<OnboardingSteps />); - expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(undefined); - }); - }); - - describe('Org Name', () => { - it('renders button to change name', () => { - setMockValues({ - organization: { - name: 'foo', - defaultOrgName: 'foo', - }, - }); - const wrapper = shallow(<OnboardingSteps />); - - const button = wrapper - .find(OrgNameOnboarding) - .dive() - .find('[data-test-subj="orgNameChangeButton"]'); - - button.simulate('click'); - expect(mockTelemetryActions.sendWorkplaceSearchTelemetry).toHaveBeenCalled(); - }); - - it('hides card when name has been changed', () => { - setMockValues({ - organization: { - name: 'foo', - defaultOrgName: 'bar', - }, - }); - const wrapper = shallow(<OnboardingSteps />); - - expect(wrapper.find(OrgNameOnboarding)).toHaveLength(0); - }); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.tsx deleted file mode 100644 index fc3998fcdfeec..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { useValues, useActions } from 'kea'; - -import { - EuiSpacer, - EuiButtonEmpty, - EuiTitle, - EuiPanel, - EuiIcon, - EuiFlexGrid, - EuiFlexItem, - EuiFlexGroup, - EuiButtonEmptyProps, - EuiLinkProps, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; -import { TelemetryLogic } from '../../../shared/telemetry'; -import { AppLogic } from '../../app_logic'; -import sharedSourcesIcon from '../../components/shared/assets/source_icons/share_circle.svg'; -import { ContentSection } from '../../components/shared/content_section'; -import { SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; - -import { OnboardingCard } from './onboarding_card'; -import { OverviewLogic } from './overview_logic'; - -const SOURCES_TITLE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.title', - { defaultMessage: 'Shared sources' } -); - -const USERS_TITLE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.title', - { defaultMessage: 'Users & invitations' } -); - -const ONBOARDING_SOURCES_CARD_DESCRIPTION = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.description', - { defaultMessage: 'Add shared sources for your organization to start searching.' } -); - -const USERS_CARD_DESCRIPTION = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.overviewUsersCard.title', - { defaultMessage: 'Nice, you’ve invited colleagues to search with you.' } -); - -const ONBOARDING_USERS_CARD_DESCRIPTION = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.description', - { defaultMessage: 'Invite your colleagues into this organization to search with you.' } -); - -export const OnboardingSteps: React.FC = () => { - const { - isFederatedAuth, - organization: { name, defaultOrgName }, - account: { isCurated, canCreateInvitations }, - } = useValues(AppLogic); - - const { - hasUsers, - hasOrgSources, - canCreateContentSources, - accountsCount, - sourcesCount, - } = useValues(OverviewLogic); - - const accountsPath = - !isFederatedAuth && (canCreateInvitations || isCurated) ? USERS_PATH : undefined; - const sourcesPath = canCreateContentSources || isCurated ? SOURCES_PATH : undefined; - - const SOURCES_CARD_DESCRIPTION = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.sourcesOnboardingCard.description', - { - defaultMessage: - 'You have added {sourcesCount, number} shared {sourcesCount, plural, one {source} other {sources}}. Happy searching.', - values: { sourcesCount }, - } - ); - - return ( - <ContentSection> - <EuiFlexGrid columns={isFederatedAuth ? 1 : 2}> - <OnboardingCard - title={SOURCES_TITLE} - testSubj="sharedSourcesButton" - icon={sharedSourcesIcon} - description={ - hasOrgSources ? SOURCES_CARD_DESCRIPTION : ONBOARDING_SOURCES_CARD_DESCRIPTION - } - actionTitle={i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.sourcesOnboardingCard.buttonLabel', - { - defaultMessage: 'Add {label} sources', - values: { label: sourcesCount > 0 ? 'more' : '' }, - } - )} - actionPath={sourcesPath} - complete={hasOrgSources} - /> - {!isFederatedAuth && ( - <OnboardingCard - title={USERS_TITLE} - testSubj="usersButton" - icon="user" - description={hasUsers ? USERS_CARD_DESCRIPTION : ONBOARDING_USERS_CARD_DESCRIPTION} - actionTitle={i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.usersOnboardingCard.buttonLabel', - { - defaultMessage: 'Invite {label} users', - values: { label: accountsCount > 0 ? 'more' : '' }, - } - )} - actionPath={accountsPath} - complete={hasUsers} - /> - )} - </EuiFlexGrid> - {name === defaultOrgName && ( - <> - <EuiSpacer /> - <OrgNameOnboarding /> - </> - )} - </ContentSection> - ); -}; - -export const OrgNameOnboarding: React.FC = () => { - const { sendWorkplaceSearchTelemetry } = useActions(TelemetryLogic); - - const onClick = () => - sendWorkplaceSearchTelemetry({ - action: 'clicked', - metric: 'org_name_change_button', - }); - - const buttonProps = { - onClick, - target: '_blank', - color: 'primary', - href: getWorkplaceSearchUrl(ORG_SETTINGS_PATH), - 'data-test-subj': 'orgNameChangeButton', - } as EuiButtonEmptyProps & EuiLinkProps; - - return ( - <EuiPanel paddingSize="l"> - <EuiFlexGroup justifyContent="spaceBetween" alignItems="center" responsive={false}> - <EuiFlexItem className="eui-hideFor--xs eui-hideFor--s" grow={false}> - <EuiIcon type="training" color="subdued" size="xl" /> - </EuiFlexItem> - <EuiFlexItem> - <EuiTitle size="xs"> - <h4> - <FormattedMessage - id="xpack.enterpriseSearch.workplaceSearch.orgNameOnboarding.description" - defaultMessage="Before inviting your colleagues, name your organization to improve recognition." - /> - </h4> - </EuiTitle> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButtonEmpty {...buttonProps}> - <FormattedMessage - id="xpack.enterpriseSearch.workplaceSearch.orgNameOnboarding.buttonLabel" - defaultMessage="Name your organization" - /> - </EuiButtonEmpty> - </EuiFlexItem> - </EuiFlexGroup> - </EuiPanel> - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.test.tsx deleted file mode 100644 index 110557ac4087a..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { setMockValues } from './__mocks__'; -import './__mocks__/overview_logic.mock'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiFlexGrid } from '@elastic/eui'; - -import { OrganizationStats } from './organization_stats'; -import { StatisticCard } from './statistic_card'; - -describe('OrganizationStats', () => { - it('renders', () => { - const wrapper = shallow(<OrganizationStats />); - - expect(wrapper.find(StatisticCard)).toHaveLength(2); - expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(2); - }); - - it('renders additional cards for federated auth', () => { - setMockValues({ isFederatedAuth: false }); - const wrapper = shallow(<OrganizationStats />); - - expect(wrapper.find(StatisticCard)).toHaveLength(4); - expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(4); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.tsx deleted file mode 100644 index 525035030b8cc..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { useValues } from 'kea'; - -import { EuiFlexGrid } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { AppLogic } from '../../app_logic'; -import { ContentSection } from '../../components/shared/content_section'; -import { SOURCES_PATH, USERS_PATH } from '../../routes'; - -import { OverviewLogic } from './overview_logic'; -import { StatisticCard } from './statistic_card'; - -export const OrganizationStats: React.FC = () => { - const { isFederatedAuth } = useValues(AppLogic); - - const { sourcesCount, pendingInvitationsCount, accountsCount, personalSourcesCount } = useValues( - OverviewLogic - ); - - return ( - <ContentSection - title={ - <FormattedMessage - id="xpack.enterpriseSearch.workplaceSearch.organizationStats.title" - defaultMessage="Usage statistics" - /> - } - headerSpacer="m" - > - <EuiFlexGrid columns={isFederatedAuth ? 2 : 4}> - <StatisticCard - title={i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.organizationStats.sharedSources', - { defaultMessage: 'Shared sources' } - )} - count={sourcesCount} - actionPath={SOURCES_PATH} - /> - {!isFederatedAuth && ( - <> - <StatisticCard - title={i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.organizationStats.invitations', - { defaultMessage: 'Invitations' } - )} - count={pendingInvitationsCount} - actionPath={USERS_PATH} - /> - <StatisticCard - title={i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.organizationStats.activeUsers', - { defaultMessage: 'Active users' } - )} - count={accountsCount} - actionPath={USERS_PATH} - /> - </> - )} - <StatisticCard - title={i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.organizationStats.privateSources', - { defaultMessage: 'Private sources' } - )} - count={personalSourcesCount} - /> - </EuiFlexGrid> - </ContentSection> - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.test.tsx deleted file mode 100644 index 19c893bec81ea..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import '../../../__mocks__/react_router_history.mock'; -import './__mocks__/overview_logic.mock'; -import { mockActions, setMockValues } from './__mocks__'; - -import React from 'react'; - -import { shallow, mount } from 'enzyme'; - -import { Loading } from '../../../shared/loading'; -import { ViewContentHeader } from '../../components/shared/view_content_header'; - -import { OnboardingSteps } from './onboarding_steps'; -import { OrganizationStats } from './organization_stats'; -import { Overview } from './overview'; -import { RecentActivity } from './recent_activity'; - -describe('Overview', () => { - describe('non-happy-path states', () => { - it('isLoading', () => { - const wrapper = shallow(<Overview />); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - }); - - describe('happy-path states', () => { - it('calls initialize function', async () => { - mount(<Overview />); - - expect(mockActions.initializeOverview).toHaveBeenCalled(); - }); - - it('renders onboarding state', () => { - setMockValues({ dataLoading: false }); - const wrapper = shallow(<Overview />); - - expect(wrapper.find(ViewContentHeader)).toHaveLength(1); - expect(wrapper.find(OnboardingSteps)).toHaveLength(1); - expect(wrapper.find(OrganizationStats)).toHaveLength(1); - expect(wrapper.find(RecentActivity)).toHaveLength(1); - }); - - it('renders when onboarding complete', () => { - setMockValues({ - dataLoading: false, - hasUsers: true, - hasOrgSources: true, - isOldAccount: true, - organization: { - name: 'foo', - defaultOrgName: 'bar', - }, - }); - const wrapper = shallow(<Overview />); - - expect(wrapper.find(OnboardingSteps)).toHaveLength(0); - }); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.tsx deleted file mode 100644 index 6bf84b585da80..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// TODO: Remove EuiPage & EuiPageBody before exposing full app - -import React, { useEffect } from 'react'; - -import { useActions, useValues } from 'kea'; - -import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; -import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -import { AppLogic } from '../../app_logic'; -import { ProductButton } from '../../components/shared/product_button'; -import { ViewContentHeader } from '../../components/shared/view_content_header'; - -import { OnboardingSteps } from './onboarding_steps'; -import { OrganizationStats } from './organization_stats'; -import { OverviewLogic } from './overview_logic'; -import { RecentActivity } from './recent_activity'; - -const ONBOARDING_HEADER_TITLE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.title', - { defaultMessage: 'Get started with Workplace Search' } -); - -const HEADER_TITLE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.overviewHeader.title', { - defaultMessage: 'Organization overview', -}); - -const ONBOARDING_HEADER_DESCRIPTION = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.description', - { defaultMessage: 'Complete the following to set up your organization.' } -); - -const HEADER_DESCRIPTION = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.overviewHeader.description', - { defaultMessage: "Your organizations's statistics and activity" } -); - -export const Overview: React.FC = () => { - const { - organization: { name: orgName, defaultOrgName }, - } = useValues(AppLogic); - - const { initializeOverview } = useActions(OverviewLogic); - const { dataLoading, hasUsers, hasOrgSources, isOldAccount } = useValues(OverviewLogic); - - useEffect(() => { - initializeOverview(); - }, [initializeOverview]); - - // TODO: Remove div wrapper once the Overview page is using the full Layout - if (dataLoading) { - return ( - <div style={{ height: '90vh' }}> - <Loading /> - </div> - ); - } - - const hideOnboarding = hasUsers && hasOrgSources && isOldAccount && orgName !== defaultOrgName; - - const headerTitle = hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE; - const headerDescription = hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; - - return ( - <EuiPage restrictWidth> - <SetPageChrome /> - <SendTelemetry action="viewed" metric="overview" /> - - <EuiPageBody> - <ViewContentHeader - title={headerTitle} - description={headerDescription} - action={<ProductButton />} - /> - {!hideOnboarding && <OnboardingSteps />} - <EuiSpacer size="xl" /> - <OrganizationStats /> - <EuiSpacer size="xl" /> - <RecentActivity /> - </EuiPageBody> - </EuiPage> - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.test.ts deleted file mode 100644 index 75a41216ffbb7..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { LogicMounter, mockHttpValues } from '../../../__mocks__'; -import { mockOverviewValues } from './__mocks__'; - -import { OverviewLogic } from './overview_logic'; - -describe('OverviewLogic', () => { - const { mount } = new LogicMounter(OverviewLogic); - const { http } = mockHttpValues; - - beforeEach(() => { - jest.clearAllMocks(); - mount(); - }); - - it('has expected default values', () => { - expect(OverviewLogic.values).toEqual(mockOverviewValues); - }); - - describe('setServerData', () => { - const feed = [{ foo: 'bar' }] as any; - - const data = { - accountsCount: 1, - activityFeed: feed, - canCreateContentSources: true, - hasOrgSources: true, - hasUsers: true, - isOldAccount: true, - pendingInvitationsCount: 1, - personalSourcesCount: 1, - sourcesCount: 1, - }; - - beforeEach(() => { - OverviewLogic.actions.setServerData(data); - }); - - it('will set `dataLoading` to false', () => { - expect(OverviewLogic.values.dataLoading).toEqual(false); - }); - - it('will set server values', () => { - expect(OverviewLogic.values.hasUsers).toEqual(true); - expect(OverviewLogic.values.hasOrgSources).toEqual(true); - expect(OverviewLogic.values.canCreateContentSources).toEqual(true); - expect(OverviewLogic.values.isOldAccount).toEqual(true); - expect(OverviewLogic.values.sourcesCount).toEqual(1); - expect(OverviewLogic.values.pendingInvitationsCount).toEqual(1); - expect(OverviewLogic.values.accountsCount).toEqual(1); - expect(OverviewLogic.values.personalSourcesCount).toEqual(1); - expect(OverviewLogic.values.activityFeed).toEqual(feed); - }); - }); - - describe('initializeOverview', () => { - it('calls API and sets values', async () => { - const setServerDataSpy = jest.spyOn(OverviewLogic.actions, 'setServerData'); - - await OverviewLogic.actions.initializeOverview(); - - expect(http.get).toHaveBeenCalledWith('/api/workplace_search/overview'); - expect(setServerDataSpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.ts deleted file mode 100644 index 7d8bc95529483..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { kea, MakeLogicType } from 'kea'; - -import { flashAPIErrors } from '../../../shared/flash_messages'; -import { HttpLogic } from '../../../shared/http'; - -import { FeedActivity } from './recent_activity'; - -interface OverviewServerData { - hasUsers: boolean; - hasOrgSources: boolean; - canCreateContentSources: boolean; - isOldAccount: boolean; - sourcesCount: number; - pendingInvitationsCount: number; - accountsCount: number; - personalSourcesCount: number; - activityFeed: FeedActivity[]; -} - -interface OverviewActions { - setServerData(serverData: OverviewServerData): OverviewServerData; - initializeOverview(): void; -} - -interface OverviewValues extends OverviewServerData { - dataLoading: boolean; -} - -export const OverviewLogic = kea<MakeLogicType<OverviewValues, OverviewActions>>({ - path: ['enterprise_search', 'workplace_search', 'overview_logic'], - actions: { - setServerData: (serverData) => serverData, - initializeOverview: () => null, - }, - reducers: { - hasUsers: [ - false, - { - setServerData: (_, { hasUsers }) => hasUsers, - }, - ], - hasOrgSources: [ - false, - { - setServerData: (_, { hasOrgSources }) => hasOrgSources, - }, - ], - canCreateContentSources: [ - false, - { - setServerData: (_, { canCreateContentSources }) => canCreateContentSources, - }, - ], - isOldAccount: [ - false, - { - setServerData: (_, { isOldAccount }) => isOldAccount, - }, - ], - sourcesCount: [ - 0, - { - setServerData: (_, { sourcesCount }) => sourcesCount, - }, - ], - pendingInvitationsCount: [ - 0, - { - setServerData: (_, { pendingInvitationsCount }) => pendingInvitationsCount, - }, - ], - accountsCount: [ - 0, - { - setServerData: (_, { accountsCount }) => accountsCount, - }, - ], - personalSourcesCount: [ - 0, - { - setServerData: (_, { personalSourcesCount }) => personalSourcesCount, - }, - ], - activityFeed: [ - [], - { - setServerData: (_, { activityFeed }) => activityFeed, - }, - ], - dataLoading: [ - true, - { - setServerData: () => false, - }, - ], - }, - listeners: ({ actions }) => ({ - initializeOverview: async () => { - try { - const response = await HttpLogic.values.http.get('/api/workplace_search/overview'); - actions.setServerData(response); - } catch (e) { - flashAPIErrors(e); - } - }, - }), -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.scss deleted file mode 100644 index 822ba64c91237..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.scss +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -.activity { - display: flex; - justify-content: space-between; - padding: $euiSizeM; - font-size: $euiFontSizeS; - - &--error { - font-weight: $euiFontWeightSemiBold; - color: $euiColorDanger; - background: rgba($euiColorDanger, .1); - - &__label { - margin-left: $euiSizeS * 1.75; - font-weight: $euiFontWeightRegular; - text-decoration: underline; - opacity: .7; - } - } - - &__message { - flex-grow: 1; - } - - &__date { - flex-grow: 0; - } - - & + & { - border-top: $euiBorderThin; - } -} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.test.tsx deleted file mode 100644 index 7213526c8864a..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { mockTelemetryActions } from '../../../__mocks__'; -import { setMockValues } from './__mocks__'; -import './__mocks__/overview_logic.mock'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiEmptyPrompt, EuiLink } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { RecentActivity, RecentActivityItem } from './recent_activity'; - -const organization = { name: 'foo', defaultOrgName: 'bar' }; - -const activityFeed = [ - { - id: 'demo', - sourceId: 'd2d2d23d', - message: 'was successfully connected', - target: 'http://localhost:3002/ws/org/sources', - timestamp: '2020-06-24 16:34:16', - }, -]; - -describe('RecentActivity', () => { - it('renders with no activityFeed data', () => { - const wrapper = shallow(<RecentActivity />); - - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); - - // Branch coverage - renders without error for custom org name - setMockValues({ organization }); - shallow(<RecentActivity />); - }); - - it('renders an activityFeed with links', () => { - setMockValues({ activityFeed }); - const wrapper = shallow(<RecentActivity />); - const activity = wrapper.find(RecentActivityItem).dive(); - - expect(activity).toHaveLength(1); - - const link = activity.find('[data-test-subj="viewSourceDetailsLink"]'); - link.simulate('click'); - expect(mockTelemetryActions.sendWorkplaceSearchTelemetry).toHaveBeenCalled(); - }); - - it('renders activity item error state', () => { - const props = { ...activityFeed[0], status: 'error' }; - const wrapper = shallow(<RecentActivityItem {...props} />); - - expect(wrapper.find('.activity--error')).toHaveLength(1); - expect(wrapper.find('.activity--error__label')).toHaveLength(1); - expect(wrapper.find(EuiLink).prop('color')).toEqual('danger'); - }); - - it('renders recent activity message for default org name', () => { - setMockValues({ - organization: { - name: 'foo', - defaultOrgName: 'foo', - }, - }); - const wrapper = shallow(<RecentActivity />); - const emptyPrompt = wrapper.find(EuiEmptyPrompt).dive(); - - expect(emptyPrompt.find(FormattedMessage).prop('defaultMessage')).toEqual( - 'Your organization has no recent activity' - ); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.tsx deleted file mode 100644 index 43d3f880feef4..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.tsx +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { useValues, useActions } from 'kea'; -import moment from 'moment'; - -import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiSpacer, EuiLinkProps } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; -import { TelemetryLogic } from '../../../shared/telemetry'; -import { AppLogic } from '../../app_logic'; -import { ContentSection } from '../../components/shared/content_section'; -import { RECENT_ACTIVITY_TITLE } from '../../constants'; -import { SOURCE_DETAILS_PATH, getContentSourcePath } from '../../routes'; - -import { OverviewLogic } from './overview_logic'; - -import './recent_activity.scss'; - -export interface FeedActivity { - status?: string; - id: string; - message: string; - timestamp: string; - sourceId: string; -} - -export const RecentActivity: React.FC = () => { - const { - organization: { name, defaultOrgName }, - } = useValues(AppLogic); - - const { activityFeed } = useValues(OverviewLogic); - - return ( - <ContentSection title={RECENT_ACTIVITY_TITLE} headerSpacer="m"> - <EuiPanel> - {activityFeed.length > 0 ? ( - <> - {activityFeed.map((props: FeedActivity, index) => ( - <RecentActivityItem {...props} key={index} /> - ))} - </> - ) : ( - <> - <EuiSpacer size="xl" /> - <EuiEmptyPrompt - iconType="clock" - iconColor="subdued" - titleSize="s" - title={ - <h3> - {name === defaultOrgName ? ( - <FormattedMessage - id="xpack.enterpriseSearch.workplaceSearch.activityFeedEmptyDefault.title" - defaultMessage="Your organization has no recent activity" - /> - ) : ( - <FormattedMessage - id="xpack.enterpriseSearch.workplaceSearch.activityFeedNamedDefault.title" - defaultMessage="{name} has no recent activity" - values={{ name }} - /> - )} - </h3> - } - /> - <EuiSpacer size="xl" /> - </> - )} - </EuiPanel> - </ContentSection> - ); -}; - -export const RecentActivityItem: React.FC<FeedActivity> = ({ - id, - status, - message, - timestamp, - sourceId, -}) => { - const { sendWorkplaceSearchTelemetry } = useActions(TelemetryLogic); - - const onClick = () => - sendWorkplaceSearchTelemetry({ - action: 'clicked', - metric: 'recent_activity_source_details_link', - }); - - const linkProps = { - onClick, - target: '_blank', - href: getWorkplaceSearchUrl(getContentSourcePath(SOURCE_DETAILS_PATH, sourceId, true)), - external: true, - color: status === 'error' ? 'danger' : 'primary', - 'data-test-subj': 'viewSourceDetailsLink', - } as EuiLinkProps; - - return ( - <div className={`activity ${status ? `activity--${status}` : ''}`}> - <div className="activity__message"> - <EuiLink {...linkProps}> - {id} {message} - {status === 'error' && ( - <span className="activity--error__label"> - {' '} - <FormattedMessage - id="xpack.enterpriseSearch.workplaceSearch.recentActivitySourceLink.linkLabel" - defaultMessage="View Source" - /> - </span> - )} - </EuiLink> - </div> - <div className="activity__date">{moment.utc(timestamp).fromNow()}</div> - </div> - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.test.tsx deleted file mode 100644 index ff1d69e406830..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import '../../../__mocks__/enterprise_search_url.mock'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiCard } from '@elastic/eui'; - -import { StatisticCard } from './statistic_card'; - -const props = { - title: 'foo', -}; - -describe('StatisticCard', () => { - it('renders', () => { - const wrapper = shallow(<StatisticCard {...props} />); - - expect(wrapper.find(EuiCard)).toHaveLength(1); - }); - - it('renders clickable card', () => { - const wrapper = shallow(<StatisticCard {...props} actionPath="/foo" />); - - expect(wrapper.find(EuiCard).prop('href')).toBe('http://localhost:3002/ws/foo'); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.tsx deleted file mode 100644 index 346debb1c5251..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { EuiCard, EuiFlexItem, EuiTitle, EuiTextColor } from '@elastic/eui'; - -import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; - -interface StatisticCardProps { - title: string; - count?: number; - actionPath?: string; -} - -export const StatisticCard: React.FC<StatisticCardProps> = ({ title, count = 0, actionPath }) => { - const linkProps = actionPath - ? { - href: getWorkplaceSearchUrl(actionPath), - target: '_blank', - rel: 'noopener', - } - : {}; - // TODO: When we port this destination to Kibana, we'll want to create a EuiReactRouterCard component (see shared/react_router_helpers/eui_link.tsx) - - return ( - <EuiFlexItem> - <EuiCard - {...linkProps} - layout="horizontal" - title={title} - titleSize="xs" - description={ - <EuiTitle size="l"> - <EuiTextColor color={actionPath ? 'default' : 'subdued'}>{count}</EuiTextColor> - </EuiTitle> - } - /> - </EuiFlexItem> - ); -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx index 04772860c9fe7..8c8a5ae56c3ba 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx @@ -248,9 +248,8 @@ export const AnomaliesTable = (props: Props) => { }, defaultPaginationOptions: { pageSize: 10 }, }), - [timeRange, sorting?.field, sorting?.direction, anomalyThreshold] + [timeRange.start, timeRange.end, sorting?.field, sorting?.direction, anomalyThreshold] ); - const { metricsHostsAnomalies, getMetricsHostsAnomalies, diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_metrics_hosts_anomalies.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_metrics_hosts_anomalies.ts index b1401f268dc51..b28a0ff0b4788 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_metrics_hosts_anomalies.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_metrics_hosts_anomalies.ts @@ -224,7 +224,8 @@ export const useMetricsHostsAnomaliesResults = ({ sourceId, anomalyThreshold, dispatch, - reducerState.timeRange, + reducerState.timeRange.start, + reducerState.timeRange.end, reducerState.sortOptions, reducerState.paginationOptions, reducerState.paginationCursor, diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_metrics_k8s_anomalies.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_metrics_k8s_anomalies.ts index ad26c14df32b4..384cefa691d96 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_metrics_k8s_anomalies.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_metrics_k8s_anomalies.ts @@ -221,7 +221,8 @@ export const useMetricsK8sAnomaliesResults = ({ sourceId, anomalyThreshold, dispatch, - reducerState.timeRange, + reducerState.timeRange.start, + reducerState.timeRange.end, reducerState.sortOptions, reducerState.paginationOptions, reducerState.paginationCursor, diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index 27e0fa29b1e55..177f0a4b291d5 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -51,6 +51,7 @@ export const OPERATOR_EXCLUDED = 'excluded'; export const ENTRY_VALUE = 'some host name'; export const MATCH = 'match'; export const MATCH_ANY = 'match_any'; +export const WILDCARD = 'wildcard'; export const MAX_IMPORT_PAYLOAD_BYTES = 9000000; export const IMPORT_BUFFER_SIZE = 1000; export const LIST = 'list'; diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.ts b/x-pack/plugins/lists/common/schemas/common/schemas.ts index f261e4e3eefa6..7e43e7dd5f4ab 100644 --- a/x-pack/plugins/lists/common/schemas/common/schemas.ts +++ b/x-pack/plugins/lists/common/schemas/common/schemas.ts @@ -287,6 +287,7 @@ export enum OperatorTypeEnum { NESTED = 'nested', MATCH = 'match', MATCH_ANY = 'match_any', + WILDCARD = 'wildcard', EXISTS = 'exists', LIST = 'list', } diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_wildcard.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_wildcard.ts new file mode 100644 index 0000000000000..dfcaa963666de --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_wildcard.ts @@ -0,0 +1,21 @@ +/* + * 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'; + +import { NonEmptyString } from '../../../shared_imports'; +import { operatorIncluded } from '../../common/schemas'; + +export const endpointEntryMatchWildcard = t.exact( + t.type({ + field: NonEmptyString, + operator: operatorIncluded, + type: t.keyof({ wildcard: null }), + value: NonEmptyString, + }) +); +export type EndpointEntryMatchWildcard = t.TypeOf<typeof endpointEntryMatchWildcard>; diff --git a/x-pack/plugins/lists/common/schemas/types/entries.ts b/x-pack/plugins/lists/common/schemas/types/entries.ts index 277751bf1c271..26cfed568cea8 100644 --- a/x-pack/plugins/lists/common/schemas/types/entries.ts +++ b/x-pack/plugins/lists/common/schemas/types/entries.ts @@ -12,12 +12,28 @@ import { entriesMatch } from './entry_match'; import { entriesExists } from './entry_exists'; import { entriesList } from './entry_list'; import { entriesNested } from './entry_nested'; +import { entriesMatchWildcard } from './entry_match_wildcard'; -export const entry = t.union([entriesMatch, entriesMatchAny, entriesList, entriesExists]); +// NOTE: Type nested is not included here to denote it's non-recursive nature. +// So a nested entry is really just a collection of `Entry` types. +export const entry = t.union([ + entriesMatch, + entriesMatchAny, + entriesList, + entriesExists, + entriesMatchWildcard, +]); export type Entry = t.TypeOf<typeof entry>; export const entriesArray = t.array( - t.union([entriesMatch, entriesMatchAny, entriesList, entriesExists, entriesNested]) + t.union([ + entriesMatch, + entriesMatchAny, + entriesList, + entriesExists, + entriesNested, + entriesMatchWildcard, + ]) ); export type EntriesArray = t.TypeOf<typeof entriesArray>; diff --git a/x-pack/plugins/lists/common/schemas/types/entry_match_wildcard.mock.ts b/x-pack/plugins/lists/common/schemas/types/entry_match_wildcard.mock.ts new file mode 100644 index 0000000000000..3204bbe064496 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_match_wildcard.mock.ts @@ -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 { ENTRY_VALUE, FIELD, OPERATOR, WILDCARD } from '../../constants.mock'; + +import { EntryMatchWildcard } from './entry_match_wildcard'; + +export const getEntryMatchWildcardMock = (): EntryMatchWildcard => ({ + field: FIELD, + operator: OPERATOR, + type: WILDCARD, + value: ENTRY_VALUE, +}); + +export const getEntryMatchWildcardExcludeMock = (): EntryMatchWildcard => ({ + ...getEntryMatchWildcardMock(), + operator: 'excluded', +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_match_wildcard.test.ts b/x-pack/plugins/lists/common/schemas/types/entry_match_wildcard.test.ts new file mode 100644 index 0000000000000..53cfc4fdff1f5 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_match_wildcard.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../shared_imports'; + +import { getEntryMatchWildcardMock } from './entry_match_wildcard.mock'; +import { EntryMatchWildcard, entriesMatchWildcard } from './entry_match_wildcard'; + +describe('entriesMatchWildcard', () => { + test('it should validate an entry', () => { + const payload = getEntryMatchWildcardMock(); + const decoded = entriesMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when operator is "included"', () => { + const payload = getEntryMatchWildcardMock(); + const decoded = entriesMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when "operator" is "excluded"', () => { + const payload = getEntryMatchWildcardMock(); + payload.operator = 'excluded'; + const decoded = entriesMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should FAIL validation when "field" is empty string', () => { + const payload: Omit<EntryMatchWildcard, 'field'> & { field: string } = { + ...getEntryMatchWildcardMock(), + field: '', + }; + const decoded = entriesMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "field"']); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "value" is not string', () => { + const payload: Omit<EntryMatchWildcard, 'value'> & { value: string[] } = { + ...getEntryMatchWildcardMock(), + value: ['some value'], + }; + const decoded = entriesMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "["some value"]" supplied to "value"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "value" is empty string', () => { + const payload: Omit<EntryMatchWildcard, 'value'> & { value: string } = { + ...getEntryMatchWildcardMock(), + value: '', + }; + const decoded = entriesMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "value"']); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "type" is not "wildcard"', () => { + const payload: Omit<EntryMatchWildcard, 'type'> & { type: string } = { + ...getEntryMatchWildcardMock(), + type: 'match', + }; + const decoded = entriesMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: EntryMatchWildcard & { + extraKey?: string; + } = getEntryMatchWildcardMock(); + payload.extraKey = 'some value'; + const decoded = entriesMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getEntryMatchWildcardMock()); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_match_wildcard.ts b/x-pack/plugins/lists/common/schemas/types/entry_match_wildcard.ts new file mode 100644 index 0000000000000..14522256df354 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_match_wildcard.ts @@ -0,0 +1,21 @@ +/* + * 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'; + +import { NonEmptyString } from '../../shared_imports'; +import { operator } from '../common/schemas'; + +export const entriesMatchWildcard = t.exact( + t.type({ + field: NonEmptyString, + operator, + type: t.keyof({ wildcard: null }), + value: NonEmptyString, + }) +); +export type EntryMatchWildcard = t.TypeOf<typeof entriesMatchWildcard>; diff --git a/x-pack/plugins/lists/common/schemas/types/index.ts b/x-pack/plugins/lists/common/schemas/types/index.ts index 98342f3b9c153..ebe21174570cb 100644 --- a/x-pack/plugins/lists/common/schemas/types/index.ts +++ b/x-pack/plugins/lists/common/schemas/types/index.ts @@ -15,6 +15,7 @@ export * from './default_namespace'; export * from './entries'; export * from './entry_match'; export * from './entry_match_any'; +export * from './entry_match_wildcard'; export * from './entry_list'; export * from './entry_exists'; export * from './entry_nested'; diff --git a/x-pack/plugins/lists/common/shared_exports.ts b/x-pack/plugins/lists/common/shared_exports.ts index 286fee6de5425..8be53cb8cddbc 100644 --- a/x-pack/plugins/lists/common/shared_exports.ts +++ b/x-pack/plugins/lists/common/shared_exports.ts @@ -20,6 +20,7 @@ export { EntryExists, EntryMatch, EntryMatchAny, + EntryMatchWildcard, EntryNested, EntryList, EntriesArray, @@ -39,6 +40,7 @@ export { nestedEntryItem, entriesMatch, entriesMatchAny, + entriesMatchWildcard, entriesExists, entriesList, namespaceType, diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/types.ts b/x-pack/plugins/lists/public/exceptions/components/builder/types.ts index cdb4f735aa103..800f1445217b9 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/types.ts +++ b/x-pack/plugins/lists/public/exceptions/components/builder/types.ts @@ -13,6 +13,7 @@ import { EntryExists, EntryMatch, EntryMatchAny, + EntryMatchWildcard, EntryNested, ExceptionListItemSchema, OperatorEnum, @@ -34,7 +35,7 @@ export interface EmptyEntry { id: string; field: string | undefined; operator: OperatorEnum; - type: OperatorTypeEnum.MATCH | OperatorTypeEnum.MATCH_ANY; + type: OperatorTypeEnum.MATCH | OperatorTypeEnum.MATCH_ANY | OperatorTypeEnum.WILDCARD; value: string | string[] | undefined; } @@ -53,6 +54,7 @@ export interface EmptyNestedEntry { entries: Array< | (EntryMatch & { id?: string }) | (EntryMatchAny & { id?: string }) + | (EntryMatchWildcard & { id?: string }) | (EntryExists & { id?: string }) >; } @@ -69,6 +71,7 @@ export type BuilderEntryNested = Omit<EntryNested, 'entries'> & { entries: Array< | (EntryMatch & { id?: string }) | (EntryMatchAny & { id?: string }) + | (EntryMatchWildcard & { id?: string }) | (EntryExists & { id?: string }) >; }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx index e7a6874870fb2..ea69a371cedae 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx @@ -77,18 +77,18 @@ export const EMPTY_LABEL = i18n.translate('xpack.observability.expView.seriesBui export const CHOOSE_REPORT_DEFINITION = i18n.translate( 'xpack.observability.expView.seriesBuilder.emptyReportDefinition', { - defaultMessage: 'Please choose a report definition below to visualize.', + defaultMessage: 'Select a report type to create a visualization.', } ); export const SELECT_REPORT_TYPE_BELOW = i18n.translate( 'xpack.observability.expView.seriesBuilder.selectReportType.empty', { - defaultMessage: 'Please Select a report type below to define visualization.', + defaultMessage: 'Select a report type to create a visualization.', } ); const SELECTED_DATA_TYPE_FOR_REPORT = i18n.translate( 'xpack.observability.expView.reportType.selectDataType', - { defaultMessage: 'Please Select a data type below to start building a series.' } + { defaultMessage: 'Select a data type to create a visualization.' } ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx index bc39bf5b27daa..19136cda6387c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx @@ -21,7 +21,7 @@ import { SeriesBuilder } from './series_builder/series_builder'; export function ExploratoryView() { const { - services: { lens }, + services: { lens, notifications }, } = useKibana<ObservabilityPublicPluginsStart>(); const seriesBuilderRef = useRef<HTMLDivElement>(null); @@ -37,7 +37,7 @@ export function ExploratoryView() { const LensComponent = lens?.EmbeddableComponent; - const { firstSeriesId: seriesId, firstSeries: series } = useUrlStorage(); + const { firstSeriesId: seriesId, firstSeries: series, setSeries } = useUrlStorage(); const lensAttributesT = useLensAttributes({ seriesId, @@ -77,6 +77,24 @@ export function ExploratoryView() { id="exploratoryView" timeRange={series?.time} attributes={lensAttributes} + onBrushEnd={({ range }) => { + if (series?.reportType !== 'pld') { + setSeries(seriesId, { + ...series, + time: { + from: new Date(range[0]).toISOString(), + to: new Date(range[1]).toISOString(), + }, + }); + } else { + notifications?.toasts.add( + i18n.translate('xpack.observability.exploratoryView.noBrusing', { + defaultMessage: + 'Zoom by brush selection is only available on time series charts.', + }) + ); + } + }} /> ) : ( <EmptyView series={series} loading={loading} height={height} /> diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index 7ac0961532b65..9d051e89e1a38 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -36,6 +36,9 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { defaultMessage: 'Exploratory view', })}{' '} <EuiBetaBadge + style={{ + verticalAlign: `middle`, + }} label={i18n.translate('xpack.observability.expView.heading.experimental', { defaultMessage: 'Experimental', })} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx index df5b57124f0e7..d3c4cee6d7dc1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx @@ -94,6 +94,7 @@ export function XYChartTypesSelect({ return ( <EuiSuperSelect + fullWidth compressed prepend="Chart type" valueOfSelected={value} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx index b33671f78bfe9..6377165d7473f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx @@ -74,6 +74,7 @@ export function OperationTypeSelect({ return ( <EuiSuperSelect + fullWidth prepend="Calculation" data-test-subj="operationTypeSelect" compressed diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx index f7520fb64f211..717309e064ba3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx @@ -29,8 +29,6 @@ function getColumnType(dataView: DataSeries, selectedDefinition: URLReportDefini return null; } -const MaxWidthStyle = { maxWidth: 250 }; - export function ReportDefinitionCol({ dataViewSeries, seriesId, @@ -89,14 +87,14 @@ export function ReportDefinitionCol({ </EuiFlexItem> ))} {(hasOperationType || columnType === 'operation') && ( - <EuiFlexItem style={MaxWidthStyle}> + <EuiFlexItem> <OperationTypeSelect seriesId={seriesId} defaultOperationType={yAxisColumns[0].operationType} /> </EuiFlexItem> )} - <EuiFlexItem style={MaxWidthStyle}> + <EuiFlexItem> <SeriesChartTypesSelect seriesId={seriesId} defaultChartType={defaultSeriesType} /> </EuiFlexItem> </FlexGroup> diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx index e0d043504d50f..6b74ad45b2c07 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx @@ -31,17 +31,16 @@ export function CustomReportField({ field, seriesId, options: opts, defaultValue const options = opts ?? []; return ( - <div style={{ maxWidth: 250 }}> - <EuiSuperSelect - compressed - prepend={'Metric'} - options={options.map(({ label, field: fd }) => ({ - value: fd, - inputDisplay: label, - }))} - valueOfSelected={reportDefinitions?.[field]?.[0] || defaultValue || options?.[0].field} - onChange={(value) => onChange(value)} - /> - </div> + <EuiSuperSelect + fullWidth + compressed + prepend={'Metric'} + options={options.map(({ label, field: fd }) => ({ + value: fd, + inputDisplay: label, + }))} + valueOfSelected={reportDefinitions?.[field]?.[0] || defaultValue || options?.[0].field} + onChange={(value) => onChange(value)} + /> ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx index 04f4ecb2ccb23..926852fda5cbc 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx @@ -60,7 +60,7 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P flush="left" iconType="plus" onClick={() => { - setIsPopoverVisible(true); + setIsPopoverVisible((prevState) => !prevState); }} size="s" > @@ -131,7 +131,7 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P onClick={() => { setSeries(seriesId, { ...urlSeries, filters: undefined }); }} - size="xs" + size="s" > {i18n.translate('xpack.observability.expView.seriesEditor.clearFilter', { defaultMessage: 'Clear filters', diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx index 1c0e1fdb00770..55c65ce175fe0 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx @@ -6,7 +6,7 @@ */ import React, { useEffect, useState } from 'react'; -import { merge } from 'lodash'; +import { union } from 'lodash'; import { EuiComboBox, EuiFormControlLayout, EuiComboBoxOptionOption } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; @@ -31,11 +31,11 @@ export function FieldValueCombobox({ onChange: onSelectionChange, }: FieldValueSelectionProps) { const [options, setOptions] = useState<ValueOption[]>( - formatOptions(merge(values ?? [], selectedValue ?? [])) + formatOptions(union(values ?? [], selectedValue ?? [])) ); useEffect(() => { - setOptions(formatOptions(merge(values ?? [], selectedValue ?? []))); + setOptions(formatOptions(union(values ?? [], selectedValue ?? []))); }, [selectedValue, values]); const onChange = (selectedValuesN: ValueOption[]) => { diff --git a/x-pack/plugins/observability/public/hooks/use_values_list.ts b/x-pack/plugins/observability/public/hooks/use_values_list.ts index 69e889f0069ee..8d6e0abb896b3 100644 --- a/x-pack/plugins/observability/public/hooks/use_values_list.ts +++ b/x-pack/plugins/observability/public/hooks/use_values_list.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { capitalize, merge } from 'lodash'; +import { capitalize, union } from 'lodash'; import { useEffect, useState } from 'react'; import { useDebounce } from 'react-use'; import { IndexPattern } from '../../../../../src/plugins/data/common'; @@ -98,7 +98,7 @@ export const useValuesList = ({ if (keepHistory && query) { setValues((prevState) => { - return merge(newValues, prevState); + return union(newValues, prevState); }); } else { setValues(newValues); diff --git a/x-pack/plugins/osquery/common/schemas/common/schemas.ts b/x-pack/plugins/osquery/common/schemas/common/schemas.ts index ffcadc7cfea8f..f5d0a357b85b8 100644 --- a/x-pack/plugins/osquery/common/schemas/common/schemas.ts +++ b/x-pack/plugins/osquery/common/schemas/common/schemas.ts @@ -12,6 +12,16 @@ export type Name = t.TypeOf<typeof name>; export const nameOrUndefined = t.union([name, t.undefined]); export type NameOrUndefined = t.TypeOf<typeof nameOrUndefined>; +export const agentSelection = t.type({ + agents: t.array(t.string), + allAgentsSelected: t.boolean, + platformsSelected: t.array(t.string), + policiesSelected: t.array(t.string), +}); +export type AgentSelection = t.TypeOf<typeof agentSelection>; +export const agentSelectionOrUndefined = t.union([agentSelection, t.undefined]); +export type AgentSelectionOrUndefined = t.TypeOf<typeof agentSelectionOrUndefined>; + export const description = t.string; export type Description = t.TypeOf<typeof description>; export const descriptionOrUndefined = t.union([description, t.undefined]); diff --git a/x-pack/plugins/osquery/common/schemas/routes/action/create_action_request_body_schema.ts b/x-pack/plugins/osquery/common/schemas/routes/action/create_action_request_body_schema.ts new file mode 100644 index 0000000000000..bcbd528c4e749 --- /dev/null +++ b/x-pack/plugins/osquery/common/schemas/routes/action/create_action_request_body_schema.ts @@ -0,0 +1,17 @@ +/* + * 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'; + +import { query, agentSelection } from '../../common/schemas'; + +export const createActionRequestBodySchema = t.type({ + agentSelection, + query, +}); + +export type CreateActionRequestBodySchema = t.OutputOf<typeof createActionRequestBodySchema>; diff --git a/x-pack/plugins/osquery/common/schemas/routes/action/index.ts b/x-pack/plugins/osquery/common/schemas/routes/action/index.ts new file mode 100644 index 0000000000000..286aa2e5128b2 --- /dev/null +++ b/x-pack/plugins/osquery/common/schemas/routes/action/index.ts @@ -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 './create_action_request_body_schema'; diff --git a/x-pack/plugins/osquery/public/action_results/use_action_results.ts b/x-pack/plugins/osquery/public/action_results/use_action_results.ts index 7cad8ca3fc498..1f6da0b3a2a0e 100644 --- a/x-pack/plugins/osquery/public/action_results/use_action_results.ts +++ b/x-pack/plugins/osquery/public/action_results/use_action_results.ts @@ -8,6 +8,7 @@ import { flatten, reverse, uniqBy } from 'lodash/fp'; import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { @@ -32,7 +33,7 @@ export interface ResultsArgs { totalCount: number; } -interface UseActionResults { +export interface UseActionResults { actionId: string; activePage: number; agentIds?: string[]; @@ -55,7 +56,10 @@ export const useActionResults = ({ skip = false, isLive = false, }: UseActionResults) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['actionResults', { actionId }], @@ -120,6 +124,12 @@ export const useActionResults = ({ refetchInterval: isLive ? 1000 : false, keepPreviousData: true, enabled: !skip && !!agentIds?.length, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.action_results.fetchError', { + defaultMessage: 'Error while fetching action results', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/actions/use_action_details.ts b/x-pack/plugins/osquery/public/actions/use_action_details.ts index 2e5fa79cae992..bb260cd78ca76 100644 --- a/x-pack/plugins/osquery/public/actions/use_action_details.ts +++ b/x-pack/plugins/osquery/public/actions/use_action_details.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { @@ -32,7 +33,10 @@ interface UseActionDetails { } export const useActionDetails = ({ actionId, filterQuery, skip = false }: UseActionDetails) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['actionDetails', { actionId, filterQuery }], @@ -57,6 +61,12 @@ export const useActionDetails = ({ actionId, filterQuery, skip = false }: UseAct }, { enabled: !skip, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.action_details.fetchError', { + defaultMessage: 'Error while fetching action details', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/actions/use_all_actions.ts b/x-pack/plugins/osquery/public/actions/use_all_actions.ts index a58f45b8e99a2..375d108c4dd8b 100644 --- a/x-pack/plugins/osquery/public/actions/use_all_actions.ts +++ b/x-pack/plugins/osquery/public/actions/use_all_actions.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { @@ -47,7 +48,10 @@ export const useAllActions = ({ filterQuery, skip = false, }: UseAllActions) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['actions', { activePage, direction, limit, sortField }], @@ -78,6 +82,12 @@ export const useAllActions = ({ { keepPreviousData: true, enabled: !skip, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.all_actions.fetchError', { + defaultMessage: 'Error while fetching actions', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts b/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts index 95323dd23f4d2..d4bd0a1f4277f 100644 --- a/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts +++ b/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { agentPolicyRouteService, @@ -15,7 +16,10 @@ import { } from '../../../fleet/common'; export const useAgentPolicies = () => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; return useQuery<GetAgentPoliciesResponse, unknown, GetAgentPoliciesResponseItem[]>( ['agentPolicies'], @@ -30,6 +34,12 @@ export const useAgentPolicies = () => { placeholderData: [], keepPreviousData: true, select: (response) => response.items, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.agent_policies.fetchError', { + defaultMessage: 'Error while fetching agent policies', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/agent_policies/use_agent_policy.ts b/x-pack/plugins/osquery/public/agent_policies/use_agent_policy.ts index 5fdc317d3f6f1..e87d8d1c9f28e 100644 --- a/x-pack/plugins/osquery/public/agent_policies/use_agent_policy.ts +++ b/x-pack/plugins/osquery/public/agent_policies/use_agent_policy.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { agentPolicyRouteService } from '../../../fleet/common'; @@ -16,7 +17,10 @@ interface UseAgentPolicy { } export const useAgentPolicy = ({ policyId, skip }: UseAgentPolicy) => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['agentPolicy', { policyId }], @@ -25,6 +29,12 @@ export const useAgentPolicy = ({ policyId, skip }: UseAgentPolicy) => { enabled: !skip, keepPreviousData: true, select: (response) => response.item, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.agent_policy_details.fetchError', { + defaultMessage: 'Error while fetching agent policy details', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/agents/use_agent_groups.ts b/x-pack/plugins/osquery/public/agents/use_agent_groups.ts index 0853891f1919d..44737af9d3477 100644 --- a/x-pack/plugins/osquery/public/agents/use_agent_groups.ts +++ b/x-pack/plugins/osquery/public/agents/use_agent_groups.ts @@ -6,6 +6,7 @@ */ import { useState } from 'react'; import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { useAgentPolicies } from './use_agent_policies'; @@ -24,7 +25,10 @@ interface UseAgentGroups { } export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseAgentGroups) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; const { agentPoliciesLoading, agentPolicyById } = useAgentPolicies(osqueryPolicies); const [platforms, setPlatforms] = useState<Group[]>([]); @@ -96,6 +100,12 @@ export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseA }, { enabled: !osqueryPoliciesLoading && !agentPoliciesLoading, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.agent_groups.fetchError', { + defaultMessage: 'Error while fetching agent groups', + }), + }), } ); diff --git a/x-pack/plugins/osquery/public/agents/use_agent_policies.ts b/x-pack/plugins/osquery/public/agents/use_agent_policies.ts index c8b3ef064c038..ecb95fff8838e 100644 --- a/x-pack/plugins/osquery/public/agents/use_agent_policies.ts +++ b/x-pack/plugins/osquery/public/agents/use_agent_policies.ts @@ -7,17 +7,27 @@ import { mapKeys } from 'lodash'; import { useQueries, UseQueryResult } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { agentPolicyRouteService, GetOneAgentPolicyResponse } from '../../../fleet/common'; export const useAgentPolicies = (policyIds: string[] = []) => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; const agentResponse = useQueries( policyIds.map((policyId) => ({ queryKey: ['agentPolicy', policyId], queryFn: () => http.get(agentPolicyRouteService.getInfoPath(policyId)), enabled: policyIds.length > 0, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.action_policy_details.fetchError', { + defaultMessage: 'Error while fetching policy details', + }), + }), })) ) as Array<UseQueryResult<GetOneAgentPolicyResponse>>; diff --git a/x-pack/plugins/osquery/public/agents/use_agent_status.ts b/x-pack/plugins/osquery/public/agents/use_agent_status.ts index c26adb908f6be..4954eb0dc80c4 100644 --- a/x-pack/plugins/osquery/public/agents/use_agent_status.ts +++ b/x-pack/plugins/osquery/public/agents/use_agent_status.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { useQuery } from 'react-query'; import { GetAgentStatusResponse, agentRouteService } from '../../../fleet/common'; @@ -16,7 +17,10 @@ interface UseAgentStatus { } export const useAgentStatus = ({ policyId, skip }: UseAgentStatus) => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; return useQuery<GetAgentStatusResponse, unknown, GetAgentStatusResponse['results']>( ['agentStatus', policyId], @@ -34,6 +38,12 @@ export const useAgentStatus = ({ policyId, skip }: UseAgentStatus) => { { enabled: !skip, select: (response) => response.results, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.agent_status.fetchError', { + defaultMessage: 'Error while fetching agent status', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/agents/use_all_agents.ts b/x-pack/plugins/osquery/public/agents/use_all_agents.ts index e10bc2a0d9bf6..674deb3b339bd 100644 --- a/x-pack/plugins/osquery/public/agents/use_all_agents.ts +++ b/x-pack/plugins/osquery/public/agents/use_all_agents.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { useQuery } from 'react-query'; import { GetAgentsResponse, agentRouteService } from '../../../fleet/common'; @@ -27,7 +28,10 @@ export const useAllAgents = ( opts: RequestOptions = { perPage: 9000 } ) => { const { perPage } = opts; - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; const { isLoading: agentsLoading, data: agentData } = useQuery<GetAgentsResponse>( ['agents', osqueryPolicies, searchValue, perPage], () => { @@ -52,6 +56,12 @@ export const useAllAgents = ( }, { enabled: !osqueryPoliciesLoading, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.agents.fetchError', { + defaultMessage: 'Error while fetching agents', + }), + }), } ); diff --git a/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts b/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts index 2937c57b50a3d..0eb94af73e3a8 100644 --- a/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts +++ b/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts @@ -5,15 +5,21 @@ * 2.0. */ +import { uniq } from 'lodash'; import { useQuery } from 'react-query'; +import { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { packagePolicyRouteService, PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../fleet/common'; import { OSQUERY_INTEGRATION_NAME } from '../../common'; export const useOsqueryPolicies = () => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; - const { isLoading: osqueryPoliciesLoading, data: osqueryPolicies } = useQuery( + const { isLoading: osqueryPoliciesLoading, data: osqueryPolicies = [] } = useQuery( ['osqueryPolicies'], () => http.get(packagePolicyRouteService.getListPath(), { @@ -21,8 +27,19 @@ export const useOsqueryPolicies = () => { kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${OSQUERY_INTEGRATION_NAME}`, }, }), - { select: (data) => data.items.map((p: { policy_id: string }) => p.policy_id) } + { + select: (response) => + uniq<string>(response.items.map((p: { policy_id: string }) => p.policy_id)), + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.osquery_policies.fetchError', { + defaultMessage: 'Error while fetching osquery policies', + }), + }), + } ); - - return { osqueryPoliciesLoading, osqueryPolicies }; + return useMemo(() => ({ osqueryPoliciesLoading, osqueryPolicies }), [ + osqueryPoliciesLoading, + osqueryPolicies, + ]); }; diff --git a/x-pack/plugins/osquery/public/common/hooks/use_osquery_integration.tsx b/x-pack/plugins/osquery/public/common/hooks/use_osquery_integration.tsx index d8bed30b969ad..ccfb407eab58b 100644 --- a/x-pack/plugins/osquery/public/common/hooks/use_osquery_integration.tsx +++ b/x-pack/plugins/osquery/public/common/hooks/use_osquery_integration.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { find } from 'lodash/fp'; import { useQuery } from 'react-query'; @@ -13,7 +14,10 @@ import { OSQUERY_INTEGRATION_NAME } from '../../../common'; import { useKibana } from '../lib/kibana'; export const useOsqueryIntegration = () => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; return useQuery( 'integrations', @@ -26,6 +30,12 @@ export const useOsqueryIntegration = () => { { select: ({ response }: GetPackagesResponse) => find(['name', OSQUERY_INTEGRATION_NAME], response), + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.osquery_integration.fetchError', { + defaultMessage: 'Error while fetching osquery integration', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/common/validations.ts b/x-pack/plugins/osquery/public/common/validations.ts new file mode 100644 index 0000000000000..7ab9de52e35ad --- /dev/null +++ b/x-pack/plugins/osquery/public/common/validations.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +import { ValidationFunc, fieldValidators } from '../shared_imports'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const queryFieldValidation: ValidationFunc<any, string, string> = fieldValidators.emptyField( + i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.emptyQueryError', { + defaultMessage: 'Query is a required field', + }) +); diff --git a/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx b/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx index 272e65d9cc0fa..d1ef18e2e12ea 100644 --- a/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx @@ -22,7 +22,7 @@ const QueryAgentResultsComponent = () => { {data?.actionDetails._source?.data?.query} </EuiCodeBlock> <EuiSpacer /> - <ResultsTable actionId={actionId} agentId={agentId} /> + <ResultsTable actionId={actionId} selectedAgent={agentId} /> </> ); }; diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index 056bbc75f3b76..5d1b616c7d88a 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -12,14 +12,18 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { useMemo } from 'react'; import { useMutation } from 'react-query'; -import { UseField, Form, FormData, useForm, useFormData } from '../../shared_imports'; +import { UseField, Form, FormData, useForm, useFormData, FIELD_TYPES } from '../../shared_imports'; import { AgentsTableField } from './agents_table_field'; import { LiveQueryQueryField } from './live_query_query_field'; import { useKibana } from '../../common/lib/kibana'; import { ResultTabs } from '../../queries/edit/tabs'; +import { queryFieldValidation } from '../../common/validations'; +import { fieldValidators } from '../../shared_imports'; const FORM_ID = 'liveQueryForm'; +export const MAX_QUERY_LENGTH = 2000; + interface LiveQueryFormProps { defaultValue?: Partial<FormData> | undefined; onSubmit?: (payload: Record<string, string>) => Promise<void>; @@ -50,9 +54,27 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({ } ); + const formSchema = { + query: { + type: FIELD_TYPES.TEXT, + validations: [ + { + validator: fieldValidators.maxLengthField({ + length: MAX_QUERY_LENGTH, + message: i18n.translate('xpack.osquery.liveQuery.queryForm.largeQueryError', { + defaultMessage: 'Query is too large (max {maxLength} characters)', + values: { maxLength: MAX_QUERY_LENGTH }, + }), + }), + }, + { validator: queryFieldValidation }, + ], + }, + }; + const { form } = useForm({ id: FORM_ID, - // schema: formSchema, + schema: formSchema, onSubmit: (payload) => { return mutateAsync(payload); }, @@ -60,10 +82,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({ stripEmptyFields: false, }, defaultValue: defaultValue ?? { - query: { - id: null, - query: '', - }, + query: '', }, }); @@ -85,16 +104,16 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({ [agentSelection] ); - const queryValueProvided = useMemo(() => !!query?.query?.length, [query]); + const queryValueProvided = useMemo(() => !!query?.length, [query]); const queryStatus = useMemo(() => { if (!agentSelected) return 'disabled'; - if (isError) return 'danger'; + if (isError || !form.getFields().query.isValid) return 'danger'; if (isLoading) return 'loading'; if (isSuccess) return 'complete'; return 'incomplete'; - }, [agentSelected, isError, isLoading, isSuccess]); + }, [agentSelected, isError, isLoading, isSuccess, form]); const resultsStatus = useMemo(() => (queryStatus === 'complete' ? 'incomplete' : 'disabled'), [ queryStatus, diff --git a/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx b/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx index 68207200dc789..07c13b930e143 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx @@ -5,86 +5,32 @@ * 2.0. */ -// import { find } from 'lodash/fp'; -// import { EuiCodeBlock, EuiSuperSelect, EuiText, EuiSpacer } from '@elastic/eui'; import React, { useCallback } from 'react'; -// import { useQuery } from 'react-query'; +import { EuiFormRow } from '@elastic/eui'; import { FieldHook } from '../../shared_imports'; -// import { useKibana } from '../../common/lib/kibana'; import { OsqueryEditor } from '../../editor'; interface LiveQueryQueryFieldProps { disabled?: boolean; - field: FieldHook<{ - id: string | null; - query: string; - }>; + field: FieldHook<string>; } const LiveQueryQueryFieldComponent: React.FC<LiveQueryQueryFieldProps> = ({ disabled, field }) => { - // const { http } = useKibana().services; - // const { data } = useQuery('savedQueryList', () => - // http.get('/internal/osquery/saved_query', { - // query: { - // pageIndex: 0, - // pageSize: 100, - // sortField: 'updated_at', - // sortDirection: 'desc', - // }, - // }) - // ); - - // const queryOptions = - // // @ts-expect-error update types - // data?.saved_objects.map((savedQuery) => ({ - // value: savedQuery, - // inputDisplay: savedQuery.attributes.name, - // dropdownDisplay: ( - // <> - // <strong>{savedQuery.attributes.name}</strong> - // <EuiText size="s" color="subdued"> - // <p className="euiTextColor--subdued">{savedQuery.attributes.description}</p> - // </EuiText> - // <EuiCodeBlock language="sql" fontSize="s" paddingSize="s"> - // {savedQuery.attributes.query} - // </EuiCodeBlock> - // </> - // ), - // })) ?? []; - - const { value, setValue } = field; - - // const handleSavedQueryChange = useCallback( - // (newValue) => { - // setValue({ - // id: newValue.id, - // query: newValue.attributes.query, - // }); - // }, - // [setValue] - // ); + const { value, setValue, errors } = field; + const error = errors[0]?.message; const handleEditorChange = useCallback( (newValue) => { - setValue({ - id: null, - query: newValue, - }); + setValue(newValue); }, [setValue] ); return ( - <> - {/* <EuiSuperSelect - valueOfSelected={find(['id', value.id], data?.saved_objects)} - options={queryOptions} - onChange={handleSavedQueryChange} - /> - <EuiSpacer /> */} - <OsqueryEditor defaultValue={value.query} disabled={disabled} onChange={handleEditorChange} /> - </> + <EuiFormRow isInvalid={typeof error === 'string'} error={error} fullWidth> + <OsqueryEditor defaultValue={value} disabled={disabled} onChange={handleEditorChange} /> + </EuiFormRow> ); }; diff --git a/x-pack/plugins/osquery/public/queries/edit/tabs.tsx b/x-pack/plugins/osquery/public/queries/edit/tabs.tsx index 1a6b317653c98..f86762e76834b 100644 --- a/x-pack/plugins/osquery/public/queries/edit/tabs.tsx +++ b/x-pack/plugins/osquery/public/queries/edit/tabs.tsx @@ -36,7 +36,7 @@ const ResultTabsComponent: React.FC<ResultTabsProps> = ({ actionId, agentIds, is content: ( <> <EuiSpacer /> - <ResultsTable actionId={actionId} isLive={isLive} /> + <ResultsTable actionId={actionId} agentIds={agentIds} isLive={isLive} /> </> ), }, diff --git a/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx b/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx index a56e747355c5b..77ffdc4457d3d 100644 --- a/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx +++ b/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx @@ -31,15 +31,16 @@ const OsquerySchemaLink = React.memo(() => ( OsquerySchemaLink.displayName = 'OsquerySchemaLink'; const CodeEditorFieldComponent: React.FC<CodeEditorFieldProps> = ({ field }) => { - const { value, label, labelAppend, helpText, setValue } = field; + const { value, label, labelAppend, helpText, setValue, errors } = field; + const error = errors[0]?.message; return ( <EuiFormRow label={label} labelAppend={!isEmpty(labelAppend) ? labelAppend : <OsquerySchemaLink />} helpText={helpText} - // isInvalid={typeof error === 'string'} - // error={error} + isInvalid={typeof error === 'string'} + error={error} fullWidth > <OsqueryEditor defaultValue={value} onChange={setValue} /> diff --git a/x-pack/plugins/osquery/public/results/results_table.tsx b/x-pack/plugins/osquery/public/results/results_table.tsx index d82c45d802520..8b613a336ae73 100644 --- a/x-pack/plugins/osquery/public/results/results_table.tsx +++ b/x-pack/plugins/osquery/public/results/results_table.tsx @@ -12,6 +12,10 @@ import { EuiDataGridProps, EuiDataGridColumn, EuiLink, + EuiTextColor, + EuiBasicTable, + EuiBasicTableColumn, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react'; @@ -20,16 +24,89 @@ import { pagePathGetters } from '../../../fleet/public'; import { useAllResults } from './use_all_results'; import { Direction, ResultEdges } from '../../common/search_strategy'; import { useKibana } from '../common/lib/kibana'; +import { useActionResults } from '../action_results/use_action_results'; +import { generateEmptyDataMessage } from './translations'; const DataContext = createContext<ResultEdges>([]); interface ResultsTableComponentProps { actionId: string; - agentId?: string; + selectedAgent?: string; + agentIds?: string[]; isLive?: boolean; } -const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({ actionId, isLive }) => { +interface SummaryTableValue { + total: number | string; + pending: number | string; + responded: number; + failed: number; +} + +const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({ + actionId, + agentIds, + isLive, +}) => { + const { + // @ts-expect-error update types + data: { aggregations }, + } = useActionResults({ + actionId, + activePage: 0, + agentIds, + limit: 0, + direction: Direction.asc, + sortField: '@timestamp', + isLive, + }); + + const notRespondedCount = useMemo(() => { + if (!agentIds || !aggregations.totalResponded) { + return '-'; + } + + return agentIds.length - aggregations.totalResponded; + }, [aggregations.totalResponded, agentIds]); + + const summaryColumns: Array<EuiBasicTableColumn<SummaryTableValue>> = useMemo( + () => [ + { + field: 'total', + name: 'Agents queried', + }, + { + field: 'responded', + name: 'Successful', + }, + { + field: 'pending', + name: 'Not yet responded', + }, + { + field: 'failed', + name: 'Failed', + // eslint-disable-next-line react/display-name + render: (failed: number) => ( + <EuiTextColor color={failed ? 'danger' : 'default'}>{failed}</EuiTextColor> + ), + }, + ], + [] + ); + + const summaryItems = useMemo( + () => [ + { + total: agentIds?.length ?? '-', + pending: notRespondedCount, + responded: aggregations.totalResponded, + failed: aggregations.failed, + }, + ], + [aggregations, agentIds, notRespondedCount] + ); + const { getUrlForApp } = useKibana().services.application; const getFleetAppUrl = useCallback( @@ -115,30 +192,41 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({ actionId, const newColumns = keys(allResultsData?.edges[0]?.fields) .sort() - .reduce((acc, fieldName) => { - if (fieldName === 'agent.name') { - acc.push({ - id: fieldName, - displayAsText: i18n.translate('xpack.osquery.liveQueryResults.table.agentColumnTitle', { - defaultMessage: 'agent', - }), - defaultSortDirection: Direction.asc, - }); + .reduce( + (acc, fieldName) => { + const { data, seen } = acc; + if (fieldName === 'agent.name') { + data.push({ + id: fieldName, + displayAsText: i18n.translate( + 'xpack.osquery.liveQueryResults.table.agentColumnTitle', + { + defaultMessage: 'agent', + } + ), + defaultSortDirection: Direction.asc, + }); - return acc; - } - - if (fieldName.startsWith('osquery.')) { - acc.push({ - id: fieldName, - displayAsText: fieldName.split('.')[1], - defaultSortDirection: Direction.asc, - }); - return acc; - } + return acc; + } - return acc; - }, [] as EuiDataGridColumn[]); + if (fieldName.startsWith('osquery.')) { + const displayAsText = fieldName.split('.')[1]; + if (!seen.has(displayAsText)) { + data.push({ + id: fieldName, + displayAsText, + defaultSortDirection: Direction.asc, + }); + seen.add(displayAsText); + } + return acc; + } + + return acc; + }, + { data: [], seen: new Set<string>() } as { data: EuiDataGridColumn[]; seen: Set<string> } + ).data; if (!isEqual(columns, newColumns)) { setColumns(newColumns); @@ -149,16 +237,24 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({ actionId, return ( // @ts-expect-error update types <DataContext.Provider value={allResultsData?.edges}> - <EuiDataGrid - aria-label="Osquery results" - columns={columns} - columnVisibility={columnVisibility} - rowCount={allResultsData?.totalCount ?? 0} - renderCellValue={renderCellValue} - sorting={tableSorting} - pagination={tablePagination} - height="500px" - /> + <EuiBasicTable items={summaryItems} rowHeader="total" columns={summaryColumns} /> + <EuiSpacer /> + {columns.length > 0 ? ( + <EuiDataGrid + aria-label="Osquery results" + columns={columns} + columnVisibility={columnVisibility} + rowCount={allResultsData?.totalCount ?? 0} + renderCellValue={renderCellValue} + sorting={tableSorting} + pagination={tablePagination} + height="500px" + /> + ) : ( + <div className={'eui-textCenter'}> + {generateEmptyDataMessage(aggregations.totalResponded)} + </div> + )} </DataContext.Provider> ); }; diff --git a/x-pack/plugins/osquery/public/results/translations.ts b/x-pack/plugins/osquery/public/results/translations.ts index 0f785f0c1f4d1..8e77e78ec76e2 100644 --- a/x-pack/plugins/osquery/public/results/translations.ts +++ b/x-pack/plugins/osquery/public/results/translations.ts @@ -7,6 +7,14 @@ import { i18n } from '@kbn/i18n'; +export const generateEmptyDataMessage = (agentsResponded: number): string => { + return i18n.translate('xpack.osquery.results.multipleAgentsResponded', { + defaultMessage: + '{agentsResponded, plural, one {# agent has} other {# agents have}} responded, but no osquery data has been reported.', + values: { agentsResponded }, + }); +}; + export const ERROR_ALL_RESULTS = i18n.translate('xpack.osquery.results.errorSearchDescription', { defaultMessage: `An error has occurred on all results search`, }); diff --git a/x-pack/plugins/osquery/public/results/use_all_results.ts b/x-pack/plugins/osquery/public/results/use_all_results.ts index 7140f80f510f4..afeb7dadb030c 100644 --- a/x-pack/plugins/osquery/public/results/use_all_results.ts +++ b/x-pack/plugins/osquery/public/results/use_all_results.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { @@ -51,7 +52,10 @@ export const useAllResults = ({ skip = false, isLive = false, }: UseAllResults) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['allActionResults', { actionId, activePage, direction, limit, sortField }], @@ -82,6 +86,12 @@ export const useAllResults = ({ { refetchInterval: isLive ? 1000 : false, enabled: !skip, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.results.fetchError', { + defaultMessage: 'Error while fetching results', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx index b2cfa05e0fc63..808431b68c4ba 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx @@ -23,6 +23,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { CodeEditorField } from '../../queries/form/code_editor_field'; +import { idFieldValidations, intervalFieldValidation, queryFieldValidation } from './validations'; import { Form, useForm, FormData, getUseField, Field, FIELD_TYPES } from '../../shared_imports'; const FORM_ID = 'addQueryFlyoutForm'; @@ -50,12 +51,14 @@ const AddQueryFlyoutComponent: React.FC<AddQueryFlyoutProps> = ({ onSave, onClos label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.idFieldLabel', { defaultMessage: 'ID', }), + validations: idFieldValidations.map((validator) => ({ validator })), }, query: { type: FIELD_TYPES.TEXT, label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.queryFieldLabel', { defaultMessage: 'Query', }), + validations: [{ validator: queryFieldValidation }], }, interval: { type: FIELD_TYPES.NUMBER, @@ -65,6 +68,7 @@ const AddQueryFlyoutComponent: React.FC<AddQueryFlyoutProps> = ({ onSave, onClos defaultMessage: 'Interval (s)', } ), + validations: [{ validator: intervalFieldValidation }], }, }, }); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/confirmation_modal.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/confirmation_modal.tsx index e686038430829..65379c9e23626 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/confirmation_modal.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/confirmation_modal.tsx @@ -74,7 +74,7 @@ const ConfirmDeployAgentPolicyModalComponent: React.FC<ConfirmDeployAgentPolicyM <EuiSpacer size="l" /> <FormattedMessage id="xpack.osquery.agentPolicy.confirmModalDescription" - defaultMessage="This action can not be undone. Are you sure you wish to continue?" + defaultMessage="Are you sure you wish to continue?" /> </EuiConfirmModal> ); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx index 41846636eccd4..767eda01c06df 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx @@ -25,6 +25,7 @@ import { i18n } from '@kbn/i18n'; import { PackagePolicyInputStream } from '../../../../fleet/common'; import { CodeEditorField } from '../../queries/form/code_editor_field'; import { Form, useForm, getUseField, Field, FIELD_TYPES } from '../../shared_imports'; +import { idFieldValidations, intervalFieldValidation, queryFieldValidation } from './validations'; const FORM_ID = 'editQueryFlyoutForm'; @@ -64,12 +65,14 @@ export const EditQueryFlyout: React.FC<EditQueryFlyoutProps> = ({ label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.idFieldLabel', { defaultMessage: 'ID', }), + validations: idFieldValidations.map((validator) => ({ validator })), }, query: { type: FIELD_TYPES.TEXT, label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.queryFieldLabel', { defaultMessage: 'Query', }), + validations: [{ validator: queryFieldValidation }], }, interval: { type: FIELD_TYPES.NUMBER, @@ -79,6 +82,7 @@ export const EditQueryFlyout: React.FC<EditQueryFlyoutProps> = ({ defaultMessage: 'Interval (s)', } ), + validations: [{ validator: intervalFieldValidation }], }, }, }); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/translations.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/form/translations.ts new file mode 100644 index 0000000000000..5d00d60ffd8b8 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/translations.ts @@ -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. + */ + +import { i18n } from '@kbn/i18n'; + +export const INVALID_ID_ERROR = i18n.translate('xpack.osquery.agents.failSearchDescription', { + defaultMessage: `Failed to fetch agents`, +}); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/validations.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/form/validations.ts new file mode 100644 index 0000000000000..95e3000476a08 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/validations.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +import { ValidationFunc, fieldValidators } from '../../shared_imports'; +export { queryFieldValidation } from '../../common/validations'; + +const idPattern = /^[a-zA-Z0-9-_]+$/; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const idSchemaValidation: ValidationFunc<any, string, string> = ({ value }) => { + const valueIsValid = idPattern.test(value); + if (!valueIsValid) { + return { + message: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.invalidIdError', { + defaultMessage: 'Characters must be alphanumeric, _, or -', + }), + }; + } +}; + +export const idFieldValidations = [ + fieldValidators.emptyField( + i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.emptyIdError', { + defaultMessage: 'ID is required', + }) + ), + idSchemaValidation, +]; + +export const intervalFieldValidation: ValidationFunc< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + string, + number +> = fieldValidators.numberGreaterThanField({ + than: 0, + message: i18n.translate( + 'xpack.osquery.scheduledQueryGroup.queryFlyoutForm.invalidIntervalField', + { + defaultMessage: 'A positive interval value is required', + } + ), +}); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx index d501f56b789d7..90ec7e0c2717b 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx @@ -148,7 +148,7 @@ const ScheduledQueryGroupQueriesTableComponent: React.FC<ScheduledQueryGroupQuer { field: 'vars.interval.value', name: i18n.translate('xpack.osquery.scheduledQueryGroup.queriesTable.intervalColumnTitle', { - defaultMessage: 'Interval', + defaultMessage: 'Interval (s)', }), width: '100px', }, diff --git a/x-pack/plugins/osquery/public/shared_imports.ts b/x-pack/plugins/osquery/public/shared_imports.ts index bae73da78f704..737b4d4735777 100644 --- a/x-pack/plugins/osquery/public/shared_imports.ts +++ b/x-pack/plugins/osquery/public/shared_imports.ts @@ -26,6 +26,7 @@ export { ValidationFunc, VALIDATION_TYPES, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; + export { Field, ComboBoxField, diff --git a/x-pack/plugins/osquery/server/lib/parse_agent_groups.ts b/x-pack/plugins/osquery/server/lib/parse_agent_groups.ts index 975770e594367..f6cbdf4ec51f4 100644 --- a/x-pack/plugins/osquery/server/lib/parse_agent_groups.ts +++ b/x-pack/plugins/osquery/server/lib/parse_agent_groups.ts @@ -5,7 +5,10 @@ * 2.0. */ -import type { ElasticsearchClient } from 'src/core/server'; +import { uniq } from 'lodash'; +import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../fleet/common'; +import { OSQUERY_INTEGRATION_NAME } from '../../common'; import { OsqueryAppContext } from './osquery_app_context_services'; export interface AgentSelection { @@ -15,45 +18,82 @@ export interface AgentSelection { policiesSelected: string[]; } +const PER_PAGE = 9000; + +const aggregateResults = async ( + generator: (page: number, perPage: number) => Promise<{ results: string[]; total: number }> +) => { + const { results, total } = await generator(1, PER_PAGE); + const totalPages = Math.ceil(total / PER_PAGE); + let currPage = 2; + while (currPage <= totalPages) { + const { results: additionalResults } = await generator(currPage++, PER_PAGE); + results.push(...additionalResults); + } + return uniq<string>(results); +}; + export const parseAgentSelection = async ( esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract, context: OsqueryAppContext, agentSelection: AgentSelection ) => { - let selectedAgents: string[] = []; + const selectedAgents: Set<string> = new Set(); + const addAgent = selectedAgents.add.bind(selectedAgents); const { allAgentsSelected, platformsSelected, policiesSelected, agents } = agentSelection; const agentService = context.service.getAgentService(); - if (agentService) { + const packagePolicyService = context.service.getPackagePolicyService(); + const kueryFragments = ['active:true']; + + if (agentService && packagePolicyService) { + const osqueryPolicies = await aggregateResults(async (page, perPage) => { + const { items, total } = await packagePolicyService.list(soClient, { + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${OSQUERY_INTEGRATION_NAME}`, + perPage, + page, + }); + return { results: items.map((it) => it.policy_id), total }; + }); + kueryFragments.push(`policy_id:(${uniq(osqueryPolicies).join(',')})`); if (allAgentsSelected) { - // TODO: actually fetch all the agents - const { agents: fetchedAgents } = await agentService.listAgents(esClient, { - perPage: 9000, - showInactive: true, + const kuery = kueryFragments.join(' and '); + const fetchedAgents = await aggregateResults(async (page, perPage) => { + const res = await agentService.listAgents(esClient, { + perPage, + page, + kuery, + showInactive: true, + }); + return { results: res.agents.map((agent) => agent.id), total: res.total }; }); - selectedAgents.push(...fetchedAgents.map((a) => a.id)); + fetchedAgents.forEach(addAgent); } else { if (platformsSelected.length > 0 || policiesSelected.length > 0) { - const kueryFragments = []; + const groupFragments = []; if (platformsSelected.length) { - kueryFragments.push( - ...platformsSelected.map((platform) => `local_metadata.os.platform:${platform}`) - ); + groupFragments.push(`local_metadata.os.platform:(${platformsSelected.join(',')})`); } if (policiesSelected.length) { - kueryFragments.push(...policiesSelected.map((policy) => `policy_id:${policy}`)); + groupFragments.push(`policy_id:(${policiesSelected.join(',')})`); } - const kuery = kueryFragments.join(' or '); - // TODO: actually fetch all the agents - const { agents: fetchedAgents } = await agentService.listAgents(esClient, { - kuery, - perPage: 9000, - showInactive: true, + kueryFragments.push(`(${groupFragments.join(' or ')})`); + const kuery = kueryFragments.join(' and '); + const fetchedAgents = await aggregateResults(async (page, perPage) => { + const res = await agentService.listAgents(esClient, { + perPage, + page, + kuery, + showInactive: true, + }); + return { results: res.agents.map((agent) => agent.id), total: res.total }; }); - selectedAgents.push(...fetchedAgents.map((a) => a.id)); + fetchedAgents.forEach(addAgent); } - selectedAgents.push(...agents); - selectedAgents = Array.from(new Set(selectedAgents)); } } - return selectedAgents; + + agents.forEach(addAgent); + + return Array.from(selectedAgents); }; diff --git a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts index 8e741c6a9e3ca..9dcd020f0734e 100644 --- a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts +++ b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts @@ -7,29 +7,42 @@ import uuid from 'uuid'; import moment from 'moment'; -import { schema } from '@kbn/config-schema'; import { IRouter } from '../../../../../../src/core/server'; import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; import { parseAgentSelection, AgentSelection } from '../../lib/parse_agent_groups'; +import { buildRouteValidation } from '../../utils/build_validation/route_validation'; +import { + createActionRequestBodySchema, + CreateActionRequestBodySchema, +} from '../../../common/schemas/routes/action/create_action_request_body_schema'; export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.post( { path: '/internal/osquery/action', validate: { - params: schema.object({}, { unknowns: 'allow' }), - body: schema.object({}, { unknowns: 'allow' }), - }, - options: { - tags: ['access:osquery', 'access:osquery_write'], + body: buildRouteValidation< + typeof createActionRequestBodySchema, + CreateActionRequestBodySchema + >(createActionRequestBodySchema), }, }, async (context, request, response) => { const esClient = context.core.elasticsearch.client.asCurrentUser; + const soClient = context.core.savedObjects.client; const { agentSelection } = request.body as { agentSelection: AgentSelection }; - const selectedAgents = await parseAgentSelection(esClient, osqueryContext, agentSelection); + const selectedAgents = await parseAgentSelection( + esClient, + soClient, + osqueryContext, + agentSelection + ); + + if (!selectedAgents.length) { + throw new Error('No agents found for selection, aborting.'); + } const action = { action_id: uuid.v4(), @@ -39,10 +52,8 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon input_type: 'osquery', agents: selectedAgents, data: { - // @ts-expect-error update validation - id: request.body.query.id ?? uuid.v4(), - // @ts-expect-error update validation - query: request.body.query.query, + id: uuid.v4(), + query: request.body.query, }, }; const actionResponse = await esClient.index<{}, {}>({ diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 2b584b196a738..a735f3885cf2c 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ENABLE_CASE_CONNECTOR } from '../../cases/common/constants'; +import { ENABLE_CASE_CONNECTOR } from '../../cases/common'; export const APP_ID = 'securitySolution'; export const SERVER_APP_ID = 'siem'; @@ -25,6 +25,8 @@ export const DEFAULT_REFRESH_RATE_INTERVAL = 'timepicker:refreshIntervalDefaults export const DEFAULT_APP_TIME_RANGE = 'securitySolution:timeDefaults'; export const DEFAULT_APP_REFRESH_INTERVAL = 'securitySolution:refreshIntervalDefaults'; export const DEFAULT_SIGNALS_INDEX = '.siem-signals'; +// The DEFAULT_MAX_SIGNALS value exists also in `x-pack/plugins/cases/common/constants.ts` +// If either changes, engineer should ensure both values are updated export const DEFAULT_MAX_SIGNALS = 100; export const DEFAULT_SEARCH_AFTER_PAGE_SIZE = 100; export const DEFAULT_ANOMALY_SCORE = 'securitySolution:defaultAnomalyScore'; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts index 326795ae55662..df0d0d7acf4c7 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts @@ -247,6 +247,30 @@ describe('When invoking Trusted Apps Schema', () => { expect(() => body.validate(bodyMsg)).not.toThrow(); }); + it('should validate `entry.type` does not accept `wildcard` when field is NOT PATH', () => { + const bodyMsg = createNewTrustedApp({ + entries: [ + createConditionEntry({ + field: ConditionEntryField.HASH, + type: 'wildcard', + }), + ], + }); + expect(() => body.validate(bodyMsg)).toThrow(); + }); + + it('should validate `entry.type` accepts `wildcard` when field is PATH', () => { + const bodyMsg = createNewTrustedApp({ + entries: [ + createConditionEntry({ + field: ConditionEntryField.PATH, + type: 'wildcard', + }), + ], + }); + expect(() => body.validate(bodyMsg)).not.toThrow(); + }); + it('should validate `entry.value` required', () => { const { value, ...entry } = createConditionEntry(); expect(() => body.validate(createNewTrustedApp({ entries: [entry] }))).toThrow(); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index e582744e1a141..54d0becd2446e 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -29,7 +29,12 @@ export const GetTrustedAppsRequestSchema = { }), }; -const ConditionEntryTypeSchema = schema.literal('match'); +const ConditionEntryTypeSchema = schema.conditional( + schema.siblingRef('field'), + ConditionEntryField.PATH, + schema.oneOf([schema.literal('match'), schema.literal('wildcard')]), + schema.literal('match') +); const ConditionEntryOperatorSchema = schema.literal('included'); /* diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index d36958c11d2a1..8d66370fea4d3 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -7,6 +7,7 @@ import { TypeOf } from '@kbn/config-schema'; import { ApplicationStart } from 'kibana/public'; + import { DeleteTrustedAppsRequestSchema, GetOneTrustedAppRequestSchema, @@ -69,9 +70,15 @@ export enum ConditionEntryField { SIGNER = 'process.Ext.code_signature', } +export enum OperatorFieldIds { + is = 'is', + matches = 'matches', +} + +export type TrustedAppEntryTypes = 'match' | 'wildcard'; export interface ConditionEntry<T extends ConditionEntryField = ConditionEntryField> { field: T; - type: 'match'; + type: TrustedAppEntryTypes; operator: 'included'; value: string; } diff --git a/x-pack/plugins/security_solution/common/shared_imports.ts b/x-pack/plugins/security_solution/common/shared_imports.ts index 033df0df6c458..e987775a8e768 100644 --- a/x-pack/plugins/security_solution/common/shared_imports.ts +++ b/x-pack/plugins/security_solution/common/shared_imports.ts @@ -20,6 +20,7 @@ export { EntryExists, EntryMatch, EntryMatchAny, + EntryMatchWildcard, EntryNested, EntryList, EntriesArray, @@ -38,6 +39,7 @@ export { nestedEntryItem, entriesMatch, entriesMatchAny, + entriesMatchWildcard, entriesExists, entriesList, namespaceType, diff --git a/x-pack/plugins/security_solution/common/utils/path_placeholder.test.ts b/x-pack/plugins/security_solution/common/utils/path_placeholder.test.ts new file mode 100644 index 0000000000000..9618440c105dc --- /dev/null +++ b/x-pack/plugins/security_solution/common/utils/path_placeholder.test.ts @@ -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 { getPlaceholderTextByOSType, getPlaceholderText } from './path_placeholder'; +import { ConditionEntryField, OperatingSystem, TrustedAppEntryTypes } from '../endpoint/types'; + +const trustedAppEntry = { + os: OperatingSystem.LINUX, + field: ConditionEntryField.HASH, + type: 'match' as TrustedAppEntryTypes, +}; + +describe('Trusted Apps: Path placeholder text', () => { + it('returns no placeholder text when field IS NOT PATH', () => { + expect(getPlaceholderTextByOSType({ ...trustedAppEntry })).toEqual(undefined); + }); + + it('returns a placeholder text when field IS PATH', () => { + expect( + getPlaceholderTextByOSType({ ...trustedAppEntry, field: ConditionEntryField.PATH }) + ).toEqual(getPlaceholderText().others.exact); + }); + + it('returns LINUX/MAC equivalent placeholder when field IS PATH', () => { + expect( + getPlaceholderTextByOSType({ + ...trustedAppEntry, + os: OperatingSystem.MAC, + field: ConditionEntryField.PATH, + }) + ).toEqual(getPlaceholderText().others.exact); + }); + + it('returns LINUX/MAC equivalent placeholder text when field IS PATH and WILDCARD operator is selected', () => { + expect( + getPlaceholderTextByOSType({ + ...trustedAppEntry, + os: OperatingSystem.LINUX, + field: ConditionEntryField.PATH, + type: 'wildcard', + }) + ).toEqual(getPlaceholderText().others.wildcard); + }); + + it('returns WINDOWS equivalent placeholder text when field IS PATH', () => { + expect( + getPlaceholderTextByOSType({ + ...trustedAppEntry, + os: OperatingSystem.WINDOWS, + field: ConditionEntryField.PATH, + }) + ).toEqual(getPlaceholderText().windows.exact); + }); + + it('returns WINDOWS equivalent placeholder text when field IS PATH and WILDCARD operator is selected', () => { + expect( + getPlaceholderTextByOSType({ + ...trustedAppEntry, + os: OperatingSystem.WINDOWS, + field: ConditionEntryField.PATH, + type: 'wildcard', + }) + ).toEqual(getPlaceholderText().windows.wildcard); + }); +}); diff --git a/x-pack/plugins/security_solution/common/utils/path_placeholder.ts b/x-pack/plugins/security_solution/common/utils/path_placeholder.ts new file mode 100644 index 0000000000000..bba01b6d05b65 --- /dev/null +++ b/x-pack/plugins/security_solution/common/utils/path_placeholder.ts @@ -0,0 +1,43 @@ +/* + * 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 { ConditionEntryField, OperatingSystem, TrustedAppEntryTypes } from '../endpoint/types'; + +export const getPlaceholderText = () => ({ + windows: { + wildcard: 'C:\\sample\\**\\*', + exact: 'C:\\sample\\path.exe', + }, + others: { + wildcard: '/opt/**/*', + exact: '/opt/bin', + }, +}); + +export const getPlaceholderTextByOSType = ({ + os, + field, + type, +}: { + os: OperatingSystem; + field: ConditionEntryField; + type: TrustedAppEntryTypes; +}): string | undefined => { + if (field === ConditionEntryField.PATH) { + if (os === OperatingSystem.WINDOWS) { + if (type === 'wildcard') { + return getPlaceholderText().windows.wildcard; + } + return getPlaceholderText().windows.exact; + } else { + if (type === 'wildcard') { + return getPlaceholderText().others.wildcard; + } + return getPlaceholderText().others.exact; + } + } +}; diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/attach_timeline.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/attach_timeline.spec.ts index 3f3209b52120e..7f0016e39ff88 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/attach_timeline.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/attach_timeline.spec.ts @@ -19,7 +19,8 @@ import { createTimeline } from '../../tasks/api_calls/timelines'; import { cleanKibana } from '../../tasks/common'; import { createCase } from '../../tasks/api_calls/cases'; -describe('attach timeline to case', () => { +// TODO: enable once attach timeline to cases is re-enabled +describe.skip('attach timeline to case', () => { context('without cases created', () => { beforeEach(() => { cleanKibana(); diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts index f46feae946242..c568aaae664a0 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts @@ -67,8 +67,8 @@ describe('Cases', () => { .as('mycase') ); }); - - it('Creates a new case with timeline and opens the timeline', function () { + // TODO: enable once attach timeline to cases is re-enabled + it.skip('Creates a new case with timeline and opens the timeline', function () { loginAndWaitForPageWithoutDateRange(CASES_URL); goToCreateNewCase(); fillCasesMandatoryfields(this.mycase); diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index d4551f76ae390..50a5f62740271 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -7,6 +7,7 @@ "requiredPlugins": [ "actions", "alerting", + "cases", "data", "dataEnhanced", "embeddable", diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx index 9f3e23fcde1c0..60fa0e4aafd8e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx @@ -5,574 +5,75 @@ * 2.0. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - EuiBasicTable as _EuiBasicTable, - EuiContextMenuPanel, - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingContent, - EuiProgress, - EuiTableSortingType, -} 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 React, { useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; -import * as i18n from './translations'; -import { CaseStatuses, CaseType } from '../../../../../cases/common/api'; -import { getCasesColumns } from './columns'; -import { Case, DeleteCase, FilterOptions, SortFieldCase, SubCase } from '../../containers/types'; -import { useGetCases, UpdateCase } from '../../containers/use_get_cases'; -import { useGetCasesStatus } from '../../containers/use_get_cases_status'; -import { useDeleteCases } from '../../containers/use_delete_cases'; -import { EuiBasicTableOnChange } from '../../../detections/pages/detection_engine/rules/types'; -import { Panel } from '../../../common/components/panel'; import { - UtilityBar, - UtilityBarAction, - UtilityBarGroup, - UtilityBarSection, - UtilityBarText, -} from '../../../common/components/utility_bar'; -import { getCreateCaseUrl, useFormatUrl } from '../../../common/components/link_to'; -import { getBulkItems } from '../bulk_actions'; -import { CaseHeaderPage } from '../case_header_page'; -import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; -import { getActions } from './actions'; -import { CasesTableFilters } from './table_filters'; -import { useUpdateCases } from '../../containers/use_bulk_update_case'; -import { useGetActionLicense } from '../../containers/use_get_action_license'; -import { getActionLicenseError } from '../use_push_to_service/helpers'; -import { CaseCallOut } from '../callout'; -import { ConfigureCaseButton } from '../configure_cases/button'; -import { ERROR_PUSH_SERVICE_CALLOUT_TITLE } from '../use_push_to_service/translations'; -import { LinkButton } from '../../../common/components/links'; + getCaseDetailsUrl, + getConfigureCasesUrl, + getCreateCaseUrl, + useFormatUrl, +} from '../../../common/components/link_to'; import { SecurityPageName } from '../../../app/types'; import { useKibana } from '../../../common/lib/kibana'; import { APP_ID } from '../../../../common/constants'; -import { Stats } from '../status'; -import { SELECTABLE_MESSAGE_COLLECTIONS } from '../../translations'; -import { getExpandedRowMap } from './expanded_row'; -import { isSelectedCasesIncludeCollections } from './helpers'; - -const Div = styled.div` - margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; -`; - -const FlexItemDivider = styled(EuiFlexItem)` - ${({ theme }) => css` - .euiFlexGroup--gutterMedium > &.euiFlexItem { - border-right: ${theme.eui.euiBorderThin}; - padding-right: ${theme.eui.euiSize}; - margin-right: ${theme.eui.euiSize}; - } - `} -`; - -const ProgressLoader = styled(EuiProgress)` - ${({ theme }) => css` - top: 2px; - border-radius: ${theme.eui.euiBorderRadius}; - z-index: ${theme.eui.euiZHeader}; - `} -`; - -const getSortField = (field: string): SortFieldCase => { - if (field === SortFieldCase.createdAt) { - return SortFieldCase.createdAt; - } else if (field === SortFieldCase.closedAt) { - return SortFieldCase.closedAt; - } - return SortFieldCase.createdAt; -}; -const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any -const BasicTable = styled(EuiBasicTable)` - ${({ theme }) => ` - .euiTableRow-isExpandedRow.euiTableRow-isSelectable .euiTableCellContent { - padding: 8px 0 8px 32px; - } - - &.isModal .euiTableRow.isDisabled { - cursor: not-allowed; - background-color: ${theme.eui.euiTableHoverClickableColor}; - } - - &.isModal .euiTableRow.euiTableRow-isExpandedRow .euiTableRowCell, - &.isModal .euiTableRow.euiTableRow-isExpandedRow:hover { - background-color: transparent; - } - - &.isModal .euiTableRow.euiTableRow-isExpandedRow { - .subCase:hover { - background-color: ${theme.eui.euiTableHoverClickableColor}; - } - } - `} -`; -BasicTable.displayName = 'BasicTable'; +export interface AllCasesNavProps { + detailName: string; + search?: string; + subCaseId?: string; +} interface AllCasesProps { - onRowClick?: (theCase?: Case | SubCase) => void; - isModal?: boolean; userCanCrud: boolean; - disabledStatuses?: CaseStatuses[]; - disabledCases?: CaseType[]; } -export const AllCases = React.memo<AllCasesProps>( - ({ onRowClick, isModal = false, userCanCrud, disabledStatuses, disabledCases = [] }) => { - const { navigateToApp } = useKibana().services.application; - const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case); - const { actionLicense } = useGetActionLicense(); - const { - countOpenCases, - countInProgressCases, - countClosedCases, - isLoading: isCasesStatusLoading, - fetchCasesStatus, - } = useGetCasesStatus(); - const { - data, - dispatchUpdateCaseProperty, - filterOptions, - loading, - queryParams, - selectedCases, - refetchCases, - setFilters, - setQueryParams, - setSelectedCases, - } = useGetCases(); - - // Delete case - const { - dispatchResetIsDeleted, - handleOnDeleteConfirm, - handleToggleModal, - isLoading: isDeleting, - isDeleted, - isDisplayConfirmDeleteModal, - } = useDeleteCases(); - - // Update case - const { - dispatchResetIsUpdated, - isLoading: isUpdating, - isUpdated, - updateBulkStatus, - } = useUpdateCases(); - const [deleteThisCase, setDeleteThisCase] = useState<DeleteCase>({ - title: '', - id: '', - type: null, - }); - const [deleteBulk, setDeleteBulk] = useState<DeleteCase[]>([]); - const filterRefetch = useRef<() => void>(); - const setFilterRefetch = useCallback( - (refetchFilter: () => void) => { - filterRefetch.current = refetchFilter; - }, - [filterRefetch] - ); - const refreshCases = useCallback( - (dataRefresh = true) => { - if (dataRefresh) refetchCases(); - fetchCasesStatus(); - setSelectedCases([]); - setDeleteBulk([]); - if (filterRefetch.current != null) { - filterRefetch.current(); - } - }, - [filterRefetch, refetchCases, setSelectedCases, fetchCasesStatus] - ); - - useEffect(() => { - if (isDeleted) { - refreshCases(); - dispatchResetIsDeleted(); - } - if (isUpdated) { - refreshCases(); - dispatchResetIsUpdated(); - } - }, [isDeleted, isUpdated, refreshCases, dispatchResetIsDeleted, dispatchResetIsUpdated]); - const confirmDeleteModal = useMemo( - () => ( - <ConfirmDeleteCaseModal - caseTitle={deleteThisCase.title} - isModalVisible={isDisplayConfirmDeleteModal} - isPlural={deleteBulk.length > 0} - onCancel={handleToggleModal} - onConfirm={handleOnDeleteConfirm.bind( - null, - deleteBulk.length > 0 ? deleteBulk : [deleteThisCase] - )} - /> - ), - [ - deleteBulk, - deleteThisCase, - isDisplayConfirmDeleteModal, - handleToggleModal, - handleOnDeleteConfirm, - ] - ); - - const toggleDeleteModal = useCallback( - (deleteCase: Case) => { - handleToggleModal(); - setDeleteThisCase({ id: deleteCase.id, title: deleteCase.title, type: deleteCase.type }); +export const AllCases = React.memo<AllCasesProps>(({ userCanCrud }) => { + const { + cases: casesUi, + application: { navigateToApp }, + } = useKibana().services; + const history = useHistory(); + const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case); + + const goToCreateCase = useCallback( + (ev) => { + ev.preventDefault(); + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: getCreateCaseUrl(urlSearch), + }); + }, + [navigateToApp, urlSearch] + ); + + const goToCaseConfigure = useCallback( + (ev) => { + ev.preventDefault(); + history.push(getConfigureCasesUrl(urlSearch)); + }, + [history, urlSearch] + ); + + return casesUi.getAllCases({ + caseDetailsNavigation: { + href: ({ detailName, subCaseId }: AllCasesNavProps) => { + return formatUrl(getCaseDetailsUrl({ id: detailName, subCaseId })); }, - [handleToggleModal] - ); - - 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] - ); - const handleDispatchUpdate = useCallback( - (args: Omit<UpdateCase, 'refetchCasesStatus'>) => { - dispatchUpdateCaseProperty({ ...args, refetchCasesStatus: fetchCasesStatus }); - }, - [dispatchUpdateCaseProperty, fetchCasesStatus] - ); - - const goToCreateCase = useCallback( - (ev) => { - ev.preventDefault(); - if (isModal && onRowClick != null) { - onRowClick(); - } else { - navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: getCreateCaseUrl(urlSearch), - }); - } - }, - [navigateToApp, isModal, onRowClick, urlSearch] - ); - - const actions = useMemo( - () => - getActions({ - caseStatus: filterOptions.status, - deleteCaseOnClick: toggleDeleteModal, - dispatchUpdate: handleDispatchUpdate, - }), - [filterOptions.status, toggleDeleteModal, handleDispatchUpdate] - ); - - 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 memoizedGetCasesColumns = useMemo( - () => getCasesColumns(userCanCrud ? actions : [], filterOptions.status, isModal), - [actions, filterOptions.status, userCanCrud, isModal] - ); - - const itemIdToExpandedRowMap = useMemo( - () => - getExpandedRowMap({ - columns: memoizedGetCasesColumns, - data: data.cases, - isModal, - onSubCaseClick: onRowClick, - }), - [data.cases, isModal, memoizedGetCasesColumns, onRowClick] - ); - - const memoizedPagination = useMemo( - () => ({ - pageIndex: queryParams.page - 1, - pageSize: queryParams.perPage, - totalItemCount: data.total, - pageSizeOptions: [5, 10, 15, 20, 25], - }), - [data, queryParams] - ); - - const sorting: EuiTableSortingType<Case> = { - sort: { field: queryParams.sortField, direction: queryParams.sortOrder }, - }; - - const euiBasicTableSelectionProps = useMemo<EuiTableSelectionType<Case>>( - () => ({ - onSelectionChange: setSelectedCases, - selectableMessage: (selectable) => (!selectable ? SELECTABLE_MESSAGE_COLLECTIONS : ''), - }), - [setSelectedCases] - ); - const isCasesLoading = useMemo( - () => loading.indexOf('cases') > -1 || loading.indexOf('caseUpdate') > -1, - [loading] - ); - const isDataEmpty = useMemo(() => data.total === 0, [data]); - - const TableWrap = useMemo(() => (isModal ? 'span' : Panel), [isModal]); - - const tableRowProps = useCallback( - (theCase: Case) => { - const onTableRowClick = memoize(() => { - if (onRowClick) { - onRowClick(theCase); - } + onClick: ({ detailName, subCaseId, search }: AllCasesNavProps) => { + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: getCaseDetailsUrl({ id: detailName, search, subCaseId }), }); - - return { - 'data-test-subj': `cases-table-row-${theCase.id}`, - className: classnames({ isDisabled: theCase.type === CaseType.collection }), - ...(isModal && theCase.type !== CaseType.collection ? { onClick: onTableRowClick } : {}), - }; }, - [isModal, onRowClick] - ); - - const enableBuckActions = userCanCrud && !isModal; - - return ( - <> - {!isEmpty(actionsErrors) && ( - <CaseCallOut title={ERROR_PUSH_SERVICE_CALLOUT_TITLE} messages={actionsErrors} /> - )} - {!isModal && ( - <CaseHeaderPage title={i18n.PAGE_TITLE}> - <EuiFlexGroup - alignItems="center" - gutterSize="m" - responsive={false} - wrap={true} - data-test-subj="all-cases-header" - > - <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> - <FlexItemDivider grow={false}> - <Stats - dataTestSubj="closedStatsHeader" - caseCount={countClosedCases} - caseStatus={CaseStatuses.closed} - isLoading={isCasesStatusLoading} - /> - </FlexItemDivider> - <EuiFlexItem grow={false}> - <ConfigureCaseButton - label={i18n.CONFIGURE_CASES_BUTTON} - isDisabled={!isEmpty(actionsErrors) || !userCanCrud} - showToolTip={!isEmpty(actionsErrors)} - msgTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].description : <></>} - titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''} - urlSearch={urlSearch} - /> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <LinkButton - isDisabled={!userCanCrud} - fill - onClick={goToCreateCase} - href={formatUrl(getCreateCaseUrl())} - iconType="plusInCircle" - data-test-subj="createNewCaseBtn" - > - {i18n.CREATE_TITLE} - </LinkButton> - </EuiFlexItem> - </EuiFlexGroup> - </CaseHeaderPage> - )} - {(isCasesLoading || isDeleting || isUpdating) && !isDataEmpty && ( - <ProgressLoader size="xs" color="accent" className="essentialAnimation" /> - )} - <TableWrap data-test-subj="table-wrap" loading={!isModal ? 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} - /> - {isCasesLoading && isDataEmpty ? ( - <Div> - <EuiLoadingContent data-test-subj="initialLoadingPanelAllCases" lines={10} /> - </Div> - ) : ( - <Div> - <UtilityBar border> - <UtilityBarSection> - <UtilityBarGroup> - <UtilityBarText data-test-subj="case-table-case-count"> - {i18n.SHOWING_CASES(data.total ?? 0)} - </UtilityBarText> - </UtilityBarGroup> - {!isModal && ( - <UtilityBarGroup data-test-subj="case-table-utility-bar-actions"> - {enableBuckActions && ( - <UtilityBarText data-test-subj="case-table-selected-case-count"> - {i18n.SHOWING_SELECTED_CASES(selectedCases.length)} - </UtilityBarText> - )} - {enableBuckActions && ( - <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> - </UtilityBar> - <BasicTable - columns={memoizedGetCasesColumns} - data-test-subj="cases-table" - isSelectable={enableBuckActions} - itemId="id" - items={data.cases} - itemIdToExpandedRowMap={itemIdToExpandedRowMap} - 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={formatUrl(getCreateCaseUrl())} - iconType="plusInCircle" - data-test-subj="cases-table-add-case" - > - {i18n.ADD_NEW_CASE} - </LinkButton> - } - /> - } - onChange={tableOnChangeCallback} - pagination={memoizedPagination} - rowProps={tableRowProps} - selection={enableBuckActions ? euiBasicTableSelectionProps : undefined} - sorting={sorting} - className={classnames({ isModal })} - /> - </Div> - )} - </TableWrap> - {confirmDeleteModal} - </> - ); - } -); + }, + configureCasesNavigation: { + href: formatUrl(getConfigureCasesUrl()), + onClick: goToCaseConfigure, + }, + createCaseNavigation: { + href: formatUrl(getCreateCaseUrl()), + onClick: goToCreateCase, + }, + userCanCrud, + }); +}); AllCases.displayName = 'AllCases'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/translations.ts b/x-pack/plugins/security_solution/public/cases/components/all_cases/translations.ts deleted file mode 100644 index ad44959ecb1dc..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/translations.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export * from '../../translations'; - -export const NO_CASES = i18n.translate('xpack.securitySolution.cases.caseTable.noCases.title', { - defaultMessage: 'No Cases', -}); -export const NO_CASES_BODY = i18n.translate('xpack.securitySolution.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.securitySolution.cases.caseTable.addNewCase', { - defaultMessage: 'Add New Case', -}); - -export const SHOWING_SELECTED_CASES = (totalRules: number) => - i18n.translate('xpack.securitySolution.cases.caseTable.selectedCasesTitle', { - values: { totalRules }, - defaultMessage: 'Selected {totalRules} {totalRules, plural, =1 {case} other {cases}}', - }); - -export const SHOWING_CASES = (totalRules: number) => - i18n.translate('xpack.securitySolution.cases.caseTable.showingCasesTitle', { - values: { totalRules }, - defaultMessage: 'Showing {totalRules} {totalRules, plural, =1 {case} other {cases}}', - }); - -export const UNIT = (totalCount: number) => - i18n.translate('xpack.securitySolution.cases.caseTable.unit', { - values: { totalCount }, - defaultMessage: `{totalCount, plural, =1 {case} other {cases}}`, - }); - -export const SEARCH_CASES = i18n.translate( - 'xpack.securitySolution.cases.caseTable.searchAriaLabel', - { - defaultMessage: 'Search cases', - } -); - -export const BULK_ACTIONS = i18n.translate('xpack.securitySolution.cases.caseTable.bulkActions', { - defaultMessage: 'Bulk actions', -}); - -export const EXTERNAL_INCIDENT = i18n.translate( - 'xpack.securitySolution.cases.caseTable.snIncident', - { - defaultMessage: 'External Incident', - } -); - -export const INCIDENT_MANAGEMENT_SYSTEM = i18n.translate( - 'xpack.securitySolution.cases.caseTable.incidentSystem', - { - defaultMessage: 'Incident Management System', - } -); - -export const SEARCH_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.cases.caseTable.searchPlaceholder', - { - defaultMessage: 'e.g. case name', - } -); - -export const CLOSED = i18n.translate('xpack.securitySolution.cases.caseTable.closed', { - defaultMessage: 'Closed', -}); - -export const DELETE = i18n.translate('xpack.securitySolution.cases.caseTable.delete', { - defaultMessage: 'Delete', -}); - -export const REQUIRES_UPDATE = i18n.translate( - 'xpack.securitySolution.cases.caseTable.requiresUpdate', - { - defaultMessage: ' requires update', - } -); - -export const UP_TO_DATE = i18n.translate('xpack.securitySolution.cases.caseTable.upToDate', { - defaultMessage: ' is up to date', -}); -export const NOT_PUSHED = i18n.translate('xpack.securitySolution.cases.caseTable.notPushed', { - defaultMessage: 'Not pushed', -}); - -export const REFRESH = i18n.translate('xpack.securitySolution.cases.caseTable.refreshTitle', { - defaultMessage: 'Refresh', -}); - -export const SERVICENOW_LINK_ARIA = i18n.translate( - 'xpack.securitySolution.cases.caseTable.serviceNowLinkAria', - { - defaultMessage: 'click to view the incident on servicenow', - } -); - -export const STATUS = i18n.translate('xpack.securitySolution.cases.caseTable.status', { - defaultMessage: 'Status', -}); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx index 18a76e2766d8d..5eb0a03fc5db7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx @@ -5,57 +5,9 @@ * 2.0. */ -import { AssociationType, CommentType } from '../../../../../cases/common/api'; -import { Comment } from '../../containers/types'; - -import { getManualAlertIdsWithNoRuleId, buildAlertsQuery } 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', - }, -]; +import { buildAlertsQuery } from './helpers'; 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']); - }); - }); - describe('buildAlertsQuery', () => { it('it builds the alerts query', () => { expect(buildAlertsQuery(['alert-id-1', 'alert-id-2'])).toEqual({ diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts index 7211f4bca6a37..336cf20ffb3b8 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts @@ -5,21 +5,12 @@ * 2.0. */ -import { isEmpty } from 'lodash'; -import { CommentType } from '../../../../../cases/common/api'; -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]; -}; +import { isObject, get, isString, isNumber } from 'lodash'; +import { useMemo } from 'react'; +import { useSourcererScope } from '../../../common/containers/sourcerer'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query'; +import { Ecs } from '../../../../../cases/common'; // TODO we need to allow -> docValueFields: [{ field: "@timestamp" }], export const buildAlertsQuery = (alertIds: string[]) => { @@ -39,3 +30,102 @@ export const buildAlertsQuery = (alertIds: string[]) => { size: 10000, }; }; + +export const toStringArray = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value.reduce<string[]>((acc, v) => { + if (v != null) { + switch (typeof v) { + case 'number': + case 'boolean': + return [...acc, v.toString()]; + case 'object': + try { + return [...acc, JSON.stringify(v)]; + } catch { + return [...acc, 'Invalid Object']; + } + case 'string': + return [...acc, v]; + default: + return [...acc, `${v}`]; + } + } + return acc; + }, []); + } else if (value == null) { + return []; + } else if (!Array.isArray(value) && typeof value === 'object') { + try { + return [JSON.stringify(value)]; + } catch { + return ['Invalid Object']; + } + } else { + return [`${value}`]; + } +}; + +export const formatAlertToEcsSignal = (alert: {}): Ecs => + Object.keys(alert).reduce<Ecs>((accumulator, key) => { + const item = get(alert, key); + if (item != null && isObject(item)) { + return { ...accumulator, [key]: formatAlertToEcsSignal(item) }; + } else if (Array.isArray(item) || isString(item) || isNumber(item)) { + return { ...accumulator, [key]: toStringArray(item) }; + } + return accumulator; + }, {} as Ecs); +interface Signal { + rule: { + id: string; + name: string; + to: string; + from: string; + }; +} + +interface SignalHit { + _id: string; + _index: string; + _source: { + '@timestamp': string; + signal: Signal; + }; +} + +export interface Alert { + _id: string; + _index: string; + '@timestamp': string; + signal: Signal; + [key: string]: unknown; +} +export const useFetchAlertData = (alertIds: string[]): [boolean, Record<string, Ecs>] => { + const { selectedPatterns } = useSourcererScope(SourcererScopeName.detections); + const alertsQuery = useMemo(() => buildAlertsQuery(alertIds), [alertIds]); + + const { loading: isLoadingAlerts, data: alertsData } = useQueryAlerts<SignalHit, unknown>( + alertsQuery, + selectedPatterns[0] + ); + + const alerts = useMemo( + () => + alertsData?.hits.hits.reduce<Record<string, Ecs>>( + (acc, { _id, _index, _source }) => ({ + ...acc, + [_id]: { + ...formatAlertToEcsSignal(_source), + _id, + _index, + timestamp: _source['@timestamp'], + }, + }), + {} + ) ?? {}, + [alertsData?.hits.hits] + ); + + return [isLoadingAlerts, alerts]; +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 892663c783293..b0f3ccb8c21ad 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -5,51 +5,37 @@ * 2.0. */ -import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import React, { useCallback, useState } 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 { useHistory } from 'react-router-dom'; +import { SearchResponse } from 'elasticsearch'; +import { isEmpty } from 'lodash'; -import { CaseStatuses, CaseAttributes, CaseType } from '../../../../../cases/common/api'; -import { Case, CaseConnector } from '../../containers/types'; -import { getCaseDetailsUrl, getCaseUrl, useFormatUrl } from '../../../common/components/link_to'; -import { gutterTimeline } from '../../../common/lib/helpers'; -import { HeaderPage } from '../../../common/components/header_page'; -import { EditableTitle } from '../../../common/components/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 { SpyRoute } from '../../../common/utils/route/spy_routes'; -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 { SecurityPageName } from '../../../app/types'; import { - getConnectorById, - normalizeActionConnector, - getNoneConnector, -} from '../configure_cases/utils'; -import { DetailsPanel } from '../../../timelines/components/side_panel'; -import { useSourcererScope } from '../../../common/containers/sourcerer'; -import { SourcererScopeName } from '../../../common/store/sourcerer/model'; + getCaseDetailsUrl, + getCaseDetailsUrlWithCommentId, + getCaseUrl, + getConfigureCasesUrl, + getRuleDetailsUrl, + useFormatUrl, +} from '../../../common/components/link_to'; +import { Ecs } from '../../../../common/ecs'; +import { Case } from '../../../../../cases/common'; +import { TimelineNonEcsData } from '../../../../common/search_strategy'; import { TimelineId } from '../../../../common/types/timeline'; +import { SecurityPageName } from '../../../app/types'; +import { KibanaServices, useKibana } from '../../../common/lib/kibana'; +import { APP_ID, DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../common/constants'; import { timelineActions } from '../../../timelines/store/timeline'; -import { StatusActionButton } from '../status/button'; - -import * as i18n from './translations'; +import { useSourcererScope } from '../../../common/containers/sourcerer'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { DetailsPanel } from '../../../timelines/components/side_panel'; +import { InvestigateInTimelineAction } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_timeline_action'; +import { buildAlertsQuery, formatAlertToEcsSignal, useFetchAlertData } from './helpers'; +import { SEND_ALERT_TO_TIMELINE } from './translations'; +import { useInsertTimeline } from '../use_insert_timeline'; +import { SpyRoute } from '../../../common/utils/route/spy_routes'; +import * as timelineMarkdownPlugin from '../../../common/components/markdown_editor/plugins/timeline'; interface Props { caseId: string; @@ -64,449 +50,207 @@ export interface OnUpdateFields { 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 CaseProps extends Props { fetchCase: () => void; caseData: Case; updateCase: (newCase: Case) => void; } -export const CaseComponent = React.memo<CaseProps>( - ({ caseId, caseData, fetchCase, subCaseId, updateCase, userCanCrud }) => { - const dispatch = useDispatch(); - const { formatUrl, search } = useFormatUrl(SecurityPageName.case); - const allCasesLink = getCaseUrl(search); - const caseDetailsLink = formatUrl(getCaseDetailsUrl({ id: caseId }), { absolute: true }); - const [initLoadingData, setInitLoadingData] = useState(true); - const init = useRef(true); - - const { - caseUserActions, - fetchCaseUserActions, - caseServices, - hasDataToPush, - isLoading: isLoadingUserActions, - participants, - } = useGetCaseUserActions(caseId, caseData.connector.id, subCaseId); +const TimelineDetailsPanel = () => { + const { browserFields, docValueFields } = useSourcererScope(SourcererScopeName.detections); - const { isLoading, updateKey, updateCaseProperty } = useUpdateCase({ - caseId, - subCaseId, - }); - - /** - * For the future developer: useSourcererScope is security solution dependent. - * You can use useSignalIndex as an alternative. - */ - const { browserFields, docValueFields } = useSourcererScope(SourcererScopeName.detections); - - // 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({ - 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, + return ( + <DetailsPanel + browserFields={browserFields} + docValueFields={docValueFields} + isFlyoutView + timelineId={TimelineId.casePage} + /> + ); +}; + +const InvestigateInTimelineActionComponent = (alertIds: string[]) => { + const EMPTY_ARRAY: TimelineNonEcsData[] = []; + const fetchEcsAlertsData = async (fetchAlertIds?: string[]): Promise<Ecs[]> => { + if (isEmpty(fetchAlertIds)) { + return []; + } + const alertResponse = await KibanaServices.get().http.fetch< + SearchResponse<{ '@timestamp': string; [key: string]: unknown }> + >(DETECTION_ENGINE_QUERY_SIGNALS_URL, { + method: 'POST', + body: JSON.stringify(buildAlertsQuery(fetchAlertIds ?? [])), }); - - 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] + return ( + alertResponse?.hits.hits.reduce<Ecs[]>( + (acc, { _id, _index, _source }) => [ + ...acc, + { + ...formatAlertToEcsSignal(_source as {}), + _id, + _index, + timestamp: _source['@timestamp'], + }, + ], + [] + ) ?? [] ); + }; - const handleRefresh = useCallback(() => { - fetchCaseUserActions(caseId, caseData.connector.id, subCaseId); - fetchCase(); - }, [caseData.connector.id, caseId, fetchCase, fetchCaseUserActions, subCaseId]); - - const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); - - const emailContent = useMemo( - () => ({ - subject: i18n.EMAIL_SUBJECT(caseData.title), - body: i18n.EMAIL_BODY(caseDetailsLink), - }), - [caseDetailsLink, caseData.title] - ); + return ( + <InvestigateInTimelineAction + ariaLabel={SEND_ALERT_TO_TIMELINE} + alertIds={alertIds} + key="investigate-in-timeline" + ecsRowData={null} + fetchEcsAlertsData={fetchEcsAlertsData} + nonEcsRowData={EMPTY_ARRAY} + /> + ); +}; - useEffect(() => { - if (initLoadingData && !isLoadingUserActions) { - setInitLoadingData(false); +export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) => { + const [spyState, setSpyState] = useState<{ caseTitle: string | undefined }>({ + caseTitle: undefined, + }); + + const onCaseDataSuccess = useCallback( + (data: Case) => { + if (spyState.caseTitle === undefined) { + setSpyState({ caseTitle: data.title }); } - }, [initLoadingData, isLoadingUserActions]); + }, + [spyState.caseTitle] + ); - const backOptions = useMemo( - () => ({ - href: allCasesLink, - text: i18n.BACK_TO_ALL, - dataTestSubj: 'backToCases', - pageId: SecurityPageName.case, - }), - [allCasesLink] - ); + const { + cases: casesUi, + application: { navigateToApp }, + } = useKibana().services; + const history = useHistory(); + const dispatch = useDispatch(); + const { formatUrl, search } = useFormatUrl(SecurityPageName.case); + const { formatUrl: detectionsFormatUrl, search: detectionsUrlSearch } = useFormatUrl( + SecurityPageName.detections + ); - const showAlert = useCallback( - (alertId: string, index: string) => { - dispatch( - timelineActions.toggleDetailPanel({ - panelView: 'eventDetail', - timelineId: TimelineId.casePage, - params: { - eventId: alertId, - indexName: index, - }, - }) - ); - }, - [dispatch] - ); + const allCasesLink = getCaseUrl(search); + const formattedAllCasesLink = formatUrl(allCasesLink); + const backToAllCasesOnClick = useCallback( + (ev) => { + ev.preventDefault(); + history.push(allCasesLink); + }, + [allCasesLink, history] + ); + const caseDetailsLink = formatUrl(getCaseDetailsUrl({ id: caseId }), { absolute: true }); + const getCaseDetailHrefWithCommentId = (commentId: string) => { + return formatUrl(getCaseDetailsUrlWithCommentId({ id: caseId, commentId, subCaseId }), { + absolute: true, + }); + }; + + const configureCasesHref = formatUrl(getConfigureCasesUrl()); + const onConfigureCasesNavClick = useCallback( + (ev) => { + ev.preventDefault(); + history.push(getConfigureCasesUrl(search)); + }, + [history, search] + ); - // useEffect used for component's initialization - useEffect(() => { - if (init.current) { - init.current = false; - // We need to create a timeline to show the details view - dispatch( - timelineActions.createTimeline({ - id: TimelineId.casePage, - columns: [], - indexNames: [], - expandedDetail: {}, - show: false, - }) - ); - } - }, [dispatch]); + const onDetectionsRuleDetailsClick = useCallback( + (ruleId: string | null | undefined) => { + navigateToApp(`${APP_ID}:${SecurityPageName.detections}`, { + path: getRuleDetailsUrl(ruleId ?? ''), + }); + }, + [navigateToApp] + ); - return ( - <> - <HeaderWrapper> - <HeaderPage - backOptions={backOptions} - data-test-subj="case-view-title" - hideSourcerer={true} - 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 - caseServices={caseServices} - caseUserActions={caseUserActions} - connectors={connectors} - data={caseData} - fetchUserActions={fetchCaseUserActions.bind( - null, - caseId, - caseData.connector.id, - subCaseId - )} - isLoadingDescription={isLoading && updateKey === 'description'} - isLoadingUserActions={isLoadingUserActions} - onShowAlertDetails={showAlert} - onUpdateField={onUpdateField} - updateCase={updateCase} - 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> - <DetailsPanel - browserFields={browserFields} - docValueFields={docValueFields} - isFlyoutView - timelineId={TimelineId.casePage} - /> - <SpyRoute state={spyState} pageName={SecurityPageName.case} /> - </> - ); - } -); + const getDetectionsRuleDetailsHref = useCallback( + (ruleId) => { + return detectionsFormatUrl(getRuleDetailsUrl(ruleId ?? '', detectionsUrlSearch)); + }, + [detectionsFormatUrl, detectionsUrlSearch] + ); -export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) => { - const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId, subCaseId); - if (isError) { - return null; - } + const showAlertDetails = useCallback( + (alertId: string, index: string) => { + dispatch( + timelineActions.toggleDetailPanel({ + panelView: 'eventDetail', + timelineId: TimelineId.casePage, + params: { + eventId: alertId, + indexName: index, + }, + }) + ); + }, + [dispatch] + ); - if (isLoading) { - return ( - <MyEuiFlexGroup gutterSize="none" justifyContent="center" alignItems="center"> - <EuiFlexItem grow={false}> - <EuiLoadingSpinner data-test-subj="case-view-loading" size="xl" /> - </EuiFlexItem> - </MyEuiFlexGroup> + const onComponentInitialized = useCallback(() => { + dispatch( + timelineActions.createTimeline({ + id: TimelineId.casePage, + columns: [], + indexNames: [], + expandedDetail: {}, + show: false, + }) ); - } - + }, [dispatch]); return ( - data && ( - <CaseComponent - caseId={caseId} - subCaseId={subCaseId} - fetchCase={fetchCase} - caseData={data} - updateCase={updateCase} - userCanCrud={userCanCrud} - /> - ) + <> + {casesUi.getCaseView({ + allCasesNavigation: { + href: formattedAllCasesLink, + onClick: backToAllCasesOnClick, + }, + caseDetailsNavigation: { + href: caseDetailsLink, + onClick: () => { + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: getCaseDetailsUrl({ id: caseId }), + }); + }, + }, + caseId, + configureCasesNavigation: { + href: configureCasesHref, + onClick: onConfigureCasesNavClick, + }, + getCaseDetailHrefWithCommentId, + onCaseDataSuccess, + onComponentInitialized, + ruleDetailsNavigation: { + href: getDetectionsRuleDetailsHref, + onClick: onDetectionsRuleDetailsClick, + }, + showAlertDetails, + subCaseId, + timelineIntegration: { + editor_plugins: { + parsingPlugin: timelineMarkdownPlugin.parser, + processingPluginRenderer: timelineMarkdownPlugin.renderer, + uiPlugin: timelineMarkdownPlugin.plugin, + }, + hooks: { + useInsertTimeline, + }, + ui: { + renderInvestigateInTimelineActionComponent: InvestigateInTimelineActionComponent, + renderTimelineDetailsPanel: TimelineDetailsPanel, + }, + }, + useFetchAlertData, + userCanCrud, + })} + <SpyRoute state={spyState} pageName={SecurityPageName.case} /> + </> ); }); -CaseComponent.displayName = 'CaseComponent'; CaseView.displayName = 'CaseView'; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts b/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts index f4403a43af697..d7b66bbac38df 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts @@ -6,152 +6,9 @@ */ import { i18n } from '@kbn/i18n'; - -export * from '../../translations'; - -export const SHOWING_CASES = (actionDate: string, actionName: string, userName: string) => - i18n.translate('xpack.securitySolution.cases.caseView.actionHeadline', { - values: { - actionDate, - actionName, - userName, - }, - defaultMessage: '{userName} {actionName} on {actionDate}', - }); - -export const ADDED_FIELD = i18n.translate( - 'xpack.securitySolution.cases.caseView.actionLabel.addedField', - { - defaultMessage: 'added', - } -); - -export const CHANGED_FIELD = i18n.translate( - 'xpack.securitySolution.cases.caseView.actionLabel.changededField', - { - defaultMessage: 'changed', - } -); - -export const SELECTED_THIRD_PARTY = (thirdParty: string) => - i18n.translate('xpack.securitySolution.cases.caseView.actionLabel.selectedThirdParty', { - values: { - thirdParty, - }, - defaultMessage: 'selected { thirdParty } as incident management system', - }); - -export const REMOVED_THIRD_PARTY = i18n.translate( - 'xpack.securitySolution.cases.caseView.actionLabel.removedThirdParty', - { - defaultMessage: 'removed external incident management system', - } -); - -export const EDITED_FIELD = i18n.translate( - 'xpack.securitySolution.cases.caseView.actionLabel.editedField', - { - defaultMessage: 'edited', - } -); - -export const REMOVED_FIELD = i18n.translate( - 'xpack.securitySolution.cases.caseView.actionLabel.removedField', - { - defaultMessage: 'removed', - } -); - -export const VIEW_INCIDENT = (incidentNumber: string) => - i18n.translate('xpack.securitySolution.cases.caseView.actionLabel.viewIncident', { - defaultMessage: 'View {incidentNumber}', - values: { - incidentNumber, - }, - }); - -export const PUSHED_NEW_INCIDENT = i18n.translate( - 'xpack.securitySolution.cases.caseView.actionLabel.pushedNewIncident', +export const SEND_ALERT_TO_TIMELINE = i18n.translate( + 'xpack.securitySolution.cases.caseView.sendAlertToTimelineTooltip', { - defaultMessage: 'pushed as new incident', + defaultMessage: 'Investigate in timeline', } ); - -export const UPDATE_INCIDENT = i18n.translate( - 'xpack.securitySolution.cases.caseView.actionLabel.updateIncident', - { - defaultMessage: 'updated incident', - } -); - -export const ADDED_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.cases.caseView.actionLabel.addDescription', - { - defaultMessage: 'added description', - } -); - -export const EDIT_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.cases.caseView.edit.description', - { - defaultMessage: 'Edit description', - } -); - -export const QUOTE = i18n.translate('xpack.securitySolution.cases.caseView.edit.quote', { - defaultMessage: 'Quote', -}); - -export const EDIT_COMMENT = i18n.translate('xpack.securitySolution.cases.caseView.edit.comment', { - defaultMessage: 'Edit comment', -}); - -export const ON = i18n.translate('xpack.securitySolution.cases.caseView.actionLabel.on', { - defaultMessage: 'on', -}); - -export const ADDED_COMMENT = i18n.translate( - 'xpack.securitySolution.cases.caseView.actionLabel.addComment', - { - defaultMessage: 'added comment', - } -); - -export const STATUS = i18n.translate('xpack.securitySolution.cases.caseView.statusLabel', { - defaultMessage: 'Status', -}); - -export const CASE = i18n.translate('xpack.securitySolution.cases.caseView.case', { - defaultMessage: 'case', -}); - -export const COMMENT = i18n.translate('xpack.securitySolution.cases.caseView.comment', { - defaultMessage: 'comment', -}); - -export const CASE_REFRESH = i18n.translate('xpack.securitySolution.cases.caseView.caseRefresh', { - defaultMessage: 'Refresh case', -}); - -export const EMAIL_SUBJECT = (caseTitle: string) => - i18n.translate('xpack.securitySolution.cases.caseView.emailSubject', { - values: { caseTitle }, - defaultMessage: 'Security Case - {caseTitle}', - }); - -export const EMAIL_BODY = (caseUrl: string) => - i18n.translate('xpack.securitySolution.cases.caseView.emailBody', { - values: { caseUrl }, - defaultMessage: 'Case reference: {caseUrl}', - }); - -export const CHANGED_CONNECTOR_FIELD = i18n.translate( - 'xpack.securitySolution.cases.caseView.fieldChanged', - { - defaultMessage: `changed connector field`, - } -); - -export const SYNC_ALERTS = i18n.translate('xpack.securitySolution.cases.caseView.syncAlertsLabel', { - defaultMessage: `Sync alerts`, -}); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts deleted file mode 100644 index 77c263385df0a..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const URGENCY = i18n.translate( - 'xpack.securitySolution.components.connectors.serviceNow.urgencySelectFieldLabel', - { - defaultMessage: 'Urgency', - } -); - -export const SEVERITY = i18n.translate( - 'xpack.securitySolution.components.connectors.serviceNow.severitySelectFieldLabel', - { - defaultMessage: 'Severity', - } -); - -export const IMPACT = i18n.translate( - 'xpack.securitySolution.components.connectors.serviceNow.impactSelectFieldLabel', - { - defaultMessage: 'Impact', - } -); - -export const CHOICES_API_ERROR = i18n.translate( - 'xpack.securitySolution.components.connectors.serviceNow.unableToGetChoicesMessage', - { - defaultMessage: 'Unable to get choices', - } -); - -export const MALWARE_URL = i18n.translate( - 'xpack.securitySolution.components.connectors.serviceNow.malwareURLTitle', - { - defaultMessage: 'Malware URL', - } -); - -export const MALWARE_HASH = i18n.translate( - 'xpack.securitySolution.components.connectors.serviceNow.malwareHashTitle', - { - defaultMessage: 'Malware Hash', - } -); - -export const CATEGORY = i18n.translate( - 'xpack.securitySolution.components.connectors.serviceNow.categoryTitle', - { - defaultMessage: 'Category', - } -); - -export const SUBCATEGORY = i18n.translate( - 'xpack.securitySolution.components.connectors.serviceNow.subcategoryTitle', - { - defaultMessage: 'Subcategory', - } -); - -export const SOURCE_IP = i18n.translate( - 'xpack.securitySolution.components.connectors.serviceNow.sourceIPTitle', - { - defaultMessage: 'Source IP', - } -); - -export const DEST_IP = i18n.translate( - 'xpack.securitySolution.components.connectors.serviceNow.destinationIPTitle', - { - defaultMessage: 'Destination IP', - } -); - -export const PRIORITY = i18n.translate( - 'xpack.securitySolution.components.connectors.serviceNow.prioritySelectFieldTitle', - { - defaultMessage: 'Priority', - } -); - -export const ALERT_FIELDS_LABEL = i18n.translate( - 'xpack.securitySolution.components.connectors.serviceNow.alertFieldsTitle', - { - defaultMessage: 'Select Observables to push', - } -); - -export const ALERT_FIELD_ENABLED_TEXT = i18n.translate( - 'xpack.securitySolution.components.connectors.serviceNow.alertFieldEnabledText', - { - defaultMessage: 'Yes', - } -); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx index d5883b7b88cd0..d413a2d5e0018 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx @@ -5,57 +5,22 @@ * 2.0. */ -/* eslint-disable react/display-name */ -import React, { ReactNode } from 'react'; +import React from 'react'; import { mount } from 'enzyme'; import '../../../common/mock/match_media'; import { CreateCaseFlyout } from './flyout'; import { TestProviders } from '../../../common/mock'; -jest.mock('../create/form_context', () => { - return { - FormContext: ({ - children, - onSuccess, - }: { - children: ReactNode; - onSuccess: ({ id }: { id: string }) => Promise<void>; - }) => { - return ( - <> - <button - type="button" - data-test-subj="form-context-on-success" - onClick={async () => { - await onSuccess({ id: 'case-id' }); - }} - > - {'submit'} - </button> - {children} - </> - ); +jest.mock('../../../common/lib/kibana', () => ({ + useKibana: () => ({ + services: { + cases: { + getCreateCase: jest.fn(), + }, }, - }; -}); - -jest.mock('../create/form', () => { - return { - CreateCaseForm: () => { - return <>{'form'}</>; - }, - }; -}); - -jest.mock('../create/submit_button', () => { - return { - SubmitCaseButton: () => { - return <>{'Submit'}</>; - }, - }; -}); - + }), +})); const onCloseFlyout = jest.fn(); const onSuccess = jest.fn(); const defaultProps = { @@ -88,30 +53,4 @@ describe('CreateCaseFlyout', () => { wrapper.find('.euiFlyout__closeButton').first().simulate('click'); expect(onCloseFlyout).toBeCalled(); }); - - it('pass the correct props to FormContext component', () => { - const wrapper = mount( - <TestProviders> - <CreateCaseFlyout {...defaultProps} /> - </TestProviders> - ); - - const props = wrapper.find('FormContext').props(); - expect(props).toEqual( - expect.objectContaining({ - onSuccess, - }) - ); - }); - - it('onSuccess called when creating a case', () => { - const wrapper = mount( - <TestProviders> - <CreateCaseFlyout {...defaultProps} /> - </TestProviders> - ); - - wrapper.find(`[data-test-subj='form-context-on-success']`).first().simulate('click'); - expect(onSuccess).toHaveBeenCalledWith({ id: 'case-id' }); - }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx b/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx index 8f76ee8f85173..0f9f64b32bdd0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx @@ -9,25 +9,16 @@ import React, { memo } from 'react'; import styled from 'styled-components'; import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui'; -import { FormContext } from '../create/form_context'; -import { CreateCaseForm } from '../create/form'; -import { SubmitCaseButton } from '../create/submit_button'; -import { Case } from '../../containers/types'; import * as i18n from '../../translations'; +import { useKibana } from '../../../common/lib/kibana'; +import { Case } from '../../../../../cases/common'; export interface CreateCaseModalProps { + afterCaseCreated?: (theCase: Case) => Promise<void>; onCloseFlyout: () => void; onSuccess: (theCase: Case) => Promise<void>; - afterCaseCreated?: (theCase: Case) => Promise<void>; } -const Container = styled.div` - ${({ theme }) => ` - margin-top: ${theme.eui.euiSize}; - text-align: right; - `} -`; - const StyledFlyout = styled(EuiFlyout)` ${({ theme }) => ` z-index: ${theme.eui.euiZModal}; @@ -55,10 +46,11 @@ const FormWrapper = styled.div` `; const CreateCaseFlyoutComponent: React.FC<CreateCaseModalProps> = ({ - onSuccess, afterCaseCreated, onCloseFlyout, + onSuccess, }) => { + const { cases } = useKibana().services; return ( <StyledFlyout onClose={onCloseFlyout} data-test-subj="create-case-flyout"> <EuiFlyoutHeader hasBorder> @@ -68,12 +60,12 @@ const CreateCaseFlyoutComponent: React.FC<CreateCaseModalProps> = ({ </EuiFlyoutHeader> <StyledEuiFlyoutBody> <FormWrapper> - <FormContext onSuccess={onSuccess} afterCaseCreated={afterCaseCreated}> - <CreateCaseForm withSteps={false} /> - <Container> - <SubmitCaseButton /> - </Container> - </FormContext> + {cases.getCreateCase({ + afterCaseCreated, + onCancel: onCloseFlyout, + onSuccess, + withSteps: false, + })} </FormWrapper> </StyledEuiFlyoutBody> </StyledFlyout> diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx index 7172d227f492e..2d5faef8aa009 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx @@ -6,91 +6,39 @@ */ import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; +import { mount } from 'enzyme'; import { act, waitFor } from '@testing-library/react'; import { noop } from 'lodash/fp'; -import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { TestProviders } from '../../../common/mock'; -import { useGetTags } from '../../containers/use_get_tags'; -import { useConnectors } from '../../containers/configure/use_connectors'; -import { useCaseConfigure } from '../../containers/configure/use_configure'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; -import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; -import { useGetSeverity } from '../connectors/resilient/use_get_severity'; -import { useGetIssueTypes } from '../connectors/jira/use_get_issue_types'; -import { useGetFieldsByIssueType } from '../connectors/jira/use_get_fields_by_issue_type'; -import { useCaseConfigureResponse } from '../configure_cases/__mock__'; import { useInsertTimeline } from '../use_insert_timeline'; -import { - sampleConnectorData, - sampleData, - sampleTags, - useGetIncidentTypesResponse, - useGetSeverityResponse, - useGetIssueTypesResponse, - useGetFieldsByIssueTypeResponse, -} from './mock'; import { Create } from '.'; +import { useKibana } from '../../../common/lib/kibana'; +import { Case } from '../../../../../cases/public/containers/types'; +import { basicCase } from '../../../../../cases/public/containers/mock'; -jest.mock('../../containers/api'); -jest.mock('../../containers/use_get_tags'); -jest.mock('../../containers/configure/use_connectors'); -jest.mock('../../containers/configure/use_configure'); -jest.mock('../connectors/resilient/use_get_incident_types'); -jest.mock('../connectors/resilient/use_get_severity'); -jest.mock('../connectors/jira/use_get_issue_types'); -jest.mock('../connectors/jira/use_get_fields_by_issue_type'); -jest.mock('../connectors/jira/use_get_single_issue'); -jest.mock('../connectors/jira/use_get_issues'); jest.mock('../use_insert_timeline'); +jest.mock('../../../common/lib/kibana'); -const useConnectorsMock = useConnectors as jest.Mock; -const useCaseConfigureMock = useCaseConfigure as jest.Mock; -const useGetTagsMock = useGetTags as jest.Mock; -const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; -const useGetSeverityMock = useGetSeverity as jest.Mock; -const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; -const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; const useInsertTimelineMock = useInsertTimeline as jest.Mock; -const fetchTags = jest.fn(); - -const fillForm = (wrapper: ReactWrapper) => { - wrapper - .find(`[data-test-subj="caseTitle"] input`) - .first() - .simulate('change', { target: { value: sampleData.title } }); - - wrapper - .find(`[data-test-subj="caseDescription"] textarea`) - .first() - .simulate('change', { target: { value: sampleData.description } }); - - act(() => { - ((wrapper.find(EuiComboBox).props() as unknown) as { - onChange: (a: EuiComboBoxOptionOption[]) => void; - }).onChange(sampleTags.map((tag) => ({ label: tag }))); - }); -}; describe('Create case', () => { + const mockCreateCase = jest.fn(); beforeEach(() => { jest.resetAllMocks(); jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); - useConnectorsMock.mockReturnValue(sampleConnectorData); - useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); - useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); - useGetSeverityMock.mockReturnValue(useGetSeverityResponse); - useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); - useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse); - useGetTagsMock.mockImplementation(() => ({ - tags: sampleTags, - fetchTags, - })); + (useKibana as jest.Mock).mockReturnValue({ + services: { + cases: { + getCreateCase: mockCreateCase, + }, + }, + }); }); - it('it renders', async () => { - const wrapper = mount( + it('it renders', () => { + mount( <TestProviders> <Router history={mockHistory}> <Create /> @@ -98,12 +46,20 @@ describe('Create case', () => { </TestProviders> ); - expect(wrapper.find(`[data-test-subj="create-case-submit"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="create-case-cancel"]`).exists()).toBeTruthy(); + expect(mockCreateCase).toHaveBeenCalled(); }); it('should redirect to all cases on cancel click', async () => { - const wrapper = mount( + (useKibana as jest.Mock).mockReturnValue({ + services: { + cases: { + getCreateCase: ({ onCancel }: { onCancel: () => Promise<void> }) => { + onCancel(); + }, + }, + }, + }); + mount( <TestProviders> <Router history={mockHistory}> <Create /> @@ -111,12 +67,20 @@ describe('Create case', () => { </TestProviders> ); - wrapper.find(`[data-test-subj="create-case-cancel"]`).first().simulate('click'); await waitFor(() => expect(mockHistory.push).toHaveBeenCalledWith('/')); }); it('should redirect to new case when posting the case', async () => { - const wrapper = mount( + (useKibana as jest.Mock).mockReturnValue({ + services: { + cases: { + getCreateCase: ({ onSuccess }: { onSuccess: (theCase: Case) => Promise<void> }) => { + onSuccess(basicCase); + }, + }, + }, + }); + mount( <TestProviders> <Router history={mockHistory}> <Create /> @@ -124,13 +88,10 @@ describe('Create case', () => { </TestProviders> ); - fillForm(wrapper); - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - await waitFor(() => expect(mockHistory.push).toHaveBeenNthCalledWith(1, '/basic-case-id')); }); - it('it should insert a timeline', async () => { + it.skip('it should insert a timeline', async () => { let attachTimeline = noop; useInsertTimelineMock.mockImplementation((value, onTimelineAttached) => { attachTimeline = onTimelineAttached; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index 9f904350b772e..4a1a64f5fcb41 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -6,39 +6,16 @@ */ import React, { useCallback } from 'react'; -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; -import styled from 'styled-components'; +import { EuiPanel } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; -import { Field, getUseField, useFormContext } from '../../../shared_imports'; import { getCaseDetailsUrl } from '../../../common/components/link_to'; -import * as i18n from './translations'; -import { CreateCaseForm } from './form'; -import { FormContext } from './form_context'; +import { useKibana } from '../../../common/lib/kibana'; +import * as timelineMarkdownPlugin from '../../../common/components/markdown_editor/plugins/timeline'; import { useInsertTimeline } from '../use_insert_timeline'; -import { fieldName as descriptionFieldName } from './description'; -import { SubmitCaseButton } from './submit_button'; - -export const CommonUseField = getUseField({ component: Field }); - -const Container = styled.div` - ${({ theme }) => ` - margin-top: ${theme.eui.euiSize}; - `} -`; - -const InsertTimeline = () => { - const { setFieldValue, getFormData } = useFormContext(); - const formData = getFormData(); - const onTimelineAttached = useCallback( - (newValue: string) => setFieldValue(descriptionFieldName, newValue), - [setFieldValue] - ); - useInsertTimeline(formData[descriptionFieldName] ?? '', onTimelineAttached); - return null; -}; export const Create = React.memo(() => { + const { cases } = useKibana().services; const history = useHistory(); const onSuccess = useCallback( async ({ id }) => { @@ -53,32 +30,20 @@ export const Create = React.memo(() => { return ( <EuiPanel> - <FormContext onSuccess={onSuccess}> - <CreateCaseForm /> - <Container> - <EuiFlexGroup - alignItems="center" - justifyContent="flexEnd" - gutterSize="xs" - responsive={false} - > - <EuiFlexItem grow={false}> - <EuiButtonEmpty - data-test-subj="create-case-cancel" - size="s" - onClick={handleSetIsCancel} - iconType="cross" - > - {i18n.CANCEL} - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <SubmitCaseButton /> - </EuiFlexItem> - </EuiFlexGroup> - </Container> - <InsertTimeline /> - </FormContext> + {cases.getCreateCase({ + onCancel: handleSetIsCancel, + onSuccess, + timelineIntegration: { + editor_plugins: { + parsingPlugin: timelineMarkdownPlugin.parser, + processingPluginRenderer: timelineMarkdownPlugin.renderer, + uiPlugin: timelineMarkdownPlugin.plugin, + }, + hooks: { + useInsertTimeline, + }, + }, + })} </EuiPanel> ); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/translations.ts b/x-pack/plugins/security_solution/public/cases/components/create/translations.ts deleted file mode 100644 index d9373dade1b68..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/create/translations.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export * from '../../translations'; - -export const STEP_ONE_TITLE = i18n.translate( - 'xpack.securitySolution.components.create.stepOneTitle', - { - defaultMessage: 'Case fields', - } -); - -export const STEP_TWO_TITLE = i18n.translate( - 'xpack.securitySolution.components.create.stepTwoTitle', - { - defaultMessage: 'Case settings', - } -); - -export const STEP_THREE_TITLE = i18n.translate( - 'xpack.securitySolution.components.create.stepThreeTitle', - { - defaultMessage: 'External Connector Fields', - } -); - -export const SYNC_ALERTS_LABEL = i18n.translate( - 'xpack.securitySolution.components.create.syncAlertsLabel', - { - defaultMessage: 'Sync alert status with case status', - } -); diff --git a/x-pack/plugins/security_solution/public/cases/components/status/translations.ts b/x-pack/plugins/security_solution/public/cases/components/status/translations.ts deleted file mode 100644 index 6c26513785026..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/status/translations.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -export * from '../../translations'; - -export const ALL = i18n.translate('xpack.securitySolution.cases.status.all', { - defaultMessage: 'All', -}); - -export const OPEN = i18n.translate('xpack.securitySolution.cases.status.open', { - defaultMessage: 'Open', -}); - -export const IN_PROGRESS = i18n.translate('xpack.securitySolution.cases.status.inProgress', { - defaultMessage: 'In progress', -}); - -export const CLOSED = i18n.translate('xpack.securitySolution.cases.status.closed', { - defaultMessage: 'Closed', -}); - -export const STATUS_ICON_ARIA = i18n.translate('xpack.securitySolution.cases.status.iconAria', { - defaultMessage: 'Change status', -}); - -export const CASE_OPENED = i18n.translate('xpack.securitySolution.cases.caseView.caseOpened', { - defaultMessage: 'Case opened', -}); - -export const CASE_IN_PROGRESS = i18n.translate( - 'xpack.securitySolution.cases.caseView.caseInProgress', - { - defaultMessage: 'Case in progress', - } -); - -export const CASE_CLOSED = i18n.translate('xpack.securitySolution.cases.caseView.caseClosed', { - defaultMessage: 'Case closed', -}); - -export const BULK_ACTION_CLOSE_SELECTED = i18n.translate( - 'xpack.securitySolution.cases.caseTable.bulkActions.closeSelectedTitle', - { - defaultMessage: 'Close selected', - } -); - -export const BULK_ACTION_OPEN_SELECTED = i18n.translate( - 'xpack.securitySolution.cases.caseTable.bulkActions.openSelectedTitle', - { - defaultMessage: 'Open selected', - } -); - -export const BULK_ACTION_DELETE_SELECTED = i18n.translate( - 'xpack.securitySolution.cases.caseTable.bulkActions.deleteSelectedTitle', - { - defaultMessage: 'Delete selected', - } -); - -export const BULK_ACTION_MARK_IN_PROGRESS = i18n.translate( - 'xpack.securitySolution.cases.caseTable.bulkActions.markInProgressTitle', - { - defaultMessage: 'Mark in progress', - } -); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx index 40a202f5257a7..09c94b643e8d9 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx @@ -5,21 +5,28 @@ * 2.0. */ -/* eslint-disable react/display-name */ -import React, { ReactNode } from 'react'; +import React from 'react'; import { mount } from 'enzyme'; import { EuiGlobalToastList } from '@elastic/eui'; import { useKibana, useGetUserSavedObjectPermissions } from '../../../common/lib/kibana'; import { useStateToaster } from '../../../common/components/toasters'; import { TestProviders } from '../../../common/mock'; -import { usePostComment } from '../../containers/use_post_comment'; -import { Case } from '../../containers/types'; import { AddToCaseAction } from './add_to_case_action'; +import { basicCase } from '../../../../../cases/public/containers/mock'; +import { Case } from '../../../../../cases/common'; -jest.mock('../../containers/use_post_comment'); jest.mock('../../../common/lib/kibana'); - +jest.mock('../../../common/components/link_to', () => { + const original = jest.requireActual('../../../common/components/link_to'); + return { + ...original, + useFormatUrl: jest.fn().mockReturnValue({ + formatUrl: jest.fn(), + search: '', + }), + }; +}); jest.mock('../../../common/components/toasters', () => { const actual = jest.requireActual('../../../common/components/toasters'); return { @@ -28,86 +35,7 @@ jest.mock('../../../common/components/toasters', () => { }; }); -jest.mock('../all_cases', () => { - return { - AllCases: ({ onRowClick }: { onRowClick: (theCase: Partial<Case>) => void }) => { - return ( - <button - type="button" - data-test-subj="all-cases-modal-button" - onClick={() => - onRowClick({ - id: 'selected-case', - title: 'the selected case', - settings: { syncAlerts: true }, - }) - } - > - {'case-row'} - </button> - ); - }, - }; -}); - -jest.mock('../create/form_context', () => { - return { - FormContext: ({ - children, - onSuccess, - afterCaseCreated, - }: { - children: ReactNode; - onSuccess: (theCase: Partial<Case>) => Promise<void>; - afterCaseCreated: (theCase: Partial<Case>) => Promise<void>; - }) => { - return ( - <> - <button - type="button" - data-test-subj="form-context-on-success" - onClick={() => { - afterCaseCreated({ - id: 'new-case', - title: 'the new case', - settings: { syncAlerts: true }, - }); - onSuccess({ id: 'new-case', title: 'the new case', settings: { syncAlerts: true } }); - }} - > - {'submit'} - </button> - {children} - </> - ); - }, - }; -}); - -jest.mock('../create/form', () => { - return { - CreateCaseForm: () => { - return <>{'form'}</>; - }, - }; -}); - -jest.mock('../create/submit_button', () => { - return { - SubmitCaseButton: () => { - return <>{'Submit'}</>; - }, - }; -}); - -const usePostCommentMock = usePostComment as jest.Mock; -const postComment = jest.fn(); -const defaultPostComment = { - isLoading: false, - isError: false, - postComment, -}; - +const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; describe('AddToCaseAction', () => { const props = { ecsRowData: { @@ -119,21 +47,28 @@ describe('AddToCaseAction', () => { const mockDispatchToaster = jest.fn(); const mockNavigateToApp = jest.fn(); + const mockCreateCase = jest.fn(); + const mockAllCasesModal = jest.fn(); beforeEach(() => { jest.clearAllMocks(); - usePostCommentMock.mockImplementation(() => defaultPostComment); + useKibanaMock().services.application.navigateToApp = mockNavigateToApp; + useKibanaMock().services.cases = { + getAllCases: jest.fn(), + getCaseView: jest.fn(), + getConfigureCases: jest.fn(), + getRecentCases: jest.fn(), + getCreateCase: mockCreateCase, + getAllCasesSelectorModal: mockAllCasesModal.mockImplementation(() => <>{'test'}</>), + }; (useStateToaster as jest.Mock).mockReturnValue([jest.fn(), mockDispatchToaster]); - (useKibana as jest.Mock).mockReturnValue({ - services: { application: { navigateToApp: mockNavigateToApp } }, - }); (useGetUserSavedObjectPermissions as jest.Mock).mockReturnValue({ crud: true, read: true, }); }); - it('it renders', async () => { + it('it renders', () => { const wrapper = mount( <TestProviders> <AddToCaseAction {...props} /> @@ -143,7 +78,7 @@ describe('AddToCaseAction', () => { expect(wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).exists()).toBeTruthy(); }); - it('it opens the context menu', async () => { + it('it opens the context menu', () => { const wrapper = mount( <TestProviders> <AddToCaseAction {...props} /> @@ -155,20 +90,7 @@ describe('AddToCaseAction', () => { expect(wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).exists()).toBeTruthy(); }); - it('it opens the create case modal', async () => { - const wrapper = mount( - <TestProviders> - <AddToCaseAction {...props} /> - </TestProviders> - ); - - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="add-new-case-item"]`).first().simulate('click'); - - expect(wrapper.find(`[data-test-subj="form-context-on-success"]`).exists()).toBeTruthy(); - }); - - it('it attach the alert to case on case creation', async () => { + it('it opens the create case modal', () => { const wrapper = mount( <TestProviders> <AddToCaseAction {...props} /> @@ -177,22 +99,10 @@ describe('AddToCaseAction', () => { wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); wrapper.find(`[data-test-subj="add-new-case-item"]`).first().simulate('click'); - - wrapper.find(`[data-test-subj="form-context-on-success"]`).first().simulate('click'); - - expect(postComment.mock.calls[0][0].caseId).toBe('new-case'); - expect(postComment.mock.calls[0][0].data).toEqual({ - alertId: 'test-id', - index: 'test-index', - rule: { - id: 'rule-id', - name: 'rule-name', - }, - type: 'alert', - }); + expect(mockCreateCase).toHaveBeenCalled(); }); - it('it opens the all cases modal', async () => { + it('it opens the all cases modal', () => { const wrapper = mount( <TestProviders> <AddToCaseAction {...props} /> @@ -202,34 +112,14 @@ describe('AddToCaseAction', () => { wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).first().simulate('click'); - expect(wrapper.find(`[data-test-subj="all-cases-modal-button"]`).exists()).toBeTruthy(); - }); - - it('it attach the alert to case after selecting a case', async () => { - const wrapper = mount( - <TestProviders> - <AddToCaseAction {...props} /> - </TestProviders> - ); - - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).first().simulate('click'); - - wrapper.find(`[data-test-subj="all-cases-modal-button"]`).first().simulate('click'); - - expect(postComment.mock.calls[0][0].caseId).toBe('selected-case'); - expect(postComment.mock.calls[0][0].data).toEqual({ + expect(mockAllCasesModal.mock.calls[0][0].alertData).toEqual({ alertId: 'test-id', index: 'test-index', - rule: { - id: 'rule-id', - name: 'rule-name', - }, - type: 'alert', + rule: { id: 'rule-id', name: 'rule-name' }, }); }); - it('it set rule information as null when missing', async () => { + it('it set rule information as null when missing', () => { const wrapper = mount( <TestProviders> <AddToCaseAction @@ -244,23 +134,23 @@ describe('AddToCaseAction', () => { ); wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="add-new-case-item"]`).first().simulate('click'); - - wrapper.find(`[data-test-subj="form-context-on-success"]`).first().simulate('click'); - - expect(postComment.mock.calls[0][0].caseId).toBe('new-case'); - expect(postComment.mock.calls[0][0].data).toEqual({ + wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).first().simulate('click'); + expect(mockAllCasesModal.mock.calls[0][0].alertData).toEqual({ alertId: 'test-id', index: 'test-index', - rule: { - id: 'rule-id', - name: null, - }, - type: 'alert', + rule: { id: 'rule-id', name: null }, }); }); - it('navigates to case view when attach to a new case', async () => { + it('onSuccess triggers toaster that links to case view', () => { + // @ts-ignore + useKibanaMock().services.cases.getCreateCase = ({ + onSuccess, + }: { + onSuccess: (theCase: Case) => Promise<void>; + }) => { + onSuccess(basicCase); + }; const wrapper = mount( <TestProviders> <AddToCaseAction {...props} /> @@ -269,46 +159,6 @@ describe('AddToCaseAction', () => { wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); wrapper.find(`[data-test-subj="add-new-case-item"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="form-context-on-success"]`).first().simulate('click'); - - expect(mockDispatchToaster).toHaveBeenCalled(); - const toast = mockDispatchToaster.mock.calls[0][0].toast; - - const toastWrapper = mount( - <EuiGlobalToastList toasts={[toast]} toastLifeTimeMs={6000} dismissToast={() => {}} /> - ); - - toastWrapper - .find('[data-test-subj="toaster-content-case-view-link"]') - .first() - .simulate('click'); - - expect(mockNavigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/new-case' }); - }); - - it('navigates to case view when attach to an existing case', async () => { - usePostCommentMock.mockImplementation(() => { - return { - ...defaultPostComment, - postComment: jest.fn().mockImplementation(({ caseId, data, updateCase }) => { - updateCase({ - id: 'selected-case', - title: 'the selected case', - settings: { syncAlerts: true }, - }); - }), - }; - }); - - const wrapper = mount( - <TestProviders> - <AddToCaseAction {...props} /> - </TestProviders> - ); - - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="all-cases-modal-button"]`).first().simulate('click'); expect(mockDispatchToaster).toHaveBeenCalled(); const toast = mockDispatchToaster.mock.calls[0][0].toast; @@ -323,11 +173,11 @@ describe('AddToCaseAction', () => { .simulate('click'); expect(mockNavigateToApp).toHaveBeenCalledWith('securitySolution:case', { - path: '/selected-case', + path: '/basic-case-id', }); }); - it('disabled when event type is not supported', async () => { + it('disabled when event type is not supported', () => { const wrapper = mount( <TestProviders> <AddToCaseAction @@ -345,7 +195,7 @@ describe('AddToCaseAction', () => { ).toBeTruthy(); }); - it('disabled when user does not have crud permissions', async () => { + it('disabled when user does not have crud permissions', () => { (useGetUserSavedObjectPermissions as jest.Mock).mockReturnValue({ crud: false, read: true, diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index 45c1355cecfa7..1682b4b7e7dee 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -16,27 +16,40 @@ import { EuiToolTip, } from '@elastic/eui'; -import { CommentType, CaseStatuses } from '../../../../../cases/common/api'; +import { Case, CaseStatuses } from '../../../../../cases/common'; +import { APP_ID } from '../../../../common/constants'; import { Ecs } from '../../../../common/ecs'; -import { ActionIconItem } from '../../../timelines/components/timeline/body/actions/action_icon_item'; -import { usePostComment } from '../../containers/use_post_comment'; -import { Case } from '../../containers/types'; +import { SecurityPageName } from '../../../app/types'; +import { + getCaseDetailsUrl, + getCreateCaseUrl, + useFormatUrl, +} from '../../../common/components/link_to'; import { useStateToaster } from '../../../common/components/toasters'; -import { APP_ID } from '../../../../common/constants'; +import { useControl } from '../../../common/hooks/use_control'; import { useGetUserSavedObjectPermissions, useKibana } from '../../../common/lib/kibana'; -import { getCaseDetailsUrl } from '../../../common/components/link_to'; -import { SecurityPageName } from '../../../app/types'; -import { useAllCasesModal } from '../use_all_cases_modal'; +import { ActionIconItem } from '../../../timelines/components/timeline/body/actions/action_icon_item'; +import { CreateCaseFlyout } from '../create/flyout'; import { createUpdateSuccessToaster } from './helpers'; import * as i18n from './translations'; -import { useControl } from '../../../common/hooks/use_control'; -import { CreateCaseFlyout } from '../create/flyout'; interface AddToCaseActionProps { ariaLabel?: string; ecsRowData: Ecs; } +interface PostCommentArg { + caseId: string; + data: { + type: 'alert'; + alertId: string | string[]; + index: string | string[]; + rule: { id: string | null; name: string | null }; + }; + updateCase?: (newCase: Case) => void; + subCaseId?: string; +} + const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({ ariaLabel = i18n.ACTION_ADD_TO_CASE_ARIA_LABEL, ecsRowData, @@ -45,7 +58,10 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({ const eventIndex = ecsRowData._index; const rule = ecsRowData.signal?.rule; - const { navigateToApp } = useKibana().services.application; + const { + application: { navigateToApp }, + cases, + } = useKibana().services; const [, dispatchToaster] = useStateToaster(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const openPopover = useCallback(() => setIsPopoverOpen(true), []); @@ -61,8 +77,6 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({ : i18n.UNSUPPORTED_EVENTS_MSG : i18n.PERMISSIONS_MSG; - const { postComment } = usePostComment(); - const onViewCaseClick = useCallback( (id) => { navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { @@ -79,33 +93,52 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({ } = useControl(); const attachAlertToCase = useCallback( - async (theCase: Case, updateCase?: (newCase: Case) => void) => { + async ( + theCase: Case, + postComment?: (arg: PostCommentArg) => Promise<void>, + updateCase?: (newCase: Case) => void + ) => { closeCaseFlyoutOpen(); - await postComment({ - caseId: theCase.id, - data: { - type: CommentType.alert, - alertId: eventId, - index: eventIndex ?? '', - rule: { - id: rule?.id != null ? rule.id[0] : null, - name: rule?.name != null ? rule.name[0] : null, + if (postComment) { + await postComment({ + caseId: theCase.id, + data: { + type: 'alert', + alertId: eventId, + index: eventIndex ?? '', + rule: { + id: rule?.id != null ? rule.id[0] : null, + name: rule?.name != null ? rule.name[0] : null, + }, }, - }, - updateCase, - }); + updateCase, + }); + } }, - [closeCaseFlyoutOpen, postComment, eventId, eventIndex, rule] + [closeCaseFlyoutOpen, eventId, eventIndex, rule] ); - const onCaseSuccess = useCallback( - async (theCase: Case) => - dispatchToaster({ + async (theCase: Case) => { + closeCaseFlyoutOpen(); + return dispatchToaster({ type: 'addToaster', toast: createUpdateSuccessToaster(theCase, onViewCaseClick), - }), - [dispatchToaster, onViewCaseClick] + }); + }, + [closeCaseFlyoutOpen, dispatchToaster, onViewCaseClick] + ); + + const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case); + const goToCreateCase = useCallback( + (ev) => { + ev.preventDefault(); + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: getCreateCaseUrl(urlSearch), + }); + }, + [navigateToApp, urlSearch] ); + const [isAllCaseModalOpen, openAllCaseModal] = useState(false); const onCaseClicked = useCallback( (theCase) => { @@ -116,19 +149,11 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({ */ if (theCase == null) { openCaseFlyoutOpen(); - return; } - - attachAlertToCase(theCase, onCaseSuccess); + openAllCaseModal(false); }, - [attachAlertToCase, onCaseSuccess, openCaseFlyoutOpen] + [openCaseFlyoutOpen] ); - - const { modal: allCasesModal, openModal: openAllCaseModal } = useAllCasesModal({ - disabledStatuses: [CaseStatuses.closed], - onRowClick: onCaseClicked, - }); - const addNewCaseClick = useCallback(() => { closePopover(); openCaseFlyoutOpen(); @@ -136,7 +161,7 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({ const addExistingCaseClick = useCallback(() => { closePopover(); - openAllCaseModal(); + openAllCaseModal(true); }, [openAllCaseModal, closePopover]); const items = useMemo( @@ -196,12 +221,30 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({ </ActionIconItem> {isCreateCaseFlyoutOpen && ( <CreateCaseFlyout - onCloseFlyout={closeCaseFlyoutOpen} afterCaseCreated={attachAlertToCase} + onCloseFlyout={closeCaseFlyoutOpen} onSuccess={onCaseSuccess} /> )} - {allCasesModal} + {isAllCaseModalOpen && + cases.getAllCasesSelectorModal({ + alertData: { + alertId: eventId, + index: eventIndex ?? '', + rule: { + id: rule?.id != null ? rule.id[0] : null, + name: rule?.name != null ? rule.name[0] : null, + }, + }, + createCaseNavigation: { + href: formatUrl(getCreateCaseUrl()), + onClick: goToCreateCase, + }, + disabledStatuses: [CaseStatuses.closed], + onRowClick: onCaseClicked, + updateCase: onCaseSuccess, + userCanCrud: userPermissions?.crud ?? false, + })} </> ); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx index 9e358323ee073..9722447b96ad5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx @@ -6,7 +6,7 @@ */ import { createUpdateSuccessToaster } from './helpers'; -import { Case } from '../../containers/types'; +import { Case } from '../../../../../cases/common'; const theCase = { id: 'case-id', diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx index 175d7f896648b..8682b6680830d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx @@ -8,9 +8,9 @@ import React from 'react'; import uuid from 'uuid'; import { AppToast } from '../../../common/components/toasters'; -import { Case } from '../../containers/types'; import { ToasterContent } from './toaster_content'; import * as i18n from './translations'; +import { Case } from '../../../../../cases/common'; export const createUpdateSuccessToaster = ( theCase: Case, diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.test.tsx deleted file mode 100644 index 57d4b585d573f..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* eslint-disable react/display-name */ -import { mount } from 'enzyme'; -import React from 'react'; -import '../../../common/mock/match_media'; -import { AllCasesModal } from './all_cases_modal'; -import { TestProviders } from '../../../common/mock'; - -jest.mock('../all_cases', () => { - return { - AllCases: ({ onRowClick }: { onRowClick: ({ id }: { id: string }) => void }) => { - return ( - <button - type="button" - data-test-subj="all-cases-row" - onClick={() => onRowClick({ id: 'case-id' })} - > - {'case-row'} - </button> - ); - }, - }; -}); - -jest.mock('../../../common/lib/kibana', () => { - const originalModule = jest.requireActual('../../../common/lib/kibana'); - return { - ...originalModule, - useGetUserSavedObjectPermissions: jest.fn(), - }; -}); - -const onCloseCaseModal = jest.fn(); -const onRowClick = jest.fn(); -const defaultProps = { - isModalOpen: true, - onCloseCaseModal, - onRowClick, -}; - -describe('AllCasesModal', () => { - beforeEach(() => { - jest.resetAllMocks(); - }); - - it('renders', () => { - const wrapper = mount( - <TestProviders> - <AllCasesModal {...defaultProps} /> - </TestProviders> - ); - - expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeTruthy(); - }); - - it('it does not render the modal isModalOpen=false ', () => { - const wrapper = mount( - <TestProviders> - <AllCasesModal {...defaultProps} isModalOpen={false} /> - </TestProviders> - ); - - expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeFalsy(); - }); - - it('Closing modal calls onCloseCaseModal', () => { - const wrapper = mount( - <TestProviders> - <AllCasesModal {...defaultProps} /> - </TestProviders> - ); - - wrapper.find('.euiModal__closeIcon').first().simulate('click'); - expect(onCloseCaseModal).toBeCalled(); - }); - - it('pass the correct props to AllCases component', () => { - const wrapper = mount( - <TestProviders> - <AllCasesModal {...defaultProps} /> - </TestProviders> - ); - - const props = wrapper.find('AllCases').props(); - expect(props).toEqual({ - userCanCrud: false, - onRowClick, - isModal: true, - }); - }); - - it('onRowClick called when row is clicked', () => { - const wrapper = mount( - <TestProviders> - <AllCasesModal {...defaultProps} /> - </TestProviders> - ); - - wrapper.find(`[data-test-subj='all-cases-row']`).first().simulate('click'); - expect(onRowClick).toHaveBeenCalledWith({ id: 'case-id' }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx deleted file mode 100644 index 10ad3d35004ba..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo } from 'react'; -import styled from 'styled-components'; -import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; - -import { useGetUserSavedObjectPermissions } from '../../../common/lib/kibana'; -import { CaseStatuses } from '../../../../../cases/common/api'; -import { Case, SubCase } from '../../containers/types'; -import { AllCases } from '../all_cases'; -import * as i18n from './translations'; - -export interface AllCasesModalProps { - isModalOpen: boolean; - onCloseCaseModal: () => void; - onRowClick: (theCase?: Case | SubCase) => void; - disabledStatuses?: CaseStatuses[]; -} - -const Modal = styled(EuiModal)` - ${({ theme }) => ` - width: ${theme.eui.euiBreakpoints.l}; - max-width: ${theme.eui.euiBreakpoints.l}; - `} -`; - -const AllCasesModalComponent: React.FC<AllCasesModalProps> = ({ - isModalOpen, - onCloseCaseModal, - onRowClick, - disabledStatuses, -}) => { - const userPermissions = useGetUserSavedObjectPermissions(); - const userCanCrud = userPermissions?.crud ?? false; - - return isModalOpen ? ( - <Modal onClose={onCloseCaseModal} data-test-subj="all-cases-modal"> - <EuiModalHeader> - <EuiModalHeaderTitle>{i18n.SELECT_CASE_TITLE}</EuiModalHeaderTitle> - </EuiModalHeader> - <EuiModalBody> - <AllCases - onRowClick={onRowClick} - userCanCrud={userCanCrud} - isModal - disabledStatuses={disabledStatuses} - /> - </EuiModalBody> - </Modal> - ) : null; -}; - -export const AllCasesModal = memo(AllCasesModalComponent); - -AllCasesModal.displayName = 'AllCasesModal'; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx deleted file mode 100644 index 57bb39a1ab50f..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* eslint-disable react/display-name */ -import React from 'react'; -import { renderHook, act } from '@testing-library/react-hooks'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { useKibana } from '../../../common/lib/kibana'; -import '../../../common/mock/match_media'; -import { useAllCasesModal, UseAllCasesModalProps, UseAllCasesModalReturnedValues } from '.'; -import { mockTimelineModel, TestProviders } from '../../../common/mock'; -import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; - -const mockDispatch = jest.fn(); -jest.mock('react-redux', () => { - const original = jest.requireActual('react-redux'); - return { - ...original, - useDispatch: () => mockDispatch, - }; -}); - -jest.mock('../../../common/lib/kibana'); -jest.mock('../all_cases', () => { - return { - AllCases: ({ onRowClick }: { onRowClick: ({ id }: { id: string }) => void }) => { - return ( - <button type="button" onClick={() => onRowClick({ id: 'case-id' })}> - {'case-row'} - </button> - ); - }, - }; -}); - -jest.mock('../../../common/hooks/use_selector'); - -const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; -const onRowClick = jest.fn(); - -describe('useAllCasesModal', () => { - let navigateToApp: jest.Mock; - - beforeEach(() => { - navigateToApp = jest.fn(); - useKibanaMock().services.application.navigateToApp = navigateToApp; - (useDeepEqualSelector as jest.Mock).mockReturnValue(mockTimelineModel); - }); - - it('init', async () => { - const { result } = renderHook<UseAllCasesModalProps, UseAllCasesModalReturnedValues>( - () => useAllCasesModal({ onRowClick }), - { - wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, - } - ); - - expect(result.current.isModalOpen).toBe(false); - }); - - it('opens the modal', async () => { - const { result } = renderHook<UseAllCasesModalProps, UseAllCasesModalReturnedValues>( - () => useAllCasesModal({ onRowClick }), - { - wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, - } - ); - - act(() => { - result.current.openModal(); - }); - - expect(result.current.isModalOpen).toBe(true); - }); - - it('closes the modal', async () => { - const { result } = renderHook<UseAllCasesModalProps, UseAllCasesModalReturnedValues>( - () => useAllCasesModal({ onRowClick }), - { - wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, - } - ); - - act(() => { - result.current.openModal(); - result.current.closeModal(); - }); - - expect(result.current.isModalOpen).toBe(false); - }); - - it('returns a memoized value', async () => { - const { result, rerender } = renderHook<UseAllCasesModalProps, UseAllCasesModalReturnedValues>( - () => useAllCasesModal({ onRowClick }), - { - wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, - } - ); - - const result1 = result.current; - act(() => rerender()); - const result2 = result.current; - - expect(Object.is(result1, result2)).toBe(true); - }); - - it('closes the modal when clicking a row', async () => { - const { result } = renderHook<UseAllCasesModalProps, UseAllCasesModalReturnedValues>( - () => useAllCasesModal({ onRowClick }), - { - wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, - } - ); - - act(() => { - result.current.openModal(); - }); - - const modal = result.current.modal; - render(<TestProviders>{modal}</TestProviders>); - - act(() => { - userEvent.click(screen.getByText('case-row')); - }); - - expect(result.current.isModalOpen).toBe(false); - expect(onRowClick).toHaveBeenCalledWith({ id: 'case-id' }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx deleted file mode 100644 index 0b30f6ac94e03..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState, useCallback, useMemo } from 'react'; -import { CaseStatuses } from '../../../../../cases/common/api'; -import { Case, SubCase } from '../../containers/types'; -import { AllCasesModal } from './all_cases_modal'; - -export interface UseAllCasesModalProps { - onRowClick: (theCase?: Case | SubCase) => void; - disabledStatuses?: CaseStatuses[]; -} - -export interface UseAllCasesModalReturnedValues { - modal: JSX.Element; - isModalOpen: boolean; - closeModal: () => void; - openModal: () => void; -} - -export const useAllCasesModal = ({ - onRowClick, - disabledStatuses, -}: UseAllCasesModalProps): UseAllCasesModalReturnedValues => { - const [isModalOpen, setIsModalOpen] = useState<boolean>(false); - const closeModal = useCallback(() => setIsModalOpen(false), []); - const openModal = useCallback(() => setIsModalOpen(true), []); - const onClick = useCallback( - (theCase?: Case | SubCase) => { - closeModal(); - onRowClick(theCase); - }, - [closeModal, onRowClick] - ); - - const state = useMemo( - () => ({ - modal: ( - <AllCasesModal - isModalOpen={isModalOpen} - onCloseCaseModal={closeModal} - onRowClick={onClick} - disabledStatuses={disabledStatuses} - /> - ), - isModalOpen, - closeModal, - openModal, - onRowClick, - }), - [isModalOpen, closeModal, onClick, disabledStatuses, openModal, onRowClick] - ); - - return state; -}; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx deleted file mode 100644 index 0b3915c3d38d4..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { mount } from 'enzyme'; -import { Router, mockHistory } from '../__mock__/router'; -import { UserActionMarkdown } from './user_action_markdown'; -import { TestProviders } from '../../../common/mock'; -import * as timelineHelpers from '../../../timelines/components/open_timeline/helpers'; -const onChangeEditable = jest.fn(); -const onSaveContent = jest.fn(); - -const timelineId = '1e10f150-949b-11ea-b63c-2bc51864784c'; -const timelineMarkdown = `[timeline](http://localhost:5601/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t))`; -const defaultProps = { - content: `A link to a timeline ${timelineMarkdown}`, - id: 'markdown-id', - isEditable: false, - onChangeEditable, - onSaveContent, -}; - -describe('UserActionMarkdown ', () => { - const queryTimelineByIdSpy = jest.spyOn(timelineHelpers, 'queryTimelineById'); - beforeEach(() => { - jest.resetAllMocks(); - }); - - it('Opens timeline when timeline link clicked - isEditable: false', async () => { - const wrapper = mount( - <TestProviders> - <Router history={mockHistory}> - <UserActionMarkdown {...defaultProps} /> - </Router> - </TestProviders> - ); - - wrapper - .find(`[data-test-subj="markdown-timeline-link-${timelineId}"]`) - .first() - .simulate('click'); - - expect(queryTimelineByIdSpy).toBeCalledWith({ - graphEventId: '', - timelineId, - updateIsLoading: expect.any(Function), - updateTimeline: expect.any(Function), - }); - }); - - it('Opens timeline when timeline link clicked - isEditable: true ', async () => { - const wrapper = mount( - <TestProviders> - <Router history={mockHistory}> - <UserActionMarkdown {...{ ...defaultProps, isEditable: true }} /> - </Router> - </TestProviders> - ); - - // Preview button of Markdown editor - wrapper - .find( - `[data-test-subj="user-action-markdown-form"] .euiMarkdownEditorToolbar .euiButtonEmpty` - ) - .first() - .simulate('click'); - - wrapper - .find(`[data-test-subj="markdown-timeline-link-${timelineId}"]`) - .first() - .simulate('click'); - expect(queryTimelineByIdSpy).toBeCalledWith({ - graphEventId: '', - timelineId, - updateIsLoading: expect.any(Function), - updateTimeline: expect.any(Function), - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/cases/components/wrappers/index.tsx b/x-pack/plugins/security_solution/public/cases/components/wrappers/index.tsx index 3b33e9304da83..477fb77d98ee8 100644 --- a/x-pack/plugins/security_solution/public/cases/components/wrappers/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/wrappers/index.tsx @@ -19,8 +19,3 @@ export const SectionWrapper = styled.div` max-width: 1175px; width: 100%; `; - -export const HeaderWrapper = styled.div` - padding: ${({ theme }) => - `${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} 0 ${theme.eui.paddingSizes.l}`}; -`; diff --git a/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx b/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx index 60cdb37628ba3..3e838f47e6dc2 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx @@ -13,15 +13,15 @@ import { SecurityPageName } from '../../app/types'; import { getCaseUrl } from '../../common/components/link_to'; import { useGetUrlSearch } from '../../common/components/navigation/use_get_url_search'; import { WrapperPage } from '../../common/components/wrapper_page'; -import { useGetUserSavedObjectPermissions } from '../../common/lib/kibana'; +import { useGetUserSavedObjectPermissions, useKibana } from '../../common/lib/kibana'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { navTabs } from '../../app/home/home_navigations'; import { CaseHeaderPage } from '../components/case_header_page'; -import { ConfigureCases } from '../components/configure_cases'; import { WhitePageWrapper, SectionWrapper } from '../components/wrappers'; import * as i18n from './translations'; const ConfigureCasesPageComponent: React.FC = () => { + const { cases } = useKibana().services; const history = useHistory(); const userPermissions = useGetUserSavedObjectPermissions(); const search = useGetUrlSearch(navTabs.case); @@ -53,7 +53,9 @@ const ConfigureCasesPageComponent: React.FC = () => { </HeaderWrapper> </SectionWrapper> <WhitePageWrapper> - <ConfigureCases userCanCrud={userPermissions?.crud ?? false} /> + {cases.getConfigureCases({ + userCanCrud: userPermissions?.crud ?? false, + })} </WhitePageWrapper> </WrapperPage> <SpyRoute pageName={SecurityPageName.case} /> diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts index c7a125daa54f8..92a3cb2cfac93 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts @@ -15,6 +15,7 @@ import { Entry, EntryMatch, EntryMatchAny, + EntryMatchWildcard, EntryExists, ExceptionListItemSchema, CreateExceptionListItemSchema, @@ -92,6 +93,7 @@ export interface EmptyNestedEntry { type: OperatorTypeEnum.NESTED; entries: Array< | (EntryMatch & { id?: string }) + | (EntryMatchWildcard & { id?: string }) | (EntryMatchAny & { id?: string }) | (EntryExists & { id?: string }) >; @@ -108,6 +110,7 @@ export type BuilderEntryNested = Omit<EntryNested, 'entries'> & { id?: string; entries: Array< | (EntryMatch & { id?: string }) + | (EntryMatchWildcard & { id?: string }) | (EntryMatchAny & { id?: string }) | (EntryExists & { id?: string }) >; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/index.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/index.ts index bc0da84133e68..c744ace91f434 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/index.ts @@ -6,18 +6,36 @@ */ import { - EuiMarkdownEditorUiPlugin, + EuiLinkAnchorProps, getDefaultEuiMarkdownParsingPlugins, getDefaultEuiMarkdownProcessingPlugins, getDefaultEuiMarkdownUiPlugins, } from '@elastic/eui'; - +// Remove after this issue is resolved: https://github.com/elastic/eui/issues/4688 +// eslint-disable-next-line import/no-extraneous-dependencies +import { Options as Remark2RehypeOptions } from 'mdast-util-to-hast'; +import { FunctionComponent } from 'react'; +// eslint-disable-next-line import/no-extraneous-dependencies +import rehype2react from 'rehype-react'; +import { Plugin, PluggableList } from 'unified'; import * as timelineMarkdownPlugin from './timeline'; -const uiPlugins: EuiMarkdownEditorUiPlugin[] = getDefaultEuiMarkdownUiPlugins(); + +export const { uiPlugins, parsingPlugins, processingPlugins } = { + uiPlugins: getDefaultEuiMarkdownUiPlugins(), + parsingPlugins: getDefaultEuiMarkdownParsingPlugins(), + processingPlugins: getDefaultEuiMarkdownProcessingPlugins() as [ + [Plugin, Remark2RehypeOptions], + [ + typeof rehype2react, + Parameters<typeof rehype2react>[0] & { + components: { a: FunctionComponent<EuiLinkAnchorProps>; timeline: unknown }; + } + ], + ...PluggableList + ], +}; + uiPlugins.push(timelineMarkdownPlugin.plugin); -export { uiPlugins }; -export const parsingPlugins = getDefaultEuiMarkdownParsingPlugins(); -export const processingPlugins = getDefaultEuiMarkdownProcessingPlugins(); parsingPlugins.push(timelineMarkdownPlugin.parser); diff --git a/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.tsx b/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.tsx index e3f78cee0faae..ab8e7cf97d34c 100644 --- a/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.tsx @@ -6,7 +6,7 @@ */ import { useCallback } from 'react'; -import { useKibana } from '../../lib/kibana'; +import { useKibana } from '../../../common/lib/kibana'; export interface UseMessagesStorage { getMessages: (plugin: string) => string[]; diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts index df7fad5443062..6b5599292f6d4 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts @@ -10,10 +10,11 @@ import moment from 'moment-timezone'; import { useCallback, useEffect, useState, useRef } from 'react'; import { i18n } from '@kbn/i18n'; +import { camelCase, isArray, isObject } from 'lodash'; +import { set } from '@elastic/safer-lodash-set'; import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../common/constants'; import { errorToToaster, useStateToaster } from '../../components/toasters'; import { AuthenticatedUser } from '../../../../../security/common/model'; -import { convertToCamelCase } from '../../../cases/containers/utils'; import { StartServices } from '../../../types'; import { useUiSetting, useKibana } from './kibana_react'; @@ -50,6 +51,27 @@ export interface AuthenticatedElasticUser { authenticationProvider: string; } +export const convertArrayToCamelCase = (arrayOfSnakes: unknown[]): unknown[] => + arrayOfSnakes.reduce((acc: unknown[], value) => { + if (isArray(value)) { + return [...acc, convertArrayToCamelCase(value)]; + } else if (isObject(value)) { + return [...acc, convertToCamelCase(value)]; + } else { + return [...acc, value]; + } + }, []); +export const convertToCamelCase = <T, U extends {}>(snakeCase: T): U => + Object.entries(snakeCase).reduce((acc, [key, value]) => { + if (isArray(value)) { + set(acc, camelCase(key), convertArrayToCamelCase(value)); + } else if (isObject(value)) { + set(acc, camelCase(key), convertToCamelCase(value)); + } else { + set(acc, camelCase(key), value); + } + return acc; + }, {} as U); export const useCurrentUser = (): AuthenticatedElasticUser | null => { const isMounted = useRef(false); const [user, setUser] = useState<AuthenticatedElasticUser | null>(null); diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index e504344f3d25f..1527ea7dccac5 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -92,6 +92,13 @@ export const createStartServicesMock = (): StartServices => { return ({ ...core, + cases: { + getAllCases: jest.fn(), + getCaseView: jest.fn(), + getConfigureCases: jest.fn(), + getCreateCase: jest.fn(), + getRecentCases: jest.fn(), + }, data: { ...data, query: { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx index 7ef698ae05b36..1e8525f0519ed 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx @@ -6,14 +6,38 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import { QueryBarDefineRule } from './index'; -import { useFormFieldMock } from '../../../../common/mock'; +import { + TestProviders, + useFormFieldMock, + mockOpenTimelineQueryResults, +} from '../../../../common/mock'; +import { mockHistory, Router } from '../../../../cases/components/__mock__/router'; +import { useGetAllTimeline, getAllTimeline } from '../../../../timelines/containers/all'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../timelines/containers/all', () => { + const originalModule = jest.requireActual('../../../../timelines/containers/all'); + return { + ...originalModule, + useGetAllTimeline: jest.fn(), + }; +}); + describe('QueryBarDefineRule', () => { + beforeEach(() => { + ((useGetAllTimeline as unknown) as jest.Mock).mockReturnValue({ + fetchAllTimeline: jest.fn(), + timelines: getAllTimeline('', mockOpenTimelineQueryResults.timeline ?? []), + loading: false, + totalCount: mockOpenTimelineQueryResults.totalCount, + refetch: jest.fn(), + }); + }); + it('renders correctly', () => { const Component = () => { const field = useFormFieldMock(); @@ -32,7 +56,35 @@ describe('QueryBarDefineRule', () => { ); }; const wrapper = shallow(<Component />); - expect(wrapper.dive().find('[data-test-subj="query-bar-define-rule"]')).toHaveLength(1); }); + + it('renders import query from saved timeline modal actions hidden correctly', () => { + const Component = () => { + const field = useFormFieldMock(); + + return ( + <QueryBarDefineRule + browserFields={{}} + isLoading={false} + indexPattern={{ fields: [], title: 'title' }} + onCloseTimelineSearch={jest.fn()} + openTimelineSearch={true} + dataTestSubj="query-bar-define-rule" + idAria="idAria" + field={field} + /> + ); + }; + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <Component /> + </Router> + </TestProviders> + ); + + expect(wrapper.find('[data-test-subj="open-duplicate"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="create-from-template"]').exists()).toBeFalsy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx index f45ff5f1ea1a1..6bda4a0e0f6b8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx @@ -6,7 +6,7 @@ */ import { EuiFormRow, EuiMutationObserver } from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Subscription } from 'rxjs'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; @@ -50,6 +50,8 @@ interface QueryBarDefineRuleProps { onValidityChange?: (arg: boolean) => void; } +const actionTimelineToHide: ActionTimelineToShow[] = ['duplicate', 'createFrom']; + const StyledEuiFormRow = styled(EuiFormRow)` .kbnTypeahead__items { max-height: 45vh !important; @@ -253,8 +255,6 @@ export const QueryBarDefineRule = ({ } }; - const actionTimelineToHide = useMemo<ActionTimelineToShow[]>(() => ['duplicate'], []); - return ( <> <StyledEuiFormRow diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/use_manage_case_action.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/use_manage_case_action.tsx index 875bc5e647077..c19e5c26bdc94 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/use_manage_case_action.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/use_manage_case_action.tsx @@ -6,7 +6,7 @@ */ import { useEffect, useRef, useState } from 'react'; -import { ACTION_URL } from '../../../../../../cases/common/constants'; +import { ACTION_URL } from '../../../../../../cases/common'; import { KibanaServices } from '../../../../common/lib/kibana'; interface CaseAction { diff --git a/x-pack/plugins/security_solution/public/index.ts b/x-pack/plugins/security_solution/public/index.ts index f1d1bc3e6280b..55262fe039b4e 100644 --- a/x-pack/plugins/security_solution/public/index.ts +++ b/x-pack/plugins/security_solution/public/index.ts @@ -7,8 +7,8 @@ import { PluginInitializerContext } from '../../../../src/core/public'; import { Plugin } from './plugin'; -import { PluginSetup, PluginStart } from './types'; +import { PluginSetup } from './types'; export const plugin = (context: PluginInitializerContext): Plugin => new Plugin(context); -export { Plugin, PluginSetup, PluginStart }; +export { Plugin, PluginSetup }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.test.tsx index 4e9ec3a0883a2..9d6c35d64b2d5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.test.tsx @@ -21,7 +21,7 @@ let onRemoveMock: jest.Mock; let onChangeMock: jest.Mock; let onVisitedMock: jest.Mock; -const entry: Readonly<ConditionEntry> = { +const baseEntry: Readonly<ConditionEntry> = { field: ConditionEntryField.HASH, type: 'match', operator: 'included', @@ -38,7 +38,8 @@ describe('Condition entry input', () => { const getElement = ( subject: string, os: OperatingSystem = OperatingSystem.WINDOWS, - isRemoveDisabled: boolean = false + isRemoveDisabled: boolean = false, + entry: ConditionEntry = baseEntry ) => ( <ConditionEntryInput os={os} @@ -64,10 +65,10 @@ describe('Condition entry input', () => { expect(onChangeMock).toHaveBeenCalledTimes(1); expect(onChangeMock).toHaveBeenCalledWith( { - ...entry, + ...baseEntry, field: { target: { value: field } }, }, - entry + baseEntry ); } ); @@ -77,7 +78,7 @@ describe('Condition entry input', () => { expect(onRemoveMock).toHaveBeenCalledTimes(0); element.find('[data-test-subj="testOnRemove-remove"]').first().simulate('click'); expect(onRemoveMock).toHaveBeenCalledTimes(1); - expect(onRemoveMock).toHaveBeenCalledWith(entry); + expect(onRemoveMock).toHaveBeenCalledWith(baseEntry); }); it('should not be able to call on remove for field input because disabled', () => { @@ -92,7 +93,7 @@ describe('Condition entry input', () => { expect(onVisitedMock).toHaveBeenCalledTimes(0); element.find('[data-test-subj="testOnVisited-value"]').first().simulate('blur'); expect(onVisitedMock).toHaveBeenCalledTimes(1); - expect(onVisitedMock).toHaveBeenCalledWith(entry); + expect(onVisitedMock).toHaveBeenCalledWith(baseEntry); }); it('should change value for field input', () => { @@ -105,10 +106,10 @@ describe('Condition entry input', () => { expect(onChangeMock).toHaveBeenCalledTimes(1); expect(onChangeMock).toHaveBeenCalledWith( { - ...entry, + ...baseEntry, value: 'new value', }, - entry + baseEntry ); }); @@ -138,4 +139,24 @@ describe('Condition entry input', () => { .props() as EuiSuperSelectProps<string>; expect(superSelectProps.options.length).toBe(2); }); + + it('should have operator value selected when field is HASH', () => { + const element = shallow(getElement('testOperatorOptions')); + const inputField = element.find('[data-test-subj="testOperatorOptions-operator"]'); + expect(inputField.contains('is')); + }); + + it('should show operator dorpdown with two values when field is PATH', () => { + const element = shallow( + getElement('testOperatorOptions', undefined, undefined, { + ...baseEntry, + field: ConditionEntryField.PATH, + }) + ); + const superSelectProps = element + .find('[data-test-subj="testOperatorOptions-operator"]') + .first() + .props() as EuiSuperSelectProps<string>; + expect(superSelectProps.options.length).toBe(2); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.tsx index 633adde4fdfbb..d052138d309ac 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.tsx @@ -6,12 +6,11 @@ */ import React, { ChangeEventHandler, memo, useCallback, useMemo } from 'react'; +import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { EuiButtonIcon, EuiFieldText, - EuiFlexGroup, - EuiFlexItem, EuiFormRow, EuiSuperSelect, EuiSuperSelectOption, @@ -21,6 +20,7 @@ import { import { ConditionEntry, ConditionEntryField, + OperatorFieldIds, OperatingSystem, } from '../../../../../../../common/endpoint/types'; @@ -28,9 +28,10 @@ import { CONDITION_FIELD_DESCRIPTION, CONDITION_FIELD_TITLE, ENTRY_PROPERTY_TITLES, - OPERATOR_TITLE, + OPERATOR_TITLES, } from '../../translations'; import { useTestIdGenerator } from '../../../../../components/hooks/use_test_id_generator'; +import { getPlaceholderTextByOSType } from '../../../../../../../common/utils/path_placeholder'; const ConditionEntryCell = memo<{ showLabel: boolean; @@ -66,6 +67,27 @@ export interface ConditionEntryInputProps { 'data-test-subj'?: string; } +// adding a style prop on EuiFlexGroup works only partially +// and for some odd reason garbles up gridTemplateAreas entry +const InputGroup = styled.div` + display: grid; + grid-template-columns: 25% 25% 45% 5%; + grid-template-areas: 'field operator value remove'; +`; + +const InputItem = styled.div<{ gridArea: string }>` + grid-area: ${({ gridArea }) => gridArea}; + align-self: center; + margin: 4px; + vertical-align: baseline; +`; + +const operatorOptions = (Object.keys(OperatorFieldIds) as OperatorFieldIds[]).map((value) => ({ + dropdownDisplay: OPERATOR_TITLES[value], + inputDisplay: OPERATOR_TITLES[value], + value: value === 'matches' ? 'wildcard' : 'match', +})); + export const ConditionEntryInput = memo<ConditionEntryInputProps>( ({ os, @@ -122,6 +144,11 @@ export const ConditionEntryInput = memo<ConditionEntryInputProps>( [entry, onChange] ); + const handleOperatorUpdate = useCallback( + (newOperator) => onChange({ ...entry, type: newOperator }, entry), + [entry, onChange] + ); + const handleRemoveClick = useCallback(() => onRemove(entry), [entry, onRemove]); const handleValueOnBlur = useCallback(() => { @@ -131,14 +158,8 @@ export const ConditionEntryInput = memo<ConditionEntryInputProps>( }, [entry, onVisited]); return ( - <EuiFlexGroup - gutterSize="s" - alignItems="center" - direction="row" - data-test-subj={dataTestSubj} - responsive={false} - > - <EuiFlexItem grow={2}> + <InputGroup data-test-subj={dataTestSubj}> + <InputItem gridArea="field"> <ConditionEntryCell showLabel={showLabels} label={ENTRY_PROPERTY_TITLES.field}> <EuiSuperSelect options={fieldOptions} @@ -147,17 +168,36 @@ export const ConditionEntryInput = memo<ConditionEntryInputProps>( data-test-subj={getTestId('field')} /> </ConditionEntryCell> - </EuiFlexItem> - <EuiFlexItem> + </InputItem> + <InputItem gridArea="operator"> <ConditionEntryCell showLabel={showLabels} label={ENTRY_PROPERTY_TITLES.operator}> - <EuiFieldText name="operator" value={OPERATOR_TITLE.included} readOnly /> + {entry.field === ConditionEntryField.PATH ? ( + <EuiSuperSelect + options={operatorOptions} + onChange={handleOperatorUpdate} + valueOfSelected={entry.type} + data-test-subj={getTestId('operator')} + /> + ) : ( + <EuiFieldText + name="operator" + value={OPERATOR_TITLES.is} + data-test-subj={getTestId('operator')} + readOnly + /> + )} </ConditionEntryCell> - </EuiFlexItem> - <EuiFlexItem grow={3}> + </InputItem> + <InputItem gridArea="value"> <ConditionEntryCell showLabel={showLabels} label={ENTRY_PROPERTY_TITLES.value}> <EuiFieldText name="value" value={entry.value} + placeholder={getPlaceholderTextByOSType({ + os, + field: entry.field, + type: entry.type, + })} fullWidth required onChange={handleValueUpdate} @@ -165,8 +205,8 @@ export const ConditionEntryInput = memo<ConditionEntryInputProps>( data-test-subj={getTestId('value')} /> </ConditionEntryCell> - </EuiFlexItem> - <EuiFlexItem grow={false}> + </InputItem> + <InputItem gridArea="remove"> {/* Unicode `nbsp` is used below so that Remove button is property displayed */} <ConditionEntryCell showLabel={showLabels} label={'\u00A0'}> <EuiButtonIcon @@ -181,8 +221,8 @@ export const ConditionEntryInput = memo<ConditionEntryInputProps>( data-test-subj={getTestId('remove')} /> </ConditionEntryCell> - </EuiFlexItem> - </EuiFlexGroup> + </InputItem> + </InputGroup> ); } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx index 0520f760d7343..8289792b81f89 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx @@ -31,7 +31,7 @@ import { ENTRY_PROPERTY_TITLES, CARD_DELETE_BUTTON_LABEL, CONDITION_FIELD_TITLE, - OPERATOR_TITLE, + OPERATOR_TITLES, CARD_EDIT_BUTTON_LABEL, } from '../../translations'; @@ -45,7 +45,7 @@ const getEntriesColumnDefinitions = (): Array<EuiTableFieldDataColumnType<Entry> truncateText: true, textOnly: true, width: '30%', - render(field: Entry['field'], entry: Entry) { + render(field: Entry['field'], _entry: Entry) { return CONDITION_FIELD_TITLE[field]; }, }, @@ -55,8 +55,8 @@ const getEntriesColumnDefinitions = (): Array<EuiTableFieldDataColumnType<Entry> sortable: false, truncateText: true, width: '20%', - render(field: Entry['operator'], entry: Entry) { - return OPERATOR_TITLE[field]; + render(_field: Entry['operator'], entry: Entry) { + return entry.type === 'wildcard' ? OPERATOR_TITLES.matches : OPERATOR_TITLES.is; }, }, { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts index c3e2a372fd6dc..fc031a63b84b1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts @@ -10,8 +10,8 @@ import { TrustedApp, MacosLinuxConditionEntry, WindowsConditionEntry, - ConditionEntry, ConditionEntryField, + OperatorFieldIds, } from '../../../../../common/endpoint/types'; export { OS_TITLES } from '../../../common/translations'; @@ -52,10 +52,13 @@ export const CONDITION_FIELD_DESCRIPTION: { [K in ConditionEntryField]: string } ), }; -export const OPERATOR_TITLE: { [K in ConditionEntry['operator']]: string } = { - included: i18n.translate('xpack.securitySolution.trustedapps.card.operator.includes', { +export const OPERATOR_TITLES: { [K in OperatorFieldIds]: string } = { + is: i18n.translate('xpack.securitySolution.trustedapps.card.operator.is', { defaultMessage: 'is', }), + matches: i18n.translate('xpack.securitySolution.trustedapps.card.operator.matches', { + defaultMessage: 'matches', + }), }; export const PROPERTY_TITLES: Readonly< diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx index faf8e4f2ddafc..bcf9953d70d83 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx @@ -5,93 +5,62 @@ * 2.0. */ -import { EuiHorizontalRule, EuiText } from '@elastic/eui'; -import React, { useEffect, useMemo, useRef, useCallback } from 'react'; +import React, { useCallback } from 'react'; -import { FilterOptions, QueryParams } from '../../../cases/containers/types'; -import { DEFAULT_QUERY_PARAMS, useGetCases } from '../../../cases/containers/use_get_cases'; -import { LoadingPlaceholders } from '../loading_placeholders'; -import { NoCases } from './no_cases'; -import { RecentCases } from './recent_cases'; -import * as i18n from './translations'; +import { + getCaseDetailsUrl, + getCaseUrl, + getCreateCaseUrl, +} from '../../../common/components/link_to/redirect_to_case'; +import { useFormatUrl } from '../../../common/components/link_to'; import { useKibana } from '../../../common/lib/kibana'; import { APP_ID } from '../../../../common/constants'; import { SecurityPageName } from '../../../app/types'; -import { useFormatUrl } from '../../../common/components/link_to'; -import { LinkAnchor } from '../../../common/components/links'; - -const usePrevious = (value: FilterOptions) => { - const ref = useRef(); - useEffect(() => { - (ref.current as unknown) = value; - }); - return ref.current; -}; +import { AllCasesNavProps } from '../../../cases/components/all_cases'; const MAX_CASES_TO_SHOW = 3; +const RecentCasesComponent = () => { + const { formatUrl } = useFormatUrl(SecurityPageName.case); + const { + cases: casesUi, + application: { navigateToApp }, + } = useKibana().services; -const queryParams: QueryParams = { - ...DEFAULT_QUERY_PARAMS, - perPage: MAX_CASES_TO_SHOW, -}; - -const StatefulRecentCasesComponent = React.memo( - ({ filterOptions }: { filterOptions: FilterOptions }) => { - const { formatUrl } = useFormatUrl(SecurityPageName.case); - const { navigateToApp } = useKibana().services.application; - const previousFilterOptions = usePrevious(filterOptions); - const { data, loading, setFilters } = useGetCases(queryParams); - const isLoadingCases = useMemo( - () => loading.indexOf('cases') > -1 || loading.indexOf('caseUpdate') > -1, - [loading] - ); + const goToCases = useCallback( + (ev) => { + ev.preventDefault(); + navigateToApp(`${APP_ID}:${SecurityPageName.case}`); + }, + [navigateToApp] + ); - const goToCases = useCallback( - (ev) => { - ev.preventDefault(); - navigateToApp(`${APP_ID}:${SecurityPageName.case}`); + return casesUi.getRecentCases({ + allCasesNavigation: { + href: formatUrl(getCaseUrl()), + onClick: goToCases, + }, + caseDetailsNavigation: { + href: ({ detailName, subCaseId }: AllCasesNavProps) => { + return formatUrl(getCaseDetailsUrl({ id: detailName, subCaseId })); }, - [navigateToApp] - ); - - const allCasesLink = useMemo( - () => ( - <LinkAnchor onClick={goToCases} href={formatUrl('')}> - {' '} - {i18n.VIEW_ALL_CASES} - </LinkAnchor> - ), - [goToCases, formatUrl] - ); - - useEffect(() => { - if (previousFilterOptions !== undefined && previousFilterOptions !== filterOptions) { - setFilters(filterOptions); - } - }, [previousFilterOptions, filterOptions, setFilters]); - - const content = useMemo( - () => - isLoadingCases ? ( - <LoadingPlaceholders lines={2} placeholders={3} /> - ) : !isLoadingCases && data.cases.length === 0 ? ( - <NoCases /> - ) : ( - <RecentCases cases={data.cases} /> - ), - [isLoadingCases, data] - ); - - return ( - <EuiText color="subdued" size="s"> - {content} - <EuiHorizontalRule margin="s" /> - <EuiText size="xs">{allCasesLink}</EuiText> - </EuiText> - ); - } -); + onClick: ({ detailName, subCaseId, search }: AllCasesNavProps) => { + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: getCaseDetailsUrl({ id: detailName, search, subCaseId }), + }); + }, + }, + createCaseNavigation: { + href: formatUrl(getCreateCaseUrl()), + onClick: () => { + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: getCreateCaseUrl(), + }); + }, + }, + maxCasesToShow: MAX_CASES_TO_SHOW, + }); +}; -StatefulRecentCasesComponent.displayName = 'StatefulRecentCasesComponent'; +RecentCasesComponent.displayName = 'RecentCasesComponent'; -export const StatefulRecentCases = React.memo(StatefulRecentCasesComponent); +export const RecentCases = React.memo(RecentCasesComponent); diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/no_cases/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_cases/no_cases/index.test.tsx deleted file mode 100644 index ccb2d776f6e61..0000000000000 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/no_cases/index.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { mount } from 'enzyme'; - -import { useKibana } from '../../../../common/lib/kibana'; -import '../../../../common/mock/match_media'; -import { TestProviders } from '../../../../common/mock'; -import { NoCases } from '.'; - -jest.mock('../../../../common/lib/kibana'); - -const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; - -describe('RecentCases', () => { - let navigateToApp: jest.Mock; - - beforeEach(() => { - navigateToApp = jest.fn(); - useKibanaMock().services.application.navigateToApp = navigateToApp; - }); - - it('if no cases, you should be able to create a case by clicking on the link "start a new case"', () => { - const wrapper = mount( - <TestProviders> - <NoCases /> - </TestProviders> - ); - wrapper.find(`[data-test-subj="no-cases-create-case"]`).first().simulate('click'); - expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { - path: - "/create?sourcerer=(default:!('apm-*-transaction*','auditbeat-*','endgame-*','filebeat-*','logs-*','packetbeat-*','winlogbeat-*'))&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now%2Fd,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now%2Fd)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now%2Fd,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now%2Fd)))", - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/no_cases/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_cases/no_cases/index.tsx deleted file mode 100644 index 9d538dcf88a89..0000000000000 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/no_cases/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo, useCallback } from 'react'; - -import { APP_ID } from '../../../../../common/constants'; -import { getCreateCaseUrl } from '../../../../common/components/link_to/redirect_to_case'; -import { LinkAnchor } from '../../../../common/components/links'; -import { useFormatUrl } from '../../../../common/components/link_to'; -import * as i18n from '../translations'; -import { useKibana } from '../../../../common/lib/kibana'; -import { SecurityPageName } from '../../../../app/types'; - -const NoCasesComponent = () => { - const { formatUrl, search } = useFormatUrl(SecurityPageName.case); - const { navigateToApp } = useKibana().services.application; - - const goToCreateCase = useCallback( - (ev) => { - ev.preventDefault(); - navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: getCreateCaseUrl(search), - }); - }, - [navigateToApp, search] - ); - const newCaseLink = useMemo( - () => ( - <LinkAnchor - data-test-subj="no-cases-create-case" - onClick={goToCreateCase} - href={formatUrl(getCreateCaseUrl())} - >{` ${i18n.START_A_NEW_CASE}`}</LinkAnchor> - ), - [formatUrl, goToCreateCase] - ); - - return ( - <> - <span>{i18n.NO_CASES}</span> - {newCaseLink} - {'!'} - </> - ); -}; - -NoCasesComponent.displayName = 'NoCasesComponent'; - -export const NoCases = React.memo(NoCasesComponent); diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/recent_cases.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_cases/recent_cases.tsx deleted file mode 100644 index 7cc60878bcfe2..0000000000000 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/recent_cases.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; - -import { Case } from '../../../cases/containers/types'; -import { getCaseDetailsUrl } from '../../../common/components/link_to/redirect_to_case'; -import { MarkdownRenderer } from '../../../common/components/markdown_editor'; -import { useFormatUrl } from '../../../common/components/link_to'; -import { IconWithCount } from '../recent_timelines/counts'; -import { LinkAnchor } from '../../../common/components/links'; -import * as i18n from './translations'; -import { useKibana } from '../../../common/lib/kibana'; -import { APP_ID } from '../../../../common/constants'; -import { SecurityPageName } from '../../../app/types'; - -const MarkdownContainer = styled.div` - max-height: 150px; - overflow-y: auto; - width: 300px; -`; - -const RecentCasesComponent = ({ cases }: { cases: Case[] }) => { - const { formatUrl, search } = useFormatUrl(SecurityPageName.case); - const { navigateToApp } = useKibana().services.application; - - return ( - <> - {cases.map((c, i) => ( - <EuiFlexGroup key={c.id} gutterSize="none" justifyContent="spaceBetween"> - <EuiFlexItem grow={false}> - <EuiText size="s"> - <LinkAnchor - onClick={(ev: { preventDefault: () => void }) => { - ev.preventDefault(); - navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: getCaseDetailsUrl({ id: c.id, search }), - }); - }} - href={formatUrl(getCaseDetailsUrl({ id: c.id }))} - > - {c.title} - </LinkAnchor> - </EuiText> - - <IconWithCount count={c.totalComment} icon={'editorComment'} tooltip={i18n.COMMENTS} /> - {c.description && c.description.length && ( - <MarkdownContainer> - <EuiText color="subdued" size="xs"> - <MarkdownRenderer disableLinks={true}>{c.description}</MarkdownRenderer> - </EuiText> - </MarkdownContainer> - )} - {i !== cases.length - 1 && <EuiSpacer size="l" />} - </EuiFlexItem> - </EuiFlexGroup> - ))} - </> - ); -}; - -RecentCasesComponent.displayName = 'RecentCasesComponent'; - -export const RecentCases = React.memo(RecentCasesComponent); diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/translations.ts b/x-pack/plugins/security_solution/public/overview/components/recent_cases/translations.ts deleted file mode 100644 index 37588c0c4bbed..0000000000000 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/translations.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const COMMENTS = i18n.translate('xpack.securitySolution.recentCases.commentsTooltip', { - defaultMessage: 'Comments', -}); - -export const MY_RECENTLY_REPORTED_CASES = i18n.translate( - 'xpack.securitySolution.overview.myRecentlyReportedCasesButtonLabel', - { - defaultMessage: 'My recently reported cases', - } -); - -export const NO_CASES = i18n.translate('xpack.securitySolution.recentCases.noCasesMessage', { - defaultMessage: 'No cases have been created yet. Put your detective hat on and', -}); - -export const RECENTLY_CREATED_CASES = i18n.translate( - 'xpack.securitySolution.overview.recentlyCreatedCasesButtonLabel', - { - defaultMessage: 'Recently created cases', - } -); - -export const START_A_NEW_CASE = i18n.translate( - 'xpack.securitySolution.recentCases.startNewCaseLink', - { - defaultMessage: 'start a new case', - } -); - -export const VIEW_ALL_CASES = i18n.translate( - 'xpack.securitySolution.recentCases.viewAllCasesLink', - { - defaultMessage: 'View all cases', - } -); - -export const CASES_FILTER_CONTROL = i18n.translate( - 'xpack.securitySolution.recentCases.controlLegend', - { - defaultMessage: 'Cases filter', - } -); diff --git a/x-pack/plugins/security_solution/public/overview/components/sidebar/index.tsx b/x-pack/plugins/security_solution/public/overview/components/sidebar/index.tsx index 7ae15ecb6f215..811078bd2e45f 100644 --- a/x-pack/plugins/security_solution/public/overview/components/sidebar/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/sidebar/index.tsx @@ -8,7 +8,6 @@ import React, { useState } from 'react'; import { FilterMode as RecentTimelinesFilterMode } from '../recent_timelines/types'; -import { FilterMode as RecentCasesFilterMode } from '../recent_cases/types'; import { Sidebar } from './sidebar'; @@ -16,14 +15,9 @@ export const StatefulSidebar = React.memo(() => { const [recentTimelinesFilterBy, setRecentTimelinesFilterBy] = useState<RecentTimelinesFilterMode>( 'favorites' ); - const [recentCasesFilterBy, setRecentCasesFilterBy] = useState<RecentCasesFilterMode>( - 'recentlyCreated' - ); return ( <Sidebar - recentCasesFilterBy={recentCasesFilterBy} - setRecentCasesFilterBy={setRecentCasesFilterBy} recentTimelinesFilterBy={recentTimelinesFilterBy} setRecentTimelinesFilterBy={setRecentTimelinesFilterBy} /> diff --git a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx index cd88b8f44dc7b..77cfa220f0722 100644 --- a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx @@ -10,18 +10,14 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; import { ENABLE_NEWS_FEED_SETTING, NEWS_FEED_URL_SETTING } from '../../../../common/constants'; -import { Filters as RecentCasesFilters } from '../recent_cases/filters'; import { Filters as RecentTimelinesFilters } from '../recent_timelines/filters'; -import { StatefulRecentCases } from '../recent_cases'; import { StatefulRecentTimelines } from '../recent_timelines'; import { StatefulNewsFeed } from '../../../common/components/news_feed'; import { FilterMode as RecentTimelinesFilterMode } from '../recent_timelines/types'; -import { FilterMode as RecentCasesFilterMode } from '../recent_cases/types'; -import { DEFAULT_FILTER_OPTIONS } from '../../../cases/containers/use_get_cases'; import { SidebarHeader } from '../../../common/components/sidebar_header'; -import { useCurrentUser } from '../../../common/lib/kibana'; import * as i18n from '../../pages/translations'; +import { RecentCases } from '../recent_cases'; const SidebarFlexGroup = styled(EuiFlexGroup)` width: 305px; @@ -37,79 +33,42 @@ SidebarSpacerComponent.displayName = 'SidebarSpacerComponent'; const Spacer = React.memo(SidebarSpacerComponent); export const Sidebar = React.memo<{ - recentCasesFilterBy: RecentCasesFilterMode; recentTimelinesFilterBy: RecentTimelinesFilterMode; - setRecentCasesFilterBy: (filterBy: RecentCasesFilterMode) => void; setRecentTimelinesFilterBy: (filterBy: RecentTimelinesFilterMode) => void; -}>( - ({ - recentCasesFilterBy, - recentTimelinesFilterBy, - setRecentCasesFilterBy, - setRecentTimelinesFilterBy, - }) => { - const currentUser = useCurrentUser(); - const recentCasesFilters = useMemo( - () => ( - <RecentCasesFilters - filterBy={recentCasesFilterBy} - setFilterBy={setRecentCasesFilterBy} - showMyRecentlyReported={currentUser != null} - /> - ), - [currentUser, recentCasesFilterBy, setRecentCasesFilterBy] - ); - const recentCasesFilterOptions = useMemo( - () => - recentCasesFilterBy === 'myRecentlyReported' && currentUser != null - ? { - ...DEFAULT_FILTER_OPTIONS, - reporters: [ - { - email: currentUser.email, - full_name: currentUser.fullName, - username: currentUser.username, - }, - ], - } - : DEFAULT_FILTER_OPTIONS, - [currentUser, recentCasesFilterBy] - ); - const recentTimelinesFilters = useMemo( - () => ( - <RecentTimelinesFilters - filterBy={recentTimelinesFilterBy} - setFilterBy={setRecentTimelinesFilterBy} - /> - ), - [recentTimelinesFilterBy, setRecentTimelinesFilterBy] - ); +}>(({ recentTimelinesFilterBy, setRecentTimelinesFilterBy }) => { + const recentTimelinesFilters = useMemo( + () => ( + <RecentTimelinesFilters + filterBy={recentTimelinesFilterBy} + setFilterBy={setRecentTimelinesFilterBy} + /> + ), + [recentTimelinesFilterBy, setRecentTimelinesFilterBy] + ); - return ( - <SidebarFlexGroup direction="column" gutterSize="none"> - <EuiFlexItem grow={false}> - <SidebarHeader title={i18n.RECENT_CASES}>{recentCasesFilters}</SidebarHeader> - <StatefulRecentCases filterOptions={recentCasesFilterOptions} /> - </EuiFlexItem> + return ( + <SidebarFlexGroup direction="column" gutterSize="none"> + <EuiFlexItem grow={false}> + <RecentCases /> + </EuiFlexItem> - <Spacer /> + <Spacer /> - <EuiFlexItem grow={false}> - <SidebarHeader title={i18n.RECENT_TIMELINES}>{recentTimelinesFilters}</SidebarHeader> - <StatefulRecentTimelines filterBy={recentTimelinesFilterBy} /> - </EuiFlexItem> + <EuiFlexItem grow={false}> + <SidebarHeader title={i18n.RECENT_TIMELINES}>{recentTimelinesFilters}</SidebarHeader> + <StatefulRecentTimelines filterBy={recentTimelinesFilterBy} /> + </EuiFlexItem> - <Spacer /> + <Spacer /> - <EuiFlexItem grow={false}> - <StatefulNewsFeed - enableNewsFeedSetting={ENABLE_NEWS_FEED_SETTING} - newsFeedSetting={NEWS_FEED_URL_SETTING} - /> - </EuiFlexItem> - </SidebarFlexGroup> - ); - } -); + <EuiFlexItem grow={false}> + <StatefulNewsFeed + enableNewsFeedSetting={ENABLE_NEWS_FEED_SETTING} + newsFeedSetting={NEWS_FEED_URL_SETTING} + /> + </EuiFlexItem> + </SidebarFlexGroup> + ); +}); Sidebar.displayName = 'Sidebar'; diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 23f3472b470b5..efbe857d168d8 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -63,7 +63,6 @@ import { IndexFieldsStrategyResponse, } from '../common/search_strategy/index_fields'; import { SecurityAppStore } from './common/store/store'; -import { getCaseConnectorUI } from './cases/components/connectors'; import { licenseService } from './common/hooks/use_license'; import { SecuritySolutionUiConfigType } from './common/types'; @@ -327,8 +326,6 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S }, }); - plugins.triggersActionsUi.actionTypeRegistry.register(getCaseConnectorUI()); - return { resolver: async () => { /** diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.test.tsx index 84406aed3619f..1375f6fb4bcb7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.test.tsx @@ -12,11 +12,38 @@ import { waitFor } from '@testing-library/react'; import { AddTimelineButton } from './'; import { useKibana } from '../../../../common/lib/kibana'; import { TimelineId } from '../../../../../common/types/timeline'; +import { mockOpenTimelineQueryResults, TestProviders } from '../../../../common/mock'; +import { mockHistory, Router } from '../../../../cases/components/__mock__/router'; +import { getAllTimeline, useGetAllTimeline } from '../../../containers/all'; + +jest.mock('../../open_timeline/use_timeline_status', () => { + const originalModule = jest.requireActual('../../open_timeline/use_timeline_status'); + return { + ...originalModule, + useTimelineStatus: jest.fn().mockReturnValue({ + timelineStatus: 'active', + templateTimelineFilter: [], + installPrepackagedTimelines: jest.fn(), + }), + }; +}); -jest.mock('../../../../common/lib/kibana', () => ({ - useKibana: jest.fn(), - useUiSetting$: jest.fn().mockReturnValue([]), -})); +jest.mock('../../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../../common/lib/kibana'); + return { + ...originalModule, + useKibana: jest.fn(), + useUiSetting$: jest.fn().mockReturnValue([]), + }; +}); + +jest.mock('../../../containers/all', () => { + const originalModule = jest.requireActual('../../../containers/all'); + return { + ...originalModule, + useGetAllTimeline: jest.fn(), + }; +}); jest.mock('../../timeline/properties/new_template_timeline', () => ({ NewTemplateTimeline: jest.fn(() => <div data-test-subj="create-template-btn" />), @@ -35,8 +62,7 @@ jest.mock('../../../../common/components/inspect', () => ({ InspectButtonContainer: jest.fn(({ children }) => <div>{children}</div>), })); -// FLAKY: https://github.com/elastic/kibana/issues/96691 -describe.skip('AddTimelineButton', () => { +describe('AddTimelineButton', () => { let wrapper: ReactWrapper; const props = { timelineId: TimelineId.active, @@ -67,24 +93,24 @@ describe.skip('AddTimelineButton', () => { }); test('it renders create timeline btn', async () => { - await waitFor(() => { - wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); - expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy(); - }); + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + await waitFor(() => + expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy() + ); }); test('it renders create timeline template btn', async () => { - await waitFor(() => { - wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); - expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toBeTruthy(); - }); + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + await waitFor(() => + expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toBeTruthy() + ); }); test('it renders Open timeline btn', async () => { - await waitFor(() => { - wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); - expect(wrapper.find('[data-test-subj="open-timeline-button"]').exists()).toBeTruthy(); - }); + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + await waitFor(() => + expect(wrapper.find('[data-test-subj="open-timeline-button"]').exists()).toBeTruthy() + ); }); }); @@ -113,24 +139,86 @@ describe.skip('AddTimelineButton', () => { }); test('it renders create timeline btn', async () => { - await waitFor(() => { - wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); - expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy(); - }); + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + await waitFor(() => + expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy() + ); }); test('it renders create timeline template btn', async () => { - await waitFor(() => { - wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); - expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toBeTruthy(); - }); + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + await waitFor(() => + expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toBeTruthy() + ); }); test('it renders Open timeline btn', async () => { + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + await waitFor(() => + expect(wrapper.find('[data-test-subj="open-timeline-button"]').exists()).toBeTruthy() + ); + }); + }); + + describe('open modal', () => { + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + getUrlForApp: jest.fn(), + capabilities: { + siem: { + crud: true, + }, + }, + }, + }, + }); + + ((useGetAllTimeline as unknown) as jest.Mock).mockReturnValue({ + fetchAllTimeline: jest.fn(), + timelines: getAllTimeline('', mockOpenTimelineQueryResults.timeline ?? []), + loading: false, + totalCount: mockOpenTimelineQueryResults.totalCount, + refetch: jest.fn(), + }); + + wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <AddTimelineButton {...props} /> + </Router> + </TestProviders> + ); + }); + + afterEach(() => { + (useKibana as jest.Mock).mockReset(); + }); + + it('should render timelines table', async () => { + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); await waitFor(() => { - wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); expect(wrapper.find('[data-test-subj="open-timeline-button"]').exists()).toBeTruthy(); }); + + wrapper.find('[data-test-subj="open-timeline-button"]').first().simulate('click'); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="timelines-table"]').exists()).toBeTruthy(); + }); + }); + + it('should render correct actions', async () => { + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + await waitFor(() => + expect(wrapper.find('[data-test-subj="open-timeline-button"]').exists()).toBeTruthy() + ); + + wrapper.find('[data-test-subj="open-timeline-button"]').first().simulate('click'); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="open-duplicate"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="create-from-template"]').exists()).toBeFalsy(); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx index 90b1cf09cb6cd..5ea1b60e4f156 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { OpenTimelineModalButton } from '../../open_timeline/open_timeline_modal/open_timeline_modal_button'; import { OpenTimelineModal } from '../../open_timeline/open_timeline_modal'; +import { ActionTimelineToShow } from '../../open_timeline/types'; import * as i18n from '../../timeline/properties/translations'; import { NewTimeline } from '../../timeline/properties/helpers'; import { NewTemplateTimeline } from '../../timeline/properties/new_template_timeline'; @@ -20,6 +21,8 @@ interface AddTimelineButtonComponentProps { export const ADD_TIMELINE_BUTTON_CLASS_NAME = 'add-timeline-button'; +const actionTimelineToHide: ActionTimelineToShow[] = ['createFrom']; + const AddTimelineButtonComponent: React.FC<AddTimelineButtonComponentProps> = ({ timelineId }) => { const [showActions, setShowActions] = useState(false); const [showTimelineModal, setShowTimelineModal] = useState(false); @@ -83,7 +86,9 @@ const AddTimelineButtonComponent: React.FC<AddTimelineButtonComponentProps> = ({ </EuiPopover> </EuiFlexItem> - {showTimelineModal ? <OpenTimelineModal onClose={onCloseTimelineModal} /> : null} + {showTimelineModal ? ( + <OpenTimelineModal onClose={onCloseTimelineModal} hideActions={actionTimelineToHide} /> + ) : null} </> ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx index b959e80e2cc98..bc9876b207284 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx @@ -11,9 +11,18 @@ import { mount } from 'enzyme'; import { useKibana } from '../../../../common/lib/kibana'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { mockTimelineModel, TestProviders } from '../../../../common/mock'; -import { useAllCasesModal } from '../../../../cases/components/use_all_cases_modal'; import { AddToCaseButton } from '.'; +jest.mock('../../../../common/components/link_to', () => { + const original = jest.requireActual('../../../../common/components/link_to'); + return { + ...original, + useFormatUrl: jest.fn().mockReturnValue({ + formatUrl: jest.fn(), + search: '', + }), + }; +}); const mockDispatch = jest.fn(); jest.mock('react-redux', () => { const original = jest.requireActual('react-redux'); @@ -25,57 +34,51 @@ jest.mock('react-redux', () => { jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/hooks/use_selector'); -jest.mock('../../../../cases/components/use_all_cases_modal'); const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; -const useAllCasesModalMock = useAllCasesModal as jest.Mock; -describe('EventColumnView', () => { +describe('AddToCaseButton', () => { const navigateToApp = jest.fn(); beforeEach(() => { useKibanaMock().services.application.navigateToApp = navigateToApp; - (useDeepEqualSelector as jest.Mock).mockReturnValue(mockTimelineModel); }); it('navigates to the correct path without id', async () => { - useAllCasesModalMock.mockImplementation(({ onRowClick }) => { - onRowClick(); - - return { - modal: <>{'test'}</>, - openModal: jest.fn(), - isModalOpen: true, - closeModal: jest.fn(), - }; - }); - - mount( + const here = jest.fn(); + useKibanaMock().services.cases.getAllCasesSelectorModal = here.mockImplementation( + ({ onRowClick }) => { + onRowClick(); + return <></>; + } + ); + (useDeepEqualSelector as jest.Mock).mockReturnValue(mockTimelineModel); + const wrapper = mount( <TestProviders> <AddToCaseButton timelineId={'timeline-1'} /> </TestProviders> ); + wrapper.find(`[data-test-subj="attach-timeline-case-button"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="attach-timeline-existing-case"]`).first().simulate('click'); expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/create' }); }); it('navigates to the correct path with id', async () => { - useAllCasesModalMock.mockImplementation(({ onRowClick }) => { - onRowClick({ id: 'case-id' }); - - return { - modal: <>{'test'}</>, - openModal: jest.fn(), - isModalOpen: true, - closeModal: jest.fn(), - }; - }); - - mount( + useKibanaMock().services.cases.getAllCasesSelectorModal = jest + .fn() + .mockImplementation(({ onRowClick }) => { + onRowClick({ id: 'case-id' }); + return <></>; + }); + (useDeepEqualSelector as jest.Mock).mockReturnValue(mockTimelineModel); + const wrapper = mount( <TestProviders> <AddToCaseButton timelineId={'timeline-1'} /> </TestProviders> ); + wrapper.find(`[data-test-subj="attach-timeline-case-button"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="attach-timeline-existing-case"]`).first().simulate('click'); expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/case-id' }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx index 5cba64299ee9d..a4c6fe1e344b3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx @@ -10,17 +10,20 @@ import { EuiButton, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } from ' import React, { useCallback, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; +import { Case, SubCase } from '../../../../../../cases/common'; import { APP_ID } from '../../../../../common/constants'; import { timelineSelectors } from '../../../../timelines/store/timeline'; -import { useAllCasesModal } from '../../../../cases/components/use_all_cases_modal'; import { setInsertTimeline, showTimeline } from '../../../store/timeline/actions'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useGetUserSavedObjectPermissions, useKibana } from '../../../../common/lib/kibana'; import { TimelineStatus, TimelineId, TimelineType } from '../../../../../common/types/timeline'; -import { getCreateCaseUrl, getCaseDetailsUrl } from '../../../../common/components/link_to'; +import { + getCreateCaseUrl, + getCaseDetailsUrl, + useFormatUrl, +} from '../../../../common/components/link_to'; import { SecurityPageName } from '../../../../app/types'; import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; -import { Case, SubCase } from '../../../../cases/containers/types'; import * as i18n from '../../timeline/properties/translations'; interface Props { @@ -29,7 +32,10 @@ interface Props { const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => { const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const { navigateToApp } = useKibana().services.application; + const { + cases, + application: { navigateToApp }, + } = useKibana().services; const dispatch = useDispatch(); const { graphEventId, @@ -44,13 +50,14 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => { ) ); const [isPopoverOpen, setPopover] = useState(false); + const [isCaseModalOpen, openCaseModal] = useState(false); const onRowClick = useCallback( async (theCase?: Case | SubCase) => { + openCaseModal(false); await navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { path: theCase != null ? getCaseDetailsUrl({ id: theCase.id }) : getCreateCaseUrl(), }); - dispatch( setInsertTimeline({ graphEventId, @@ -63,7 +70,15 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => { [dispatch, graphEventId, navigateToApp, savedObjectId, timelineId, timelineTitle] ); - const { modal: allCasesModal, openModal: openCaseModal } = useAllCasesModal({ onRowClick }); + const { formatUrl } = useFormatUrl(SecurityPageName.case); + const userPermissions = useGetUserSavedObjectPermissions(); + const goToCreateCase = useCallback( + (ev) => { + ev.preventDefault(); + onRowClick(); + }, + [onRowClick] + ); const handleButtonClick = useCallback(() => { setPopover((currentIsOpen) => !currentIsOpen); @@ -73,12 +88,9 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => { const handleNewCaseClick = useCallback(() => { handlePopoverClose(); - - dispatch(showTimeline({ id: TimelineId.active, show: false })); - navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { path: getCreateCaseUrl(), - }).then(() => + }).then(() => { dispatch( setInsertTimeline({ graphEventId, @@ -86,8 +98,9 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => { timelineSavedObjectId: savedObjectId, timelineTitle: timelineTitle.length > 0 ? timelineTitle : i18n.UNTITLED_TIMELINE, }) - ) - ); + ); + dispatch(showTimeline({ id: TimelineId.active, show: false })); + }); }, [ dispatch, graphEventId, @@ -100,7 +113,7 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => { const handleExistingCaseClick = useCallback(() => { handlePopoverClose(); - openCaseModal(); + openCaseModal(true); }, [openCaseModal, handlePopoverClose]); const closePopover = useCallback(() => { @@ -156,7 +169,15 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => { > <EuiContextMenuPanel items={items} /> </EuiPopover> - {allCasesModal} + {isCaseModalOpen && + cases.getAllCasesSelectorModal({ + createCaseNavigation: { + href: formatUrl(getCreateCaseUrl()), + onClick: goToCreateCase, + }, + onRowClick, + userCanCrud: userPermissions?.crud ?? false, + })} </> ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx index c1b30f3e68cf4..4aa6fd469de26 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx @@ -83,13 +83,15 @@ export const getTimelinesTableColumns = ({ }), ...getExtendedColumns(showExtendedColumns), ...getIconHeaderColumns({ timelineType }), - ...getActionsColumns({ - actionTimelineToShow, - deleteTimelines, - enableExportTimelineDownloader, - onOpenDeleteTimelineModal, - onOpenTimeline, - }), + ...(actionTimelineToShow.length + ? getActionsColumns({ + actionTimelineToShow, + deleteTimelines, + enableExportTimelineDownloader, + onOpenDeleteTimelineModal, + onOpenTimeline, + }) + : []), ]; }; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.ts index a6c2126f95e8d..d1c798a27b6c4 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.ts @@ -12,7 +12,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; // eslint-disable-next-line no-restricted-imports import isEmpty from 'lodash/isEmpty'; -import { throwErrors } from '../../../../cases/common/api'; +import { throwErrors } from '../../../../cases/common'; import { TimelineResponse, TimelineResponseType, @@ -42,8 +42,7 @@ import { import { KibanaServices } from '../../common/lib/kibana'; import { ExportSelectedData } from '../../common/components/generic_downloader'; - -import { createToasterPlainError } from '../../cases/containers/utils'; +import { ToasterError } from '../../common/components/toasters'; import { ImportDataProps, ImportDataResponse, @@ -61,7 +60,7 @@ interface RequestPatchTimeline<T = string> extends RequestPostTimeline { } type RequestPersistTimeline = RequestPostTimeline & Partial<RequestPatchTimeline<null | string>>; - +const createToasterPlainError = (message: string) => new ToasterError([message]); const decodeTimelineResponse = (respTimeline?: TimelineResponse | TimelineErrorResponse) => pipe( TimelineResponseType.decode(respTimeline), diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 7b9cd2f6e1db5..d4e2601554187 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -21,6 +21,7 @@ import { TriggersAndActionsUIPublicPluginSetup as TriggersActionsSetup, TriggersAndActionsUIPublicPluginStart as TriggersActionsStart, } from '../../triggers_actions_ui/public'; +import { CasesUiStart } from '../../cases/public'; import { SecurityPluginSetup } from '../../security/public'; import { ResolverPluginSetup } from './resolver/types'; import { Inspect } from '../common/search_strategy'; @@ -46,6 +47,7 @@ export interface SetupPlugins { } export interface StartPlugins { + cases: CasesUiStart; data: DataPublicPluginStart; embeddable: EmbeddableStart; inspector: InspectorStart; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index 1c3c92c50afd3..54b6971eec58e 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -22,6 +22,8 @@ import { translatedEntryMatchAnyMatcher, TranslatedEntryMatcher, translatedEntryMatchMatcher, + TranslatedEntryMatchWildcardMatcher, + translatedEntryMatchWildcardMatcher, TranslatedEntryNestedEntry, translatedEntryNestedEntry, TranslatedExceptionListItem, @@ -203,6 +205,10 @@ function getMatcherFunction(field: string, matchAny?: boolean): TranslatedEntryM : 'exact_cased'; } +function getMatcherWildcardFunction(field: string): TranslatedEntryMatchWildcardMatcher { + return field.endsWith('.caseless') ? 'wildcard_caseless' : 'wildcard_cased'; +} + function normalizeFieldName(field: string): string { return field.endsWith('.caseless') ? field.substring(0, field.lastIndexOf('.')) : field; } @@ -272,6 +278,17 @@ function translateEntry( } : undefined; } + case 'wildcard': { + const matcher = getMatcherWildcardFunction(entry.field); + return translatedEntryMatchWildcardMatcher.is(matcher) + ? { + field: normalizeFieldName(entry.field), + operator: entry.operator, + type: matcher, + value: entry.value, + } + : undefined; + } } } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts index 42a2e0f43d970..0b4e1cb2b09b1 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts @@ -66,8 +66,8 @@ const NEW_TRUSTED_APP: NewTrustedApp = { os: OperatingSystem.LINUX, effectScope: { type: 'global' }, entries: [ - createConditionEntry(ConditionEntryField.PATH, '/bin/malware'), - createConditionEntry(ConditionEntryField.HASH, '1234234659af249ddf3e40864e9fb241'), + createConditionEntry(ConditionEntryField.PATH, 'match', '/bin/malware'), + createConditionEntry(ConditionEntryField.HASH, 'match', '1234234659af249ddf3e40864e9fb241'), ], }; @@ -83,8 +83,8 @@ const TRUSTED_APP: TrustedApp = { os: OperatingSystem.LINUX, effectScope: { type: 'global' }, entries: [ - createConditionEntry(ConditionEntryField.HASH, '1234234659af249ddf3e40864e9fb241'), - createConditionEntry(ConditionEntryField.PATH, '/bin/malware'), + createConditionEntry(ConditionEntryField.HASH, 'match', '1234234659af249ddf3e40864e9fb241'), + createConditionEntry(ConditionEntryField.PATH, 'match', '/bin/malware'), ], }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts index 68ff7d03e413a..9ee2ece627841 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts @@ -79,13 +79,19 @@ describe('mapping', () => { description: 'Linux Trusted App', effectScope: { type: 'global' }, os: OperatingSystem.LINUX, - entries: [createConditionEntry(ConditionEntryField.PATH, '/bin/malware')], + entries: [createConditionEntry(ConditionEntryField.PATH, 'match', '/bin/malware')], }, createExceptionListItemOptions({ name: 'linux trusted app', description: 'Linux Trusted App', osTypes: ['linux'], - entries: [createEntryMatch('process.executable.caseless', '/bin/malware')], + entries: [ + createEntryMatch( + 'process.executable.caseless', + + '/bin/malware' + ), + ], }) ); }); @@ -97,13 +103,19 @@ describe('mapping', () => { description: 'MacOS Trusted App', effectScope: { type: 'global' }, os: OperatingSystem.MAC, - entries: [createConditionEntry(ConditionEntryField.PATH, '/bin/malware')], + entries: [createConditionEntry(ConditionEntryField.PATH, 'match', '/bin/malware')], }, createExceptionListItemOptions({ name: 'macos trusted app', description: 'MacOS Trusted App', osTypes: ['macos'], - entries: [createEntryMatch('process.executable.caseless', '/bin/malware')], + entries: [ + createEntryMatch( + 'process.executable.caseless', + + '/bin/malware' + ), + ], }) ); }); @@ -115,13 +127,21 @@ describe('mapping', () => { description: 'Windows Trusted App', effectScope: { type: 'global' }, os: OperatingSystem.WINDOWS, - entries: [createConditionEntry(ConditionEntryField.PATH, 'C:\\Program Files\\Malware')], + entries: [ + createConditionEntry(ConditionEntryField.PATH, 'match', 'C:\\Program Files\\Malware'), + ], }, createExceptionListItemOptions({ name: 'windows trusted app', description: 'Windows Trusted App', osTypes: ['windows'], - entries: [createEntryMatch('process.executable.caseless', 'C:\\Program Files\\Malware')], + entries: [ + createEntryMatch( + 'process.executable.caseless', + + 'C:\\Program Files\\Malware' + ), + ], }) ); }); @@ -133,7 +153,7 @@ describe('mapping', () => { description: 'Signed Trusted App', effectScope: { type: 'global' }, os: OperatingSystem.WINDOWS, - entries: [createConditionEntry(ConditionEntryField.SIGNER, 'Microsoft Windows')], + entries: [createConditionEntry(ConditionEntryField.SIGNER, 'match', 'Microsoft Windows')], }, createExceptionListItemOptions({ name: 'Signed trusted app', @@ -157,14 +177,24 @@ describe('mapping', () => { effectScope: { type: 'global' }, os: OperatingSystem.LINUX, entries: [ - createConditionEntry(ConditionEntryField.HASH, '1234234659af249ddf3e40864e9fb241'), + createConditionEntry( + ConditionEntryField.HASH, + 'match', + '1234234659af249ddf3e40864e9fb241' + ), ], }, createExceptionListItemOptions({ name: 'MD5 trusted app', description: 'MD5 Trusted App', osTypes: ['linux'], - entries: [createEntryMatch('process.hash.md5', '1234234659af249ddf3e40864e9fb241')], + entries: [ + createEntryMatch( + 'process.hash.md5', + + '1234234659af249ddf3e40864e9fb241' + ), + ], }) ); }); @@ -179,6 +209,7 @@ describe('mapping', () => { entries: [ createConditionEntry( ConditionEntryField.HASH, + 'match', 'f635da961234234659af249ddf3e40864e9fb241' ), ], @@ -188,7 +219,11 @@ describe('mapping', () => { description: 'SHA1 Trusted App', osTypes: ['linux'], entries: [ - createEntryMatch('process.hash.sha1', 'f635da961234234659af249ddf3e40864e9fb241'), + createEntryMatch( + 'process.hash.sha1', + + 'f635da961234234659af249ddf3e40864e9fb241' + ), ], }) ); @@ -204,6 +239,7 @@ describe('mapping', () => { entries: [ createConditionEntry( ConditionEntryField.HASH, + 'match', 'f635da96124659af249ddf3e40864e9fb234234659af249ddf3e40864e9fb241' ), ], @@ -215,6 +251,7 @@ describe('mapping', () => { entries: [ createEntryMatch( 'process.hash.sha256', + 'f635da96124659af249ddf3e40864e9fb234234659af249ddf3e40864e9fb241' ), ], @@ -230,14 +267,24 @@ describe('mapping', () => { effectScope: { type: 'global' }, os: OperatingSystem.LINUX, entries: [ - createConditionEntry(ConditionEntryField.HASH, '1234234659Af249ddf3e40864E9FB241'), + createConditionEntry( + ConditionEntryField.HASH, + 'match', + '1234234659Af249ddf3e40864E9FB241' + ), ], }, createExceptionListItemOptions({ name: 'MD5 trusted app', description: 'MD5 Trusted App', osTypes: ['linux'], - entries: [createEntryMatch('process.hash.md5', '1234234659af249ddf3e40864e9fb241')], + entries: [ + createEntryMatch( + 'process.hash.md5', + + '1234234659af249ddf3e40864e9fb241' + ), + ], }) ); }); @@ -257,7 +304,13 @@ describe('mapping', () => { created_at: '11/11/2011T11:11:11.111', created_by: 'admin', os_types: ['linux'], - entries: [createEntryMatch('process.executable.caseless', '/bin/malware')], + entries: [ + createEntryMatch( + 'process.executable.caseless', + + '/bin/malware' + ), + ], }), { id: '123', @@ -270,7 +323,7 @@ describe('mapping', () => { updated_at: '11/11/2011T11:11:11.111', updated_by: 'admin', os: OperatingSystem.LINUX, - entries: [createConditionEntry(ConditionEntryField.PATH, '/bin/malware')], + entries: [createConditionEntry(ConditionEntryField.PATH, 'match', '/bin/malware')], } ); }); @@ -284,7 +337,13 @@ describe('mapping', () => { created_at: '11/11/2011T11:11:11.111', created_by: 'admin', os_types: ['macos'], - entries: [createEntryMatch('process.executable.caseless', '/bin/malware')], + entries: [ + createEntryMatch( + 'process.executable.caseless', + + '/bin/malware' + ), + ], }), { id: '123', @@ -297,7 +356,7 @@ describe('mapping', () => { updated_at: '11/11/2011T11:11:11.111', updated_by: 'admin', os: OperatingSystem.MAC, - entries: [createConditionEntry(ConditionEntryField.PATH, '/bin/malware')], + entries: [createConditionEntry(ConditionEntryField.PATH, 'match', '/bin/malware')], } ); }); @@ -311,7 +370,13 @@ describe('mapping', () => { created_at: '11/11/2011T11:11:11.111', created_by: 'admin', os_types: ['windows'], - entries: [createEntryMatch('process.executable.caseless', 'C:\\Program Files\\Malware')], + entries: [ + createEntryMatch( + 'process.executable.caseless', + + 'C:\\Program Files\\Malware' + ), + ], }), { id: '123', @@ -324,7 +389,9 @@ describe('mapping', () => { updated_at: '11/11/2011T11:11:11.111', updated_by: 'admin', os: OperatingSystem.WINDOWS, - entries: [createConditionEntry(ConditionEntryField.PATH, 'C:\\Program Files\\Malware')], + entries: [ + createConditionEntry(ConditionEntryField.PATH, 'match', 'C:\\Program Files\\Malware'), + ], } ); }); @@ -356,7 +423,7 @@ describe('mapping', () => { updated_at: '11/11/2011T11:11:11.111', updated_by: 'admin', os: OperatingSystem.WINDOWS, - entries: [createConditionEntry(ConditionEntryField.SIGNER, 'Microsoft Windows')], + entries: [createConditionEntry(ConditionEntryField.SIGNER, 'match', 'Microsoft Windows')], } ); }); @@ -370,7 +437,13 @@ describe('mapping', () => { created_at: '11/11/2011T11:11:11.111', created_by: 'admin', os_types: ['linux'], - entries: [createEntryMatch('process.hash.md5', '1234234659af249ddf3e40864e9fb241')], + entries: [ + createEntryMatch( + 'process.hash.md5', + + '1234234659af249ddf3e40864e9fb241' + ), + ], }), { id: '123', @@ -384,7 +457,11 @@ describe('mapping', () => { updated_by: 'admin', os: OperatingSystem.LINUX, entries: [ - createConditionEntry(ConditionEntryField.HASH, '1234234659af249ddf3e40864e9fb241'), + createConditionEntry( + ConditionEntryField.HASH, + 'match', + '1234234659af249ddf3e40864e9fb241' + ), ], } ); @@ -400,7 +477,11 @@ describe('mapping', () => { created_by: 'admin', os_types: ['linux'], entries: [ - createEntryMatch('process.hash.sha1', 'f635da961234234659af249ddf3e40864e9fb241'), + createEntryMatch( + 'process.hash.sha1', + + 'f635da961234234659af249ddf3e40864e9fb241' + ), ], }), { @@ -417,6 +498,7 @@ describe('mapping', () => { entries: [ createConditionEntry( ConditionEntryField.HASH, + 'match', 'f635da961234234659af249ddf3e40864e9fb241' ), ], @@ -436,6 +518,7 @@ describe('mapping', () => { entries: [ createEntryMatch( 'process.hash.sha256', + 'f635da96124659af249ddf3e40864e9fb234234659af249ddf3e40864e9fb241' ), ], @@ -454,6 +537,7 @@ describe('mapping', () => { entries: [ createConditionEntry( ConditionEntryField.HASH, + 'match', 'f635da96124659af249ddf3e40864e9fb234234659af249ddf3e40864e9fb241' ), ], @@ -469,7 +553,7 @@ describe('mapping', () => { description: 'Linux Trusted App', effectScope: { type: 'global' }, os: OperatingSystem.LINUX, - entries: [createConditionEntry(ConditionEntryField.PATH, '/bin/malware')], + entries: [createConditionEntry(ConditionEntryField.PATH, 'match', '/bin/malware')], version: 'abc', }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts index c6048e5725c88..786a74e91b51a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts @@ -11,6 +11,7 @@ import { OsType } from '../../../../../lists/common/schemas'; import { EntriesArray, EntryMatch, + EntryMatchWildcard, EntryNested, ExceptionListItemSchema, NestedEntriesArray, @@ -28,6 +29,7 @@ import { OperatingSystem, TrustedApp, UpdateTrustedApp, + TrustedAppEntryTypes, } from '../../../../common/endpoint/types'; type ConditionEntriesMap = { [K in ConditionEntryField]?: ConditionEntry<K> }; @@ -46,6 +48,7 @@ const OPERATING_SYSTEM_TO_OS_TYPE: Mapping<OperatingSystem, OsType> = { }; const POLICY_REFERENCE_PREFIX = 'policy:'; +const OPERATOR_VALUE = 'included'; const filterUndefined = <T>(list: Array<T | undefined>): T[] => { return list.filter((item: T | undefined): item is T => item !== undefined); @@ -53,9 +56,10 @@ const filterUndefined = <T>(list: Array<T | undefined>): T[] => { export const createConditionEntry = <T extends ConditionEntryField>( field: T, + type: TrustedAppEntryTypes, value: string ): ConditionEntry<T> => { - return { field, value, type: 'match', operator: 'included' }; + return { field, value, type, operator: OPERATOR_VALUE }; }; export const tagsToEffectScope = (tags: string[]): EffectScope => { @@ -78,12 +82,23 @@ export const entriesToConditionEntriesMap = (entries: EntriesArray): ConditionEn if (entry.field.startsWith('process.hash') && entry.type === 'match') { return { ...result, - [ConditionEntryField.HASH]: createConditionEntry(ConditionEntryField.HASH, entry.value), + [ConditionEntryField.HASH]: createConditionEntry( + ConditionEntryField.HASH, + entry.type, + entry.value + ), }; - } else if (entry.field === 'process.executable.caseless' && entry.type === 'match') { + } else if ( + entry.field === 'process.executable.caseless' && + (entry.type === 'match' || entry.type === 'wildcard') + ) { return { ...result, - [ConditionEntryField.PATH]: createConditionEntry(ConditionEntryField.PATH, entry.value), + [ConditionEntryField.PATH]: createConditionEntry( + ConditionEntryField.PATH, + entry.type, + entry.value + ), }; } else if (entry.field === 'process.Ext.code_signature' && entry.type === 'nested') { const subjectNameCondition = entry.entries.find((subEntry): subEntry is EntryMatch => { @@ -95,6 +110,7 @@ export const entriesToConditionEntriesMap = (entries: EntriesArray): ConditionEn ...result, [ConditionEntryField.SIGNER]: createConditionEntry( ConditionEntryField.SIGNER, + subjectNameCondition.type, subjectNameCondition.value ), }; @@ -166,7 +182,11 @@ const hashType = (hash: string): 'md5' | 'sha256' | 'sha1' | undefined => { }; export const createEntryMatch = (field: string, value: string): EntryMatch => { - return { field, value, type: 'match', operator: 'included' }; + return { field, value, type: 'match', operator: OPERATOR_VALUE }; +}; + +export const createEntryMatchWildcard = (field: string, value: string): EntryMatchWildcard => { + return { field, value, type: 'wildcard', operator: OPERATOR_VALUE }; }; export const createEntryNested = (field: string, entries: NestedEntriesArray): EntryNested => { @@ -193,6 +213,11 @@ export const conditionEntriesToEntries = (conditionEntries: ConditionEntry[]): E createEntryMatch('trusted', 'true'), createEntryMatch('subject_name', conditionEntry.value), ]); + } else if ( + conditionEntry.field === ConditionEntryField.PATH && + conditionEntry.type === 'wildcard' + ) { + return createEntryMatchWildcard(`process.executable.caseless`, conditionEntry.value); } else { return createEntryMatch(`process.executable.caseless`, conditionEntry.value); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts index 42f4c6d157389..d99a89ce11137 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts @@ -65,8 +65,8 @@ const TRUSTED_APP: TrustedApp = { os: OperatingSystem.LINUX, effectScope: { type: 'global' }, entries: [ - createConditionEntry(ConditionEntryField.HASH, '1234234659af249ddf3e40864e9fb241'), - createConditionEntry(ConditionEntryField.PATH, '/bin/malware'), + createConditionEntry(ConditionEntryField.HASH, 'match', '1234234659af249ddf3e40864e9fb241'), + createConditionEntry(ConditionEntryField.PATH, 'match', '/bin/malware'), ], }; @@ -109,8 +109,35 @@ describe('service', () => { effectScope: { type: 'global' }, os: OperatingSystem.LINUX, entries: [ - createConditionEntry(ConditionEntryField.PATH, '/bin/malware'), - createConditionEntry(ConditionEntryField.HASH, '1234234659af249ddf3e40864e9fb241'), + createConditionEntry(ConditionEntryField.PATH, 'match', '/bin/malware'), + createConditionEntry( + ConditionEntryField.HASH, + 'match', + '1234234659af249ddf3e40864e9fb241' + ), + ], + }); + + expect(result).toEqual({ data: TRUSTED_APP }); + + expect(exceptionsListClient.createTrustedAppsList).toHaveBeenCalled(); + }); + + it('should create trusted app with correct wildcard type', async () => { + exceptionsListClient.createExceptionListItem.mockResolvedValue(EXCEPTION_LIST_ITEM); + + const result = await createTrustedApp(exceptionsListClient, { + name: 'linux trusted app 1', + description: 'Linux trusted app 1', + effectScope: { type: 'global' }, + os: OperatingSystem.LINUX, + entries: [ + createConditionEntry(ConditionEntryField.PATH, 'wildcard', '/bin/malware'), + createConditionEntry( + ConditionEntryField.HASH, + 'wildcard', + '1234234659af249ddf3e40864e9fb241' + ), ], }); diff --git a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/lists.ts index 4c11325652f80..1b1370472f633 100644 --- a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/lists.ts @@ -30,6 +30,24 @@ export const translatedEntryMatchMatcher = t.keyof({ }); export type TranslatedEntryMatchMatcher = t.TypeOf<typeof translatedEntryMatchMatcher>; +export const translatedEntryMatchWildcardMatcher = t.keyof({ + wildcard_cased: null, + wildcard_caseless: null, +}); +export type TranslatedEntryMatchWildcardMatcher = t.TypeOf< + typeof translatedEntryMatchWildcardMatcher +>; + +export const translatedEntryMatchWildcard = t.exact( + t.type({ + field: t.string, + operator, + type: translatedEntryMatchWildcardMatcher, + value: t.string, + }) +); +export type TranslatedEntryMatchWildcard = t.TypeOf<typeof translatedEntryMatchWildcard>; + export const translatedEntryMatch = t.exact( t.type({ field: t.string, @@ -61,6 +79,7 @@ export type TranslatedEntryNested = t.TypeOf<typeof translatedEntryNested>; export const translatedEntry = t.union([ translatedEntryNested, translatedEntryMatch, + translatedEntryMatchWildcard, translatedEntryMatchAny, ]); export type TranslatedEntry = t.TypeOf<typeof translatedEntry>; diff --git a/x-pack/plugins/transform/common/api_schemas/common.ts b/x-pack/plugins/transform/common/api_schemas/common.ts index 3651af69359a9..b84dcd2a4b749 100644 --- a/x-pack/plugins/transform/common/api_schemas/common.ts +++ b/x-pack/plugins/transform/common/api_schemas/common.ts @@ -17,6 +17,7 @@ export const transformIdsSchema = schema.arrayOf( export type TransformIdsSchema = TypeOf<typeof transformIdsSchema>; +// reflects https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/transform/transforms/TransformStats.java#L250 export const transformStateSchema = schema.oneOf([ schema.literal(TRANSFORM_STATE.ABORTING), schema.literal(TRANSFORM_STATE.FAILED), @@ -24,6 +25,7 @@ export const transformStateSchema = schema.oneOf([ schema.literal(TRANSFORM_STATE.STARTED), schema.literal(TRANSFORM_STATE.STOPPED), schema.literal(TRANSFORM_STATE.STOPPING), + schema.literal(TRANSFORM_STATE.WAITING), ]); export const indexPatternTitleSchema = schema.object({ diff --git a/x-pack/plugins/transform/common/constants.ts b/x-pack/plugins/transform/common/constants.ts index ce61f27ef2553..423b2d001381c 100644 --- a/x-pack/plugins/transform/common/constants.ts +++ b/x-pack/plugins/transform/common/constants.ts @@ -77,7 +77,7 @@ export const APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES = [ export const APP_INDEX_PRIVILEGES = ['monitor']; -// reflects https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/dataframe/transforms/DataFrameTransformStats.java#L243 +// reflects https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/transform/transforms/TransformStats.java#L250 export const TRANSFORM_STATE = { ABORTING: 'aborting', FAILED: 'failed', @@ -85,6 +85,7 @@ export const TRANSFORM_STATE = { STARTED: 'started', STOPPED: 'stopped', STOPPING: 'stopping', + WAITING: 'waiting', } as const; const transformStates = Object.values(TRANSFORM_STATE); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx index a8f6a9a233c62..e186acf31d34f 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx @@ -30,6 +30,7 @@ import { TRANSFORM_STATE } from '../../../../../../common/constants'; import { getTransformProgress, TransformListRow, TRANSFORM_LIST_COLUMN } from '../../../../common'; import { useActions } from './use_actions'; +// reflects https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/transform/transforms/TransformStats.java#L250 const STATE_COLOR = { aborting: 'warning', failed: 'danger', @@ -37,6 +38,7 @@ const STATE_COLOR = { started: 'primary', stopped: 'hollow', stopping: 'hollow', + waiting: 'hollow', } as const; export const getTaskStateBadge = ( @@ -202,13 +204,15 @@ export const useColumns = ( {!isBatchTransform && ( <Fragment> <EuiFlexItem style={{ width: '40px' }} grow={false}> - {/* If not stopped or failed show the animated progress bar */} + {/* If not stopped, failed or waiting show the animated progress bar */} {item.stats.state !== TRANSFORM_STATE.STOPPED && + item.stats.state !== TRANSFORM_STATE.WAITING && item.stats.state !== TRANSFORM_STATE.FAILED && ( <EuiProgress color="primary" size="m" /> )} - {/* If stopped or failed show an empty (0%) progress bar */} + {/* If stopped, failed or waiting show an empty (0%) progress bar */} {(item.stats.state === TRANSFORM_STATE.STOPPED || + item.stats.state === TRANSFORM_STATE.WAITING || item.stats.state === TRANSFORM_STATE.FAILED) && ( <EuiProgress value={0} max={100} color="primary" size="m" /> )} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index dfa8bd387cbd7..746af100cb733 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17801,201 +17801,58 @@ "xpack.securitySolution.cases.allCases.actions": "アクション", "xpack.securitySolution.cases.allCases.comments": "コメント", "xpack.securitySolution.cases.allCases.noTagsAvailable": "利用可能なタグがありません", - "xpack.securitySolution.cases.caseModal.title": "ケースを選択", "xpack.securitySolution.cases.caseSavedObjectNoPermissionsMessage": "ケースを表示するには、Kibana スペースで保存されたオブジェクト管理機能の権限が必要です。詳細については、Kibana管理者に連絡してください。", "xpack.securitySolution.cases.caseSavedObjectNoPermissionsTitle": "Kibana機能権限が必要です", - "xpack.securitySolution.cases.caseTable.addNewCase": "新規ケースの追加", - "xpack.securitySolution.cases.caseTable.bulkActions": "一斉アクション", - "xpack.securitySolution.cases.caseTable.bulkActions.closeSelectedTitle": "選択した項目を閉じる", - "xpack.securitySolution.cases.caseTable.bulkActions.deleteSelectedTitle": "選択した項目を削除", - "xpack.securitySolution.cases.caseTable.bulkActions.openSelectedTitle": "選択した項目を再開", "xpack.securitySolution.cases.caseTable.caseDetailsLinkAria": "クリックすると、タイトル{detailName}のケースを表示します", - "xpack.securitySolution.cases.caseTable.closed": "終了", "xpack.securitySolution.cases.caseTable.closedCases": "終了したケース", - "xpack.securitySolution.cases.caseTable.delete": "削除", - "xpack.securitySolution.cases.caseTable.incidentSystem": "インシデント管理システム", "xpack.securitySolution.cases.caseTable.inProgressCases": "進行中のケース", - "xpack.securitySolution.cases.caseTable.noCases.body": "表示するケースがありません。新しいケースを作成するか、または上記のフィルター設定を変更してください。", - "xpack.securitySolution.cases.caseTable.noCases.title": "ケースなし", - "xpack.securitySolution.cases.caseTable.notPushed": "プッシュされません", "xpack.securitySolution.cases.caseTable.openCases": "ケースを開く", - "xpack.securitySolution.cases.caseTable.refreshTitle": "更新", - "xpack.securitySolution.cases.caseTable.requiresUpdate": " 更新が必要", - "xpack.securitySolution.cases.caseTable.searchAriaLabel": "ケースの検索", - "xpack.securitySolution.cases.caseTable.searchPlaceholder": "例:ケース名", - "xpack.securitySolution.cases.caseTable.serviceNowLinkAria": "クリックすると、servicenowでインシデントを表示します", - "xpack.securitySolution.cases.caseTable.snIncident": "外部インシデント", - "xpack.securitySolution.cases.caseTable.status": "ステータス", - "xpack.securitySolution.cases.caseTable.upToDate": " は最新です", - "xpack.securitySolution.cases.caseView.actionHeadline": "{actionDate} の {userName} {actionName}", - "xpack.securitySolution.cases.caseView.actionLabel.addComment": "コメントを追加しました", - "xpack.securitySolution.cases.caseView.actionLabel.addDescription": "説明を追加しました", - "xpack.securitySolution.cases.caseView.actionLabel.addedField": "追加しました", - "xpack.securitySolution.cases.caseView.actionLabel.changededField": "変更しました", - "xpack.securitySolution.cases.caseView.actionLabel.editedField": "編集しました", - "xpack.securitySolution.cases.caseView.actionLabel.on": "日付", - "xpack.securitySolution.cases.caseView.actionLabel.pushedNewIncident": "新しいインシデントとしてプッシュしました", - "xpack.securitySolution.cases.caseView.actionLabel.removedField": "削除しました", - "xpack.securitySolution.cases.caseView.actionLabel.removedThirdParty": "外部のインシデント管理システムを削除しました", - "xpack.securitySolution.cases.caseView.actionLabel.selectedThirdParty": "インシデント管理システムとして{ thirdParty }を選択しました", - "xpack.securitySolution.cases.caseView.actionLabel.updateIncident": "インシデントを更新しました", - "xpack.securitySolution.cases.caseView.actionLabel.viewIncident": "{incidentNumber}を表示", - "xpack.securitySolution.cases.caseView.alertCommentLabelTitle": "アラートを追加しました", - "xpack.securitySolution.cases.caseView.alertRuleDeletedLabelTitle": "アラートを追加しました", - "xpack.securitySolution.cases.caseView.alreadyPushedToExternalService": "すでに{ externalService }インシデントにプッシュしました", - "xpack.securitySolution.cases.caseView.appropiateLicense": "適切なライセンス", "xpack.securitySolution.cases.caseView.backLabel": "ケースに戻る", "xpack.securitySolution.cases.caseView.breadcrumb": "作成", "xpack.securitySolution.cases.caseView.cancel": "キャンセル", - "xpack.securitySolution.cases.caseView.case": "ケース", - "xpack.securitySolution.cases.caseView.caseClosed": "ケースを閉じました", - "xpack.securitySolution.cases.caseView.caseInProgress": "進行中のケース", "xpack.securitySolution.cases.caseView.caseName": "ケース名", - "xpack.securitySolution.cases.caseView.caseOpened": "ケースを開きました", - "xpack.securitySolution.cases.caseView.caseRefresh": "ケースを更新", "xpack.securitySolution.cases.caseView.closeCase": "ケースを閉じる", "xpack.securitySolution.cases.caseView.closedOn": "終了日", - "xpack.securitySolution.cases.caseView.cloudDeploymentLink": "クラウド展開", - "xpack.securitySolution.cases.caseView.comment": "コメント", "xpack.securitySolution.cases.caseView.comment.addComment": "コメントを追加", "xpack.securitySolution.cases.caseView.comment.addCommentHelpText": "新しいコメントを追加...", "xpack.securitySolution.cases.caseView.commentFieldRequiredError": "コメントが必要です。", - "xpack.securitySolution.cases.caseView.connectorConfigureLink": "コネクター", "xpack.securitySolution.cases.caseView.connectors": "外部インシデント管理システム", - "xpack.securitySolution.cases.caseView.copyCommentLinkAria": "参照リンクをコピー", "xpack.securitySolution.cases.caseView.create": "新規ケースを作成", "xpack.securitySolution.cases.caseView.createCase": "ケースを作成", "xpack.securitySolution.cases.caseView.description": "説明", "xpack.securitySolution.cases.caseView.description.save": "保存", "xpack.securitySolution.cases.caseView.edit": "編集", - "xpack.securitySolution.cases.caseView.edit.comment": "コメントを編集", - "xpack.securitySolution.cases.caseView.edit.description": "説明を編集", - "xpack.securitySolution.cases.caseView.edit.quote": "お客様の声", - "xpack.securitySolution.cases.caseView.editActionsLinkAria": "クリックすると、すべてのアクションを表示します", "xpack.securitySolution.cases.caseView.editConnector": "外部インシデント管理システムを変更", - "xpack.securitySolution.cases.caseView.editTagsLinkAria": "クリックすると、タグを編集します", - "xpack.securitySolution.cases.caseView.emailBody": "ケースリファレンス:{caseUrl}", - "xpack.securitySolution.cases.caseView.emailSubject": "セキュリティケース - {caseTitle}", - "xpack.securitySolution.cases.caseView.errorsPushServiceCallOutTitle": "ケースを外部システムにプッシュするには、以下が必要です。", - "xpack.securitySolution.cases.caseView.fieldChanged": "変更されたコネクターフィールド", "xpack.securitySolution.cases.caseView.fieldRequiredError": "必須フィールド", - "xpack.securitySolution.cases.caseView.generatedAlertCommentLabelTitle": "から追加されました", "xpack.securitySolution.cases.caseView.goToDocumentationButton": "ドキュメンテーションを表示", "xpack.securitySolution.cases.caseView.markedCaseAs": "ケースを設定", "xpack.securitySolution.cases.caseView.markInProgress": "実行中に設定", - "xpack.securitySolution.cases.caseView.moveToCommentAria": "参照されたコメントをハイライト", "xpack.securitySolution.cases.caseView.name": "名前", "xpack.securitySolution.cases.caseView.noReportersAvailable": "利用可能なレポートがありません。", "xpack.securitySolution.cases.caseView.noTags": "現在、このケースにタグは割り当てられていません。", "xpack.securitySolution.cases.caseView.openedOn": "開始日", "xpack.securitySolution.cases.caseView.optional": "オプション", "xpack.securitySolution.cases.caseView.particpantsLabel": "参加者", - "xpack.securitySolution.cases.caseView.pushNamedIncident": "{ thirdParty }インシデントとしてプッシュ", - "xpack.securitySolution.cases.caseView.pushThirdPartyIncident": "外部インシデントとしてプッシュ", - "xpack.securitySolution.cases.caseView.pushToServiceDisableBecauseCaseClosedDescription": "終了したケースは外部システムに送信できません。外部システムでケースを開始または更新したい場合にはケースを再開します。", - "xpack.securitySolution.cases.caseView.pushToServiceDisableBecauseCaseClosedTitle": "ケースを再開する", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByConfigDescription": "kibana.ymlファイルは、特定のコネクターのみを許可するように構成されています。外部システムでケースを開けるようにするには、xpack.actions.enabledActiontypes設定に.[actionTypeId] (例:.servicenow | .jira) を追加します。詳細は{link}をご覧ください。", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByConfigTitle": "Kibanaの構成ファイルで外部サービスを有効にする", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByInvalidConnector": "外部サービスに更新を送信するために使用されるコネクターが削除されました。外部システムでケースを更新するには、別のコネクターを選択するか、新しいコネクターを作成してください。", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByLicenseDescription": "{appropriateLicense}があるか、{cloud}を使用しているか、無償試用版をテストしているときには、外部システムでケースを開くことができます。", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByLicenseTitle": "適切なライセンスにアップグレード", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByNoCaseConfigDescription": "外部システムでケースを開いて更新するには、このケースの外部インシデント管理システムを選択する必要があります。", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByNoCaseConfigTitle": "外部コネクターを選択", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByNoConfigTitle": "外部コネクターを構成", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByNoConnectors": "外部システムでケースを開いて更新するには、{link}を設定する必要があります。", "xpack.securitySolution.cases.caseView.reopenCase": "ケースを再開", "xpack.securitySolution.cases.caseView.reporterLabel": "報告者", - "xpack.securitySolution.cases.caseView.requiredUpdateToExternalService": "{ externalService }インシデントの更新が必要です", "xpack.securitySolution.cases.caseView.sendAlertToTimelineTooltip": "タイムラインで調査", - "xpack.securitySolution.cases.caseView.sendEmalLinkAria": "クリックすると、{user}に電子メールを送信します", - "xpack.securitySolution.cases.caseView.showAlertTooltip": "アラートの詳細を表示", - "xpack.securitySolution.cases.caseView.statusLabel": "ステータス", - "xpack.securitySolution.cases.caseView.syncAlertsLabel": "アラートの同期", "xpack.securitySolution.cases.caseView.tags": "タグ", "xpack.securitySolution.cases.caseView.to": "に", "xpack.securitySolution.cases.caseView.unknown": "不明", - "xpack.securitySolution.cases.caseView.unknownRule.label": "不明なルール", - "xpack.securitySolution.cases.caseView.updateNamedIncident": "{ thirdParty }インシデントを更新", - "xpack.securitySolution.cases.caseView.updateThirdPartyIncident": "外部インシデントを更新", "xpack.securitySolution.cases.common.noConnector": "コネクターを選択していません", - "xpack.securitySolution.cases.components.connectors.cases.actionTypeTitle": "ケース", - "xpack.securitySolution.cases.components.connectors.cases.addNewCaseOption": "新規ケースの追加", - "xpack.securitySolution.cases.components.connectors.cases.caseRequired": "ケースの選択が必要です。", - "xpack.securitySolution.cases.components.connectors.cases.casesDropdownPlaceholder": "ケースを選択", - "xpack.securitySolution.cases.components.connectors.cases.casesDropdownRowLabel": "ケース", - "xpack.securitySolution.cases.components.connectors.cases.commentLabel": "コメント", - "xpack.securitySolution.cases.components.connectors.cases.commentRequired": "コメントが必要です。", - "xpack.securitySolution.cases.components.connectors.cases.connectedCaseLabel": "接続されたケース", - "xpack.securitySolution.cases.components.connectors.cases.createCaseLabel": "ケースを作成", - "xpack.securitySolution.cases.components.connectors.cases.optionAddNewCase": "新しいケースに追加", - "xpack.securitySolution.cases.components.connectors.cases.optionAddToExistingCase": "既存のケースに追加", - "xpack.securitySolution.cases.components.connectors.cases.selectMessageText": "ケースを作成または更新します。", - "xpack.securitySolution.cases.configure.errorGetFields": "サービスからのフィールドの取得中にエラーが発生しました", - "xpack.securitySolution.cases.configure.successSaveToast": "保存された外部接続設定", - "xpack.securitySolution.cases.configureCases.addNewConnector": "新しいコネクターを追加", - "xpack.securitySolution.cases.configureCases.blankMappings": "1 つ以上のフィールドを { connectorName } にマッピングする必要があります", - "xpack.securitySolution.cases.configureCases.cancelButton": "キャンセル", - "xpack.securitySolution.cases.configureCases.caseClosureOptionsClosedIncident": "新しいインシデントが外部システムで閉じたときにセキュリティケースを自動的に閉じる", - "xpack.securitySolution.cases.configureCases.caseClosureOptionsDesc": "セキュリティケースの終了のしかたを定義します。自動ケース終了のためには、外部のインシデント管理システムへの接続を確立する必要がいります。", - "xpack.securitySolution.cases.configureCases.caseClosureOptionsLabel": "ケース終了オプション", - "xpack.securitySolution.cases.configureCases.caseClosureOptionsManual": "セキュリティケースを手動で閉じる", - "xpack.securitySolution.cases.configureCases.caseClosureOptionsNewIncident": "新しいインシデントを外部システムにプッシュするときにセキュリティケースを自動的に閉じる", - "xpack.securitySolution.cases.configureCases.caseClosureOptionsTitle": "ケースのクローズ", - "xpack.securitySolution.cases.configureCases.commentMapping": "コメント", - "xpack.securitySolution.cases.configureCases.editFieldMappingTitle": "{ thirdPartyName } フィールドマッピングを編集", - "xpack.securitySolution.cases.configureCases.fieldMappingDesc": "データを { thirdPartyName } にプッシュするときに、セキュリティケースフィールドを { thirdPartyName } フィールドにマッピングします。フィールドマッピングでは、{ thirdPartyName } への接続を確立する必要があります。", - "xpack.securitySolution.cases.configureCases.fieldMappingDescErr": "フィールドマッピングでは、{ thirdPartyName } への接続を確立する必要があります。接続資格情報を確認してください。", - "xpack.securitySolution.cases.configureCases.fieldMappingEditAppend": "末尾に追加", - "xpack.securitySolution.cases.configureCases.fieldMappingEditNothing": "何もしない", - "xpack.securitySolution.cases.configureCases.fieldMappingEditOverwrite": "上書き", - "xpack.securitySolution.cases.configureCases.fieldMappingFirstCol": "セキュリティケースフィールド", - "xpack.securitySolution.cases.configureCases.fieldMappingSecondCol": "{ thirdPartyName } フィールド", - "xpack.securitySolution.cases.configureCases.fieldMappingThirdCol": "編集時と更新時", - "xpack.securitySolution.cases.configureCases.fieldMappingTitle": "{ thirdPartyName } フィールドマッピング", "xpack.securitySolution.cases.configureCases.headerTitle": "ケースを構成", - "xpack.securitySolution.cases.configureCases.incidentManagementSystemDesc": "オプションとして、セキュリティケースを選択した外部のインシデント管理システムに接続できます。そうすると、選択したサードパーティシステム内でケースデータをインシデントとしてプッシュできます。", - "xpack.securitySolution.cases.configureCases.incidentManagementSystemLabel": "インシデント管理システム", - "xpack.securitySolution.cases.configureCases.incidentManagementSystemTitle": "外部のインシデント管理システムに接続", - "xpack.securitySolution.cases.configureCases.mappingFieldNotMapped": "マップされません", - "xpack.securitySolution.cases.configureCases.noFieldsError": "{ connectorName } フィールドが見つかりません。解決する { connectorName } コネクター設定または { connectorName } インスタンス設定を確認してください。", - "xpack.securitySolution.cases.configureCases.requiredMappings": "1 つ以上のケースフィールドを次の { connectorName } フィールドにマッピングする必要があります:{ fields }", - "xpack.securitySolution.cases.configureCases.saveAndCloseButton": "保存して閉じる", - "xpack.securitySolution.cases.configureCases.saveButton": "保存", - "xpack.securitySolution.cases.configureCases.updateConnector": "フィールドマッピングを更新", - "xpack.securitySolution.cases.configureCases.updateSelectedConnector": "{ connectorName }を更新", - "xpack.securitySolution.cases.configureCases.warningMessage": "選択したコネクターが削除されました。別のコネクターを選択するか、新しいコネクターを作成してください。", - "xpack.securitySolution.cases.configureCases.warningTitle": "警告", "xpack.securitySolution.cases.configureCasesButton": "外部接続を編集", - "xpack.securitySolution.cases.confirmDeleteCase.confirmQuestion": "このケースを削除すると、関連するすべてのケースデータが完全に削除され、外部インシデント管理システムにデータをプッシュできなくなります。続行していいですか?", - "xpack.securitySolution.cases.confirmDeleteCase.confirmQuestionPlural": "これらのケースを削除すると、関連するすべてのケースデータが完全に削除され、外部インシデント管理システムにデータをプッシュできなくなります。続行していいですか?", "xpack.securitySolution.cases.confirmDeleteCase.deleteCase": "ケースを削除", "xpack.securitySolution.cases.confirmDeleteCase.deleteCases": "ケースを削除", - "xpack.securitySolution.cases.confirmDeleteCase.deleteThisCase": "このケースを削除", - "xpack.securitySolution.cases.confirmDeleteCase.deleteTitle": "「{caseTitle}」を削除", - "xpack.securitySolution.cases.confirmDeleteCase.selectedCases": "選択したケースを削除", - "xpack.securitySolution.cases.connectors.jira.issueTypesSelectFieldLabel": "問題タイプ", - "xpack.securitySolution.cases.connectors.jira.parentIssueSearchLabel": "親問題", - "xpack.securitySolution.cases.connectors.jira.prioritySelectFieldLabel": "優先度", - "xpack.securitySolution.cases.connectors.resilient.incidentTypesLabel": "インシデントタイプ", - "xpack.securitySolution.cases.connectors.resilient.incidentTypesPlaceholder": "タイプを選択", - "xpack.securitySolution.cases.connectors.resilient.severityLabel": "深刻度", - "xpack.securitySolution.cases.connectors.resilient.unableToGetIncidentTypesMessage": "インシデントタイプを取得できません", - "xpack.securitySolution.cases.connectors.resilient.unableToGetSeverityMessage": "深刻度を取得できません", - "xpack.securitySolution.cases.containers.statusChangeToasterText": "このケースのアラートはステータスが更新されました", "xpack.securitySolution.cases.createCase.descriptionFieldRequiredError": "説明が必要です。", "xpack.securitySolution.cases.createCase.fieldTagsHelpText": "このケースの1つ以上のカスタム識別タグを入力します。新しいタグを開始するには、各タグの後でEnterを押します。", "xpack.securitySolution.cases.createCase.titleFieldRequiredError": "タイトルが必要です。", "xpack.securitySolution.cases.dismissErrorsPushServiceCallOutTitle": "閉じる", - "xpack.securitySolution.cases.editConnector.editConnectorLinkAria": "クリックしてコネクターを編集", "xpack.securitySolution.cases.pageTitle": "ケース", "xpack.securitySolution.cases.readOnlySavedObjectDescription": "ケースを表示する権限のみが付与されています。ケースを開いて更新する必要がある場合は、Kibana管理者に連絡してください。", "xpack.securitySolution.cases.readOnlySavedObjectTitle": "新しいケースを開いたり、既存のケースを更新したりすることはできません", "xpack.securitySolution.cases.settings.syncAlertsSwitchLabelOff": "オフ", "xpack.securitySolution.cases.settings.syncAlertsSwitchLabelOn": "オン", - "xpack.securitySolution.cases.status.closed": "終了", - "xpack.securitySolution.cases.status.iconAria": "ステータスの変更", - "xpack.securitySolution.cases.status.inProgress": "進行中", - "xpack.securitySolution.cases.status.open": "開く", "xpack.securitySolution.cases.timeline.actions.addCase": "ケースに追加", "xpack.securitySolution.cases.timeline.actions.addExistingCase": "既存のケースに追加", "xpack.securitySolution.cases.timeline.actions.addNewCase": "新しいケースに追加", @@ -18004,8 +17861,6 @@ "xpack.securitySolution.cases.timeline.actions.caseCreatedSuccessToast": "アラートが「{title}」に追加されました", "xpack.securitySolution.cases.timeline.actions.caseCreatedSuccessToastText": "このケースのアラートはステータスがケースステータスと同期されました", "xpack.securitySolution.cases.timeline.actions.caseCreatedSuccessToastViewCaseLink": "ケースの表示", - "xpack.securitySolution.caseConnectorsRegistry.get.missingCaseConnectorErrorMessage": "オブジェクトタイプ「{id}」は登録されていません。", - "xpack.securitySolution.caseConnectorsRegistry.register.duplicateCaseConnectorErrorMessage": "オブジェクトタイプ「{id}」はすでに登録されています。", "xpack.securitySolution.certificate.fingerprint.clientCertLabel": "クライアント証明書", "xpack.securitySolution.certificate.fingerprint.serverCertLabel": "サーバー証明書", "xpack.securitySolution.chart.allOthersGroupingLabel": "その他すべて", @@ -18020,31 +17875,7 @@ "xpack.securitySolution.clipboard.to.the.clipboard": "クリップボードに", "xpack.securitySolution.common.alertAddedToCase": "ケースに追加", "xpack.securitySolution.common.alertLabel": "アラート", - "xpack.securitySolution.components.connectors.jira.searchIssuesComboBoxAriaLabel": "入力して検索", - "xpack.securitySolution.components.connectors.jira.searchIssuesComboBoxPlaceholder": "入力して検索", - "xpack.securitySolution.components.connectors.jira.searchIssuesLoading": "読み込み中...", - "xpack.securitySolution.components.connectors.jira.unableToGetFieldsMessage": "コネクターを取得できません", - "xpack.securitySolution.components.connectors.jira.unableToGetIssueMessage": "ID {id}の問題を取得できません", - "xpack.securitySolution.components.connectors.jira.unableToGetIssuesMessage": "問題を取得できません", - "xpack.securitySolution.components.connectors.jira.unableToGetIssueTypesMessage": "問題タイプを取得できません", - "xpack.securitySolution.components.connectors.serviceNow.alertFieldEnabledText": "はい", - "xpack.securitySolution.components.connectors.serviceNow.alertFieldsTitle": "アラートに関連付けられたフィールド", - "xpack.securitySolution.components.connectors.serviceNow.categoryTitle": "カテゴリー", - "xpack.securitySolution.components.connectors.serviceNow.destinationIPTitle": "デスティネーション IP", - "xpack.securitySolution.components.connectors.serviceNow.impactSelectFieldLabel": "インパクト", - "xpack.securitySolution.components.connectors.serviceNow.malwareHashTitle": "マルウェアハッシュ", - "xpack.securitySolution.components.connectors.serviceNow.malwareURLTitle": "マルウェアURL", - "xpack.securitySolution.components.connectors.serviceNow.prioritySelectFieldTitle": "優先度", - "xpack.securitySolution.components.connectors.serviceNow.severitySelectFieldLabel": "深刻度", - "xpack.securitySolution.components.connectors.serviceNow.sourceIPTitle": "ソース IP", - "xpack.securitySolution.components.connectors.serviceNow.subcategoryTitle": "サブカテゴリ", - "xpack.securitySolution.components.connectors.serviceNow.unableToGetChoicesMessage": "選択肢を取得できません", - "xpack.securitySolution.components.connectors.serviceNow.urgencySelectFieldLabel": "緊急", - "xpack.securitySolution.components.create.stepOneTitle": "ケースフィールド", - "xpack.securitySolution.components.create.stepThreeTitle": "外部コネクターフィールド", - "xpack.securitySolution.components.create.stepTwoTitle": "ケース設定", "xpack.securitySolution.components.create.syncAlertHelpText": "このオプションを有効にすると、このケースのアラートのステータスをケースステータスと同期します。", - "xpack.securitySolution.components.create.syncAlertsLabel": "アラートステータスをケースステータスと同期", "xpack.securitySolution.components.embeddables.embeddedMap.clientLayerLabel": "クライアントポイント", "xpack.securitySolution.components.embeddables.embeddedMap.destinationLayerLabel": "デスティネーションポイント", "xpack.securitySolution.components.embeddables.embeddedMap.embeddableHeaderHelp": "マップ構成ヘルプ", @@ -18116,14 +17947,6 @@ "xpack.securitySolution.containers.anomalies.errorFetchingAnomaliesData": "異常データをクエリできませんでした", "xpack.securitySolution.containers.anomalies.stackByJobId": "ジョブ", "xpack.securitySolution.containers.anomalies.title": "異常", - "xpack.securitySolution.containers.cases.closedCases": "{totalCases, plural, =1 {\"{caseTitle}\"} other {{totalCases}件のケース}}をクローズしました", - "xpack.securitySolution.containers.cases.deletedCases": "{totalCases, plural, =1 {\"{caseTitle}\"} other {{totalCases}件のケース}}を削除しました", - "xpack.securitySolution.containers.cases.errorDeletingTitle": "データの削除エラー", - "xpack.securitySolution.containers.cases.errorTitle": "データの取得中にエラーが発生", - "xpack.securitySolution.containers.cases.pushToExternalService": "{ serviceName }への送信が正常に完了しました", - "xpack.securitySolution.containers.cases.reopenedCases": "{totalCases, plural, =1 {\"{caseTitle}\"} other {{totalCases}件のケース}}を再オープンしました", - "xpack.securitySolution.containers.cases.syncCase": "\"{caseTitle}\"のアラートが同期されました", - "xpack.securitySolution.containers.cases.updatedCase": "\"{caseTitle}\"を更新しました", "xpack.securitySolution.containers.detectionEngine.addRuleFailDescription": "ルールを追加できませんでした", "xpack.securitySolution.containers.detectionEngine.alerts.createListsIndex.errorDescription": "リストインデックスを作成できませんでした", "xpack.securitySolution.containers.detectionEngine.alerts.errorFetchingAlertsDescription": "アラートをクエリできませんでした", @@ -19908,7 +19731,6 @@ "xpack.securitySolution.overview.hostStatGroupFilebeat": "Filebeat", "xpack.securitySolution.overview.hostStatGroupWinlogbeat": "Winlogbeat", "xpack.securitySolution.overview.hostsTitle": "ホストイベント", - "xpack.securitySolution.overview.myRecentlyReportedCasesButtonLabel": "最近レポートしたケース", "xpack.securitySolution.overview.networkAction": "ネットワークを表示", "xpack.securitySolution.overview.networkStatGroupAuditbeat": "Auditbeat", "xpack.securitySolution.overview.networkStatGroupFilebeat": "Filebeat", @@ -19921,7 +19743,6 @@ "xpack.securitySolution.overview.pageSubtitle": "Elastic Stackによるセキュリティ情報とイベント管理", "xpack.securitySolution.overview.pageTitle": "セキュリティ", "xpack.securitySolution.overview.recentCasesSidebarTitle": "最近のケース", - "xpack.securitySolution.overview.recentlyCreatedCasesButtonLabel": "最近作成したケース", "xpack.securitySolution.overview.recentTimelinesSidebarTitle": "最近のタイムライン", "xpack.securitySolution.overview.showTopTooltip": "上位の{fieldName}を表示", "xpack.securitySolution.overview.signalCountTitle": "検出アラート傾向", @@ -19955,11 +19776,6 @@ "xpack.securitySolution.policyStatusText.success": "成功", "xpack.securitySolution.policyStatusText.unsupported": "サポートされていない", "xpack.securitySolution.policyStatusText.warning": "警告", - "xpack.securitySolution.recentCases.commentsTooltip": "コメント", - "xpack.securitySolution.recentCases.controlLegend": "ケースフィルター", - "xpack.securitySolution.recentCases.noCasesMessage": "まだケースを作成していません。準備して", - "xpack.securitySolution.recentCases.startNewCaseLink": "新しいケースの開始", - "xpack.securitySolution.recentCases.viewAllCasesLink": "すべてのケースを表示", "xpack.securitySolution.recentTimelines.errorRetrievingUserDetailsMessage": "最近のタイムライン:ユーザー詳細の取得中にエラーが発生しました", "xpack.securitySolution.recentTimelines.favoritesButtonLabel": "お気に入り", "xpack.securitySolution.recentTimelines.filterControlLegend": "タイムラインフィルター", @@ -20302,7 +20118,6 @@ "xpack.securitySolution.topN.closeButtonLabel": "閉じる", "xpack.securitySolution.topN.rawEventsSelectLabel": "未加工イベント", "xpack.securitySolution.trustedapps.aboutInfo": "パフォーマンスを改善したり、ホストで実行されている他のアプリケーションとの競合を解消したりするには、信頼できるアプリケーションを追加します。信頼できるアプリケーションは、Endpoint Securityを実行しているホストに適用されます。", - "xpack.securitySolution.trustedapps.card.operator.includes": "is", "xpack.securitySolution.trustedapps.card.removeButtonLabel": "削除", "xpack.securitySolution.trustedapps.create.conditionFieldValueRequiredMsg": "[{row}] フィールドエントリには値が必要です", "xpack.securitySolution.trustedapps.create.conditionRequiredMsg": "1つ以上のフィールド定義が必要です", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ce47b76c71949..163d9af5eeafb 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -18055,205 +18055,58 @@ "xpack.securitySolution.cases.allCases.actions": "操作", "xpack.securitySolution.cases.allCases.comments": "注释", "xpack.securitySolution.cases.allCases.noTagsAvailable": "没有可用标签", - "xpack.securitySolution.cases.caseModal.title": "选择案例", "xpack.securitySolution.cases.caseSavedObjectNoPermissionsMessage": "要查看案例,必须对 Kibana 工作区中的已保存对象管理功能有权限。有关详细信息,请联系您的 Kibana 管理员。", "xpack.securitySolution.cases.caseSavedObjectNoPermissionsTitle": "需要 Kibana 功能权限", - "xpack.securitySolution.cases.caseTable.addNewCase": "添加新案例", - "xpack.securitySolution.cases.caseTable.bulkActions": "批处理操作", - "xpack.securitySolution.cases.caseTable.bulkActions.closeSelectedTitle": "关闭所选", - "xpack.securitySolution.cases.caseTable.bulkActions.deleteSelectedTitle": "删除所选", - "xpack.securitySolution.cases.caseTable.bulkActions.openSelectedTitle": "重新打开所选", "xpack.securitySolution.cases.caseTable.caseDetailsLinkAria": "单击以访问标题为 {detailName} 的案例", - "xpack.securitySolution.cases.caseTable.closed": "已关闭", "xpack.securitySolution.cases.caseTable.closedCases": "已关闭案例", - "xpack.securitySolution.cases.caseTable.delete": "删除", - "xpack.securitySolution.cases.caseTable.incidentSystem": "事件管理系统", "xpack.securitySolution.cases.caseTable.inProgressCases": "进行中的案例", - "xpack.securitySolution.cases.caseTable.noCases.body": "没有可显示的案例。请创建新案例或在上面更改您的筛选设置。", - "xpack.securitySolution.cases.caseTable.noCases.title": "无案例", - "xpack.securitySolution.cases.caseTable.notPushed": "未推送", "xpack.securitySolution.cases.caseTable.openCases": "未结案例", - "xpack.securitySolution.cases.caseTable.refreshTitle": "刷新", - "xpack.securitySolution.cases.caseTable.requiresUpdate": " 需要更新", - "xpack.securitySolution.cases.caseTable.searchAriaLabel": "搜索案例", - "xpack.securitySolution.cases.caseTable.searchPlaceholder": "例如案例名", - "xpack.securitySolution.cases.caseTable.selectedCasesTitle": "已选择 {totalRules} 个{totalRules, plural, other {案例}}", - "xpack.securitySolution.cases.caseTable.serviceNowLinkAria": "单击可在 servicenow 上查看该事件", - "xpack.securitySolution.cases.caseTable.showingCasesTitle": "正在显示 {totalRules} 个{totalRules, plural, other {案例}}", - "xpack.securitySolution.cases.caseTable.snIncident": "外部事件", - "xpack.securitySolution.cases.caseTable.status": "状态", - "xpack.securitySolution.cases.caseTable.unit": "{totalCount, plural, other {案例}}", - "xpack.securitySolution.cases.caseTable.upToDate": " 是最新的", - "xpack.securitySolution.cases.caseView.actionHeadline": "{userName} 在 {actionDate}{actionName}", - "xpack.securitySolution.cases.caseView.actionLabel.addComment": "添加了注释", - "xpack.securitySolution.cases.caseView.actionLabel.addDescription": "添加了描述", - "xpack.securitySolution.cases.caseView.actionLabel.addedField": "添加了", - "xpack.securitySolution.cases.caseView.actionLabel.changededField": "更改了", - "xpack.securitySolution.cases.caseView.actionLabel.editedField": "编辑了", - "xpack.securitySolution.cases.caseView.actionLabel.on": "在", - "xpack.securitySolution.cases.caseView.actionLabel.pushedNewIncident": "已推送为新事件", - "xpack.securitySolution.cases.caseView.actionLabel.removedField": "移除了", - "xpack.securitySolution.cases.caseView.actionLabel.removedThirdParty": "已移除外部事件管理系统", - "xpack.securitySolution.cases.caseView.actionLabel.selectedThirdParty": "已选择 { thirdParty } 作为事件管理系统", - "xpack.securitySolution.cases.caseView.actionLabel.updateIncident": "更新了事件", - "xpack.securitySolution.cases.caseView.actionLabel.viewIncident": "查看 {incidentNumber}", - "xpack.securitySolution.cases.caseView.alertCommentLabelTitle": "添加了告警,从", - "xpack.securitySolution.cases.caseView.alertRuleDeletedLabelTitle": "添加了告警", - "xpack.securitySolution.cases.caseView.alreadyPushedToExternalService": "已推送到 { externalService } 事件", - "xpack.securitySolution.cases.caseView.appropiateLicense": "适当的许可证", "xpack.securitySolution.cases.caseView.backLabel": "返回到案例", "xpack.securitySolution.cases.caseView.breadcrumb": "创建", "xpack.securitySolution.cases.caseView.cancel": "取消", - "xpack.securitySolution.cases.caseView.case": "案例", - "xpack.securitySolution.cases.caseView.caseClosed": "案例已关闭", - "xpack.securitySolution.cases.caseView.caseInProgress": "案例进行中", "xpack.securitySolution.cases.caseView.caseName": "案例名称", - "xpack.securitySolution.cases.caseView.caseOpened": "案例已打开", - "xpack.securitySolution.cases.caseView.caseRefresh": "刷新案例", "xpack.securitySolution.cases.caseView.closeCase": "关闭案例", "xpack.securitySolution.cases.caseView.closedOn": "关闭日期", - "xpack.securitySolution.cases.caseView.cloudDeploymentLink": "云部署", - "xpack.securitySolution.cases.caseView.comment": "注释", "xpack.securitySolution.cases.caseView.comment.addComment": "添加注释", "xpack.securitySolution.cases.caseView.comment.addCommentHelpText": "添加新注释......", "xpack.securitySolution.cases.caseView.commentFieldRequiredError": "注释必填。", - "xpack.securitySolution.cases.caseView.connectorConfigureLink": "连接器", "xpack.securitySolution.cases.caseView.connectors": "外部事件管理系统", - "xpack.securitySolution.cases.caseView.copyCommentLinkAria": "复制引用链接", "xpack.securitySolution.cases.caseView.create": "创建新案例", "xpack.securitySolution.cases.caseView.createCase": "创建案例", "xpack.securitySolution.cases.caseView.description": "描述", "xpack.securitySolution.cases.caseView.description.save": "保存", "xpack.securitySolution.cases.caseView.edit": "编辑", - "xpack.securitySolution.cases.caseView.edit.comment": "编辑注释", - "xpack.securitySolution.cases.caseView.edit.description": "编辑描述", - "xpack.securitySolution.cases.caseView.edit.quote": "引述", - "xpack.securitySolution.cases.caseView.editActionsLinkAria": "单击可查看所有操作", "xpack.securitySolution.cases.caseView.editConnector": "更改外部事件管理系统", - "xpack.securitySolution.cases.caseView.editTagsLinkAria": "单击可编辑标签", - "xpack.securitySolution.cases.caseView.emailBody": "案例参考:{caseUrl}", - "xpack.securitySolution.cases.caseView.emailSubject": "Security 案例 - {caseTitle}", - "xpack.securitySolution.cases.caseView.errorsPushServiceCallOutTitle": "要将案例发送到外部系统,您需要:", - "xpack.securitySolution.cases.caseView.fieldChanged": "已更改连接器字段", "xpack.securitySolution.cases.caseView.fieldRequiredError": "必填字段", - "xpack.securitySolution.cases.caseView.generatedAlertCommentLabelTitle": "添加自", - "xpack.securitySolution.cases.caseView.generatedAlertCountCommentLabelTitle": "{totalCount} 个{totalCount, plural, other {告警}}", "xpack.securitySolution.cases.caseView.goToDocumentationButton": "查看文档", "xpack.securitySolution.cases.caseView.markedCaseAs": "将案例标记为", "xpack.securitySolution.cases.caseView.markInProgress": "标记为进行中", - "xpack.securitySolution.cases.caseView.moveToCommentAria": "高亮显示引用的注释", "xpack.securitySolution.cases.caseView.name": "名称", "xpack.securitySolution.cases.caseView.noReportersAvailable": "没有报告者。", "xpack.securitySolution.cases.caseView.noTags": "当前没有为此案例分配标签。", "xpack.securitySolution.cases.caseView.openedOn": "打开时间", "xpack.securitySolution.cases.caseView.optional": "可选", "xpack.securitySolution.cases.caseView.particpantsLabel": "参与者", - "xpack.securitySolution.cases.caseView.pushNamedIncident": "推送为 { thirdParty } 事件", - "xpack.securitySolution.cases.caseView.pushThirdPartyIncident": "推送为外部事件", - "xpack.securitySolution.cases.caseView.pushToServiceDisableBecauseCaseClosedDescription": "关闭的案例无法发送到外部系统。如果希望在外部系统中打开或更新案例,请重新打开案例。", - "xpack.securitySolution.cases.caseView.pushToServiceDisableBecauseCaseClosedTitle": "重新打开案例", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByConfigDescription": "kibana.yml 文件已配置为仅允许特定连接器。要在外部系统中打开案例,请将 .[actionTypeId] (例如:.servicenow | .jira) 添加到 xpack.actions.enabledActiontypes 设置。有关更多信息,请参阅{link}。", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByConfigTitle": "在 Kibana 配置文件中启用外部服务", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByInvalidConnector": "用于将更新发送到外部服务的连接器已删除。要在外部系统中更新案例,请选择不同的连接器或创建新的连接器。", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByLicenseDescription": "有{appropriateLicense}、正使用{cloud}或正在免费试用时,可在外部系统中创建案例。", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByLicenseTitle": "升级适当的许可", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByNoCaseConfigDescription": "要在外部系统中打开和更新案例,必须为此案例选择外部事件管理系统。", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByNoCaseConfigTitle": "选择外部连接器", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByNoConfigTitle": "配置外部连接器", - "xpack.securitySolution.cases.caseView.pushToServiceDisableByNoConnectors": "要在外部系统上打开和更新案例,必须配置{link}。", "xpack.securitySolution.cases.caseView.reopenCase": "重新打开案例", "xpack.securitySolution.cases.caseView.reporterLabel": "报告者", - "xpack.securitySolution.cases.caseView.requiredUpdateToExternalService": "需要更新 { externalService } 事件", "xpack.securitySolution.cases.caseView.sendAlertToTimelineTooltip": "在时间线中调查", - "xpack.securitySolution.cases.caseView.sendEmalLinkAria": "单击可向 {user} 发送电子邮件", - "xpack.securitySolution.cases.caseView.showAlertTooltip": "显示告警详情", - "xpack.securitySolution.cases.caseView.statusLabel": "状态", - "xpack.securitySolution.cases.caseView.syncAlertsLabel": "同步告警", "xpack.securitySolution.cases.caseView.tags": "标签", "xpack.securitySolution.cases.caseView.to": "到", "xpack.securitySolution.cases.caseView.unknown": "未知", - "xpack.securitySolution.cases.caseView.unknownRule.label": "未知规则", - "xpack.securitySolution.cases.caseView.updateNamedIncident": "更新 { thirdParty } 事件", - "xpack.securitySolution.cases.caseView.updateThirdPartyIncident": "更新外部事件", "xpack.securitySolution.cases.common.noConnector": "未选择任何连接器", - "xpack.securitySolution.cases.components.connectors.cases.actionTypeTitle": "案例", - "xpack.securitySolution.cases.components.connectors.cases.addNewCaseOption": "添加新案例", - "xpack.securitySolution.cases.components.connectors.cases.caseRequired": "必须选择策略。", - "xpack.securitySolution.cases.components.connectors.cases.casesDropdownPlaceholder": "选择案例", - "xpack.securitySolution.cases.components.connectors.cases.casesDropdownRowLabel": "案例", - "xpack.securitySolution.cases.components.connectors.cases.commentLabel": "注释", - "xpack.securitySolution.cases.components.connectors.cases.commentRequired": "“注释”必填。", - "xpack.securitySolution.cases.components.connectors.cases.connectedCaseLabel": "已连接案例", - "xpack.securitySolution.cases.components.connectors.cases.createCaseLabel": "创建案例", - "xpack.securitySolution.cases.components.connectors.cases.optionAddNewCase": "添加到新案例", - "xpack.securitySolution.cases.components.connectors.cases.optionAddToExistingCase": "添加到现有案例", - "xpack.securitySolution.cases.components.connectors.cases.selectMessageText": "创建或更新案例。", - "xpack.securitySolution.cases.configure.errorGetFields": "从服务中获取字段时出错", - "xpack.securitySolution.cases.configure.successSaveToast": "已保存外部连接设置", - "xpack.securitySolution.cases.configureCases.addNewConnector": "添加新连接器", - "xpack.securitySolution.cases.configureCases.blankMappings": "至少一个字段需映射到 { connectorName }", - "xpack.securitySolution.cases.configureCases.cancelButton": "取消", - "xpack.securitySolution.cases.configureCases.caseClosureOptionsClosedIncident": "在外部系统中关闭事件时自动关闭 Security 案例", - "xpack.securitySolution.cases.configureCases.caseClosureOptionsDesc": "定义关闭 Security 案例的方式。要自动关闭案例,需要与外部事件管理系统建立连接。", - "xpack.securitySolution.cases.configureCases.caseClosureOptionsLabel": "案例关闭选项", - "xpack.securitySolution.cases.configureCases.caseClosureOptionsManual": "手动关闭 Security 案例", - "xpack.securitySolution.cases.configureCases.caseClosureOptionsNewIncident": "将新事件推送到外部系统时自动关闭 Security 案例", - "xpack.securitySolution.cases.configureCases.caseClosureOptionsTitle": "案例关闭", - "xpack.securitySolution.cases.configureCases.commentMapping": "注释", - "xpack.securitySolution.cases.configureCases.editFieldMappingTitle": "编辑 { thirdPartyName } 字段映射", - "xpack.securitySolution.cases.configureCases.fieldMappingDesc": "将数据推送到 { thirdPartyName } 时,将 Security 案例字段映射到 { thirdPartyName } 字段。字段映射需要与 { thirdPartyName } 建立连接。", - "xpack.securitySolution.cases.configureCases.fieldMappingDescErr": "字段映射需要与 { thirdPartyName } 建立连接。请检查您的连接凭据。", - "xpack.securitySolution.cases.configureCases.fieldMappingEditAppend": "追加", - "xpack.securitySolution.cases.configureCases.fieldMappingEditNothing": "无内容", - "xpack.securitySolution.cases.configureCases.fieldMappingEditOverwrite": "覆盖", - "xpack.securitySolution.cases.configureCases.fieldMappingFirstCol": "Security 案例字段", - "xpack.securitySolution.cases.configureCases.fieldMappingSecondCol": "{ thirdPartyName } 字段", - "xpack.securitySolution.cases.configureCases.fieldMappingThirdCol": "编辑和更新时", - "xpack.securitySolution.cases.configureCases.fieldMappingTitle": "{ thirdPartyName } 字段映射", "xpack.securitySolution.cases.configureCases.headerTitle": "配置案例", - "xpack.securitySolution.cases.configureCases.incidentManagementSystemDesc": "您可能会根据需要将 Security 案例连接到选择的外部事件管理系统。这将允许您将案例数据作为事件推送到所选第三方系统。", - "xpack.securitySolution.cases.configureCases.incidentManagementSystemLabel": "事件管理系统", - "xpack.securitySolution.cases.configureCases.incidentManagementSystemTitle": "连接到外部事件管理系统", - "xpack.securitySolution.cases.configureCases.mappingFieldNotMapped": "未映射", - "xpack.securitySolution.cases.configureCases.noFieldsError": "未找到任何 { connectorName } 字段。请检查您的 { connectorName } 连接器设置或 { connectorName } 实例设置以解决问题。", - "xpack.securitySolution.cases.configureCases.requiredMappings": "至少有一个案例字段需要映射到以下所需的 { connectorName } 字段:{ fields }", - "xpack.securitySolution.cases.configureCases.saveAndCloseButton": "保存并关闭", - "xpack.securitySolution.cases.configureCases.saveButton": "保存", - "xpack.securitySolution.cases.configureCases.updateConnector": "更新字段映射", - "xpack.securitySolution.cases.configureCases.updateSelectedConnector": "更新 { connectorName }", - "xpack.securitySolution.cases.configureCases.warningMessage": "选定的连接器已删除。选择不同的连接器或创建新的连接器。", - "xpack.securitySolution.cases.configureCases.warningTitle": "警告", "xpack.securitySolution.cases.configureCasesButton": "编辑外部连接", - "xpack.securitySolution.cases.confirmDeleteCase.confirmQuestion": "删除此案例即会永久移除所有相关案例数据,而且您将无法再将数据推送到外部事件管理系统。是否确定要继续?", - "xpack.securitySolution.cases.confirmDeleteCase.confirmQuestionPlural": "删除这些案例即会永久移除所有相关案例数据,而且您将无法再将数据推送到外部事件管理系统。是否确定要继续?", "xpack.securitySolution.cases.confirmDeleteCase.deleteCase": "删除案例", "xpack.securitySolution.cases.confirmDeleteCase.deleteCases": "删除案例", - "xpack.securitySolution.cases.confirmDeleteCase.deleteThisCase": "删除此案例", - "xpack.securitySolution.cases.confirmDeleteCase.deleteTitle": "删除“{caseTitle}”", - "xpack.securitySolution.cases.confirmDeleteCase.selectedCases": "删除选定案例", - "xpack.securitySolution.cases.connectors.jira.issueTypesSelectFieldLabel": "问题类型", - "xpack.securitySolution.cases.connectors.jira.parentIssueSearchLabel": "父问题", - "xpack.securitySolution.cases.connectors.jira.prioritySelectFieldLabel": "优先级", - "xpack.securitySolution.cases.connectors.resilient.incidentTypesLabel": "事件类型", - "xpack.securitySolution.cases.connectors.resilient.incidentTypesPlaceholder": "选择类型", - "xpack.securitySolution.cases.connectors.resilient.severityLabel": "严重性", - "xpack.securitySolution.cases.connectors.resilient.unableToGetIncidentTypesMessage": "无法获取事件类型", - "xpack.securitySolution.cases.connectors.resilient.unableToGetSeverityMessage": "无法获取严重性", - "xpack.securitySolution.cases.containers.statusChangeToasterText": "此案例中的告警也更新了状态", "xpack.securitySolution.cases.createCase.descriptionFieldRequiredError": "描述必填。", "xpack.securitySolution.cases.createCase.fieldTagsHelpText": "为此案例键入一个或多个定制识别标签。在每个标签后按 Enter 键可开始新的标签。", "xpack.securitySolution.cases.createCase.titleFieldRequiredError": "标题必填。", "xpack.securitySolution.cases.dismissErrorsPushServiceCallOutTitle": "关闭", - "xpack.securitySolution.cases.editConnector.editConnectorLinkAria": "单击以编辑连接器", "xpack.securitySolution.cases.pageTitle": "案例", "xpack.securitySolution.cases.readOnlySavedObjectDescription": "您仅有权查看案例。如果需要创建和更新案例,请联系您的 Kibana 管理员。", "xpack.securitySolution.cases.readOnlySavedObjectTitle": "您无法创建新案例或更新现有案例", "xpack.securitySolution.cases.settings.syncAlertsSwitchLabelOff": "关闭", "xpack.securitySolution.cases.settings.syncAlertsSwitchLabelOn": "开启", - "xpack.securitySolution.cases.status.closed": "已关闭", - "xpack.securitySolution.cases.status.iconAria": "更改状态", - "xpack.securitySolution.cases.status.inProgress": "进行中", - "xpack.securitySolution.cases.status.open": "未结", "xpack.securitySolution.cases.timeline.actions.addCase": "添加到案例", "xpack.securitySolution.cases.timeline.actions.addExistingCase": "添加到现有案例", "xpack.securitySolution.cases.timeline.actions.addNewCase": "添加到新案例", @@ -18262,8 +18115,6 @@ "xpack.securitySolution.cases.timeline.actions.caseCreatedSuccessToast": "告警已添加到“{title}”", "xpack.securitySolution.cases.timeline.actions.caseCreatedSuccessToastText": "此案例中的告警的状态已经与案例状态同步", "xpack.securitySolution.cases.timeline.actions.caseCreatedSuccessToastViewCaseLink": "查看案例", - "xpack.securitySolution.caseConnectorsRegistry.get.missingCaseConnectorErrorMessage": "对象类型“{id}”未注册。", - "xpack.securitySolution.caseConnectorsRegistry.register.duplicateCaseConnectorErrorMessage": "已注册对象类型“{id}”。", "xpack.securitySolution.certificate.fingerprint.clientCertLabel": "客户端证书", "xpack.securitySolution.certificate.fingerprint.serverCertLabel": "服务器证书", "xpack.securitySolution.chart.allOthersGroupingLabel": "所有其他", @@ -18278,31 +18129,7 @@ "xpack.securitySolution.clipboard.to.the.clipboard": "至剪贴板", "xpack.securitySolution.common.alertAddedToCase": "已添加到案例", "xpack.securitySolution.common.alertLabel": "告警", - "xpack.securitySolution.components.connectors.jira.searchIssuesComboBoxAriaLabel": "键入内容进行搜索", - "xpack.securitySolution.components.connectors.jira.searchIssuesComboBoxPlaceholder": "键入内容进行搜索", - "xpack.securitySolution.components.connectors.jira.searchIssuesLoading": "正在加载……", - "xpack.securitySolution.components.connectors.jira.unableToGetFieldsMessage": "无法获取连接器", - "xpack.securitySolution.components.connectors.jira.unableToGetIssueMessage": "无法获取 ID 为 {id} 的问题", - "xpack.securitySolution.components.connectors.jira.unableToGetIssuesMessage": "无法获取问题", - "xpack.securitySolution.components.connectors.jira.unableToGetIssueTypesMessage": "无法获取问题类型", - "xpack.securitySolution.components.connectors.serviceNow.alertFieldEnabledText": "是", - "xpack.securitySolution.components.connectors.serviceNow.alertFieldsTitle": "与告警关联的字段", - "xpack.securitySolution.components.connectors.serviceNow.categoryTitle": "类别", - "xpack.securitySolution.components.connectors.serviceNow.destinationIPTitle": "目标 IP", - "xpack.securitySolution.components.connectors.serviceNow.impactSelectFieldLabel": "影响", - "xpack.securitySolution.components.connectors.serviceNow.malwareHashTitle": "恶意软件哈希", - "xpack.securitySolution.components.connectors.serviceNow.malwareURLTitle": "恶意软件 URL", - "xpack.securitySolution.components.connectors.serviceNow.prioritySelectFieldTitle": "优先级", - "xpack.securitySolution.components.connectors.serviceNow.severitySelectFieldLabel": "严重性", - "xpack.securitySolution.components.connectors.serviceNow.sourceIPTitle": "源 IP", - "xpack.securitySolution.components.connectors.serviceNow.subcategoryTitle": "子类别", - "xpack.securitySolution.components.connectors.serviceNow.unableToGetChoicesMessage": "无法获取选项", - "xpack.securitySolution.components.connectors.serviceNow.urgencySelectFieldLabel": "紧急性", - "xpack.securitySolution.components.create.stepOneTitle": "案例字段", - "xpack.securitySolution.components.create.stepThreeTitle": "外部连接器字段", - "xpack.securitySolution.components.create.stepTwoTitle": "案例设置", "xpack.securitySolution.components.create.syncAlertHelpText": "启用此选项将使本案例中的告警状态与案例状态同步。", - "xpack.securitySolution.components.create.syncAlertsLabel": "将告警状态与案例状态同步", "xpack.securitySolution.components.embeddables.embeddedMap.clientLayerLabel": "客户端点", "xpack.securitySolution.components.embeddables.embeddedMap.destinationLayerLabel": "目标点", "xpack.securitySolution.components.embeddables.embeddedMap.embeddableHeaderHelp": "地图配置帮助", @@ -18377,14 +18204,6 @@ "xpack.securitySolution.containers.anomalies.errorFetchingAnomaliesData": "无法查询异常数据", "xpack.securitySolution.containers.anomalies.stackByJobId": "作业", "xpack.securitySolution.containers.anomalies.title": "异常", - "xpack.securitySolution.containers.cases.closedCases": "已关闭{totalCases, plural, =1 {“{caseTitle}”} other { {totalCases} 个案例}}", - "xpack.securitySolution.containers.cases.deletedCases": "已删除{totalCases, plural, =1 {“{caseTitle}”} other { {totalCases} 个案例}}", - "xpack.securitySolution.containers.cases.errorDeletingTitle": "删除数据时出错", - "xpack.securitySolution.containers.cases.errorTitle": "提取数据时出错", - "xpack.securitySolution.containers.cases.pushToExternalService": "已成功发送到 { serviceName }", - "xpack.securitySolution.containers.cases.reopenedCases": "已重新打开{totalCases, plural, =1 {“{caseTitle}”} other { {totalCases} 个案例}}", - "xpack.securitySolution.containers.cases.syncCase": "“{caseTitle}”中的告警已同步", - "xpack.securitySolution.containers.cases.updatedCase": "已更新“{caseTitle}”", "xpack.securitySolution.containers.detectionEngine.addRuleFailDescription": "无法添加规则", "xpack.securitySolution.containers.detectionEngine.alerts.createListsIndex.errorDescription": "无法创建列表索引", "xpack.securitySolution.containers.detectionEngine.alerts.errorFetchingAlertsDescription": "无法查询告警", @@ -20227,7 +20046,6 @@ "xpack.securitySolution.overview.hostStatGroupFilebeat": "Filebeat", "xpack.securitySolution.overview.hostStatGroupWinlogbeat": "Winlogbeat", "xpack.securitySolution.overview.hostsTitle": "主机事件", - "xpack.securitySolution.overview.myRecentlyReportedCasesButtonLabel": "我最近报告的案例", "xpack.securitySolution.overview.networkAction": "查看网络", "xpack.securitySolution.overview.networkStatGroupAuditbeat": "Auditbeat", "xpack.securitySolution.overview.networkStatGroupFilebeat": "Filebeat", @@ -20242,7 +20060,6 @@ "xpack.securitySolution.overview.pageSubtitle": "Elastic Stack 的安全信息和事件管理功能", "xpack.securitySolution.overview.pageTitle": "安全", "xpack.securitySolution.overview.recentCasesSidebarTitle": "最近案例", - "xpack.securitySolution.overview.recentlyCreatedCasesButtonLabel": "最近创建的案例", "xpack.securitySolution.overview.recentTimelinesSidebarTitle": "最近的时间线", "xpack.securitySolution.overview.showTopTooltip": "显示排名靠前的{fieldName}", "xpack.securitySolution.overview.signalCountTitle": "检测告警趋势", @@ -20278,11 +20095,6 @@ "xpack.securitySolution.policyStatusText.success": "成功", "xpack.securitySolution.policyStatusText.unsupported": "不支持", "xpack.securitySolution.policyStatusText.warning": "警告", - "xpack.securitySolution.recentCases.commentsTooltip": "注释", - "xpack.securitySolution.recentCases.controlLegend": "案例筛选", - "xpack.securitySolution.recentCases.noCasesMessage": "尚未创建任何案例。以侦探的眼光", - "xpack.securitySolution.recentCases.startNewCaseLink": "建立新案例", - "xpack.securitySolution.recentCases.viewAllCasesLink": "查看所有案例", "xpack.securitySolution.recentTimelines.errorRetrievingUserDetailsMessage": "最近的时间线:检索用户详情时发生错误", "xpack.securitySolution.recentTimelines.favoritesButtonLabel": "收藏夹", "xpack.securitySolution.recentTimelines.filterControlLegend": "时间线筛选", @@ -20627,7 +20439,6 @@ "xpack.securitySolution.topN.closeButtonLabel": "关闭", "xpack.securitySolution.topN.rawEventsSelectLabel": "原始事件", "xpack.securitySolution.trustedapps.aboutInfo": "添加受信任的应用程序,以提高性能或缓解与主机上运行的其他应用程序的冲突。受信任的应用程序将应用于运行 Endpoint Security 的主机。", - "xpack.securitySolution.trustedapps.card.operator.includes": "是", "xpack.securitySolution.trustedapps.card.removeButtonLabel": "移除", "xpack.securitySolution.trustedapps.create.conditionFieldValueRequiredMsg": "[{row}] 字段条目必须包含值", "xpack.securitySolution.trustedapps.create.conditionRequiredMsg": "至少需要一个字段定义", diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 8757b39a0b3ac..87f6ad20e6040 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -42,6 +42,7 @@ { "path": "../plugins/apm/tsconfig.json" }, { "path": "../plugins/banners/tsconfig.json" }, { "path": "../plugins/beats_management/tsconfig.json" }, + { "path": "../plugins/cases/tsconfig.json" }, { "path": "../plugins/cloud/tsconfig.json" }, { "path": "../plugins/console_extensions/tsconfig.json" }, { "path": "../plugins/dashboard_mode/tsconfig.json" }, diff --git a/yarn.lock b/yarn.lock index 91b6791b2c80f..65442bf8f5efe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1197,10 +1197,10 @@ resolved "https://registry.yarnpkg.com/@bazel/ibazel/-/ibazel-0.15.10.tgz#cf0cff1aec6d8e7bb23e1fc618d09fbd39b7a13f" integrity sha512-0v+OwCQ6fsGFa50r6MXWbUkSGuWOoZ22K4pMSdtWiL5LKFIE4kfmMmtQS+M7/ICNwk2EIYob+NRreyi/DGUz5A== -"@bazel/typescript@^3.2.3": - version "3.2.3" - resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.2.3.tgz#6e40bdb7c5294e588bac3b7d1269e58b98a1856c" - integrity sha512-Q1Yin/AYdh9yrkSJo3H6nVn6mMaohr5syjLd0Df0w7WI4zerdJTxrY5nhoWZwO/S1rPj8/MedDwZudCqPDeDMA== +"@bazel/typescript@^3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.4.2.tgz#183cb14d1f4149cc67ed2723c4b8a7366da5ec36" + integrity sha512-JtLdPOC7rytALJBxawxTCnxVopGstk2eXFs56zHBy+JWSeqrnwujeWZyK5qZHzpag02/JtIQ/ZKkM/DQtrXC8Q== dependencies: protobufjs "6.8.8" semver "5.6.0"