diff --git a/.eslintrc.js b/.eslintrc.js index 19ba7cacc3c44..8a6ea7957927a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1377,6 +1377,10 @@ module.exports = { ['parent', 'sibling', 'index'], ], pathGroups: [ + { + pattern: '{**,.}/*.test.mocks', + group: 'unknown', + }, { pattern: '{@kbn/**,src/**,kibana{,/**}}', group: 'internal', @@ -1402,6 +1406,24 @@ module.exports = { }, }, + /** + * Do not allow `any` + */ + { + files: [ + 'packages/kbn-analytics/**', + // 'packages/kbn-telemetry-tools/**', + 'src/plugins/kibana_usage_collection/**', + 'src/plugins/usage_collection/**', + 'src/plugins/telemetry/**', + 'src/plugins/telemetry_collection_manager/**', + 'src/plugins/telemetry_management_section/**', + 'x-pack/plugins/telemetry_collection_xpack/**', + ], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + }, + }, { files: [ // core-team owned code 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 bff65ce9c68dd..61b984aad4882 100644 --- a/api_docs/telemetry.json +++ b/api_docs/telemetry.json @@ -3,7 +3,246 @@ "client": { "classes": [], "functions": [], - "interfaces": [], + "interfaces": [ + { + "id": "def-public.TelemetryPluginConfig", + "type": "Interface", + "label": "TelemetryPluginConfig", + "description": [ + "\nPublic-exposed configuration" + ], + "tags": [], + "children": [ + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.enabled", + "type": "boolean", + "label": "enabled", + "description": [ + "Is the plugin enabled?" + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 84 + } + }, + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.url", + "type": "string", + "label": "url", + "description": [ + "Remote telemetry service's URL" + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 86 + } + }, + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.banner", + "type": "boolean", + "label": "banner", + "description": [ + "The banner is expected to be shown when needed" + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 88 + } + }, + { + "tags": [], + "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/plugin.ts", + "lineNumber": 90 + } + }, + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.optIn", + "type": "CompoundType", + "label": "optIn", + "description": [ + "Is the cluster opted-in?" + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 92 + }, + "signature": [ + "boolean | null" + ] + }, + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.optInStatusUrl", + "type": "string", + "label": "optInStatusUrl", + "description": [ + "Opt-in/out notification URL" + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 94 + } + }, + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.sendUsageFrom", + "type": "CompoundType", + "label": "sendUsageFrom", + "description": [ + "Should the telemetry payloads be sent from the server or the browser?" + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 96 + }, + "signature": [ + "\"browser\" | \"server\"" + ] + }, + { + "tags": [], + "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/plugin.ts", + "lineNumber": 98 + }, + "signature": [ + "boolean | undefined" + ] + }, + { + "tags": [], + "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/plugin.ts", + "lineNumber": 100 + }, + "signature": [ + "boolean | undefined" + ] + } + ], + "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": [ + { + "tags": [], + "id": "def-public.TelemetryServicePublicApis.getIsOptedIn", + "type": "Function", + "label": "getIsOptedIn", + "description": [ + "Is the cluster opted-in to telemetry?" + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 38 + }, + "signature": [ + "() => boolean | null" + ] + }, + { + "tags": [], + "id": "def-public.TelemetryServicePublicApis.userCanChangeSettings", + "type": "boolean", + "label": "userCanChangeSettings", + "description": [ + "Is the user allowed to change the opt-in/out status?" + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 40 + } + }, + { + "tags": [], + "id": "def-public.TelemetryServicePublicApis.getCanChangeOptInStatus", + "type": "Function", + "label": "getCanChangeOptInStatus", + "description": [ + "Is the cluster allowed to change the opt-in/out status?" + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 42 + }, + "signature": [ + "() => boolean" + ] + }, + { + "tags": [], + "id": "def-public.TelemetryServicePublicApis.fetchExample", + "type": "Function", + "label": "fetchExample", + "description": [ + "Fetches an unencrypted telemetry payload so we can show it to the user" + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 44 + }, + "signature": [ + "() => Promise" + ] + }, + { + "tags": [], + "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/public/plugin.ts", + "lineNumber": 50 + }, + "signature": [ + "(optedIn: boolean) => Promise" + ] + } + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 36 + }, + "initialIsOpen": false + } + ], "enums": [], "misc": [], "objects": [], @@ -11,7 +250,9 @@ "id": "def-public.TelemetryPluginStart", "type": "Interface", "label": "TelemetryPluginStart", - "description": [], + "description": [ + "\nPublic's start exposed APIs by the telemetry plugin" + ], "tags": [], "children": [ { @@ -19,13 +260,21 @@ "id": "def-public.TelemetryPluginStart.telemetryService", "type": "Object", "label": "telemetryService", - "description": [], + "description": [ + "{@link TelemetryServicePublicApis}" + ], "source": { "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 38 + "lineNumber": 66 }, "signature": [ - "TelemetryService" + { + "pluginId": "telemetry", + "scope": "public", + "docId": "kibTelemetryPluginApi", + "section": "def-public.TelemetryServicePublicApis", + "text": "TelemetryServicePublicApis" + } ] }, { @@ -33,13 +282,15 @@ "id": "def-public.TelemetryPluginStart.telemetryNotifications", "type": "Object", "label": "telemetryNotifications", - "description": [], + "description": [ + "Notification helpers" + ], "source": { "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 39 + "lineNumber": 68 }, "signature": [ - "TelemetryNotifications" + "{ setOptedInNoticeSeen: () => Promise; }" ] }, { @@ -47,10 +298,12 @@ "id": "def-public.TelemetryPluginStart.telemetryConstants", "type": "Object", "label": "telemetryConstants", - "description": [], + "description": [ + "Set of publicly exposed telemetry constants" + ], "source": { "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 40 + "lineNumber": 73 }, "signature": [ "{ getPrivacyStatementUrl: () => string; }" @@ -59,7 +312,7 @@ ], "source": { "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 37 + "lineNumber": 64 }, "lifecycle": "start", "initialIsOpen": true @@ -68,7 +321,9 @@ "id": "def-public.TelemetryPluginSetup", "type": "Interface", "label": "TelemetryPluginSetup", - "description": [], + "description": [ + "\nPublic's setup exposed APIs by the telemetry plugin" + ], "tags": [], "children": [ { @@ -76,533 +331,182 @@ "id": "def-public.TelemetryPluginSetup.telemetryService", "type": "Object", "label": "telemetryService", - "description": [], + "description": [ + "{@link TelemetryService}" + ], "source": { "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 34 + "lineNumber": 58 }, "signature": [ - "TelemetryService" + { + "pluginId": "telemetry", + "scope": "public", + "docId": "kibTelemetryPluginApi", + "section": "def-public.TelemetryServicePublicApis", + "text": "TelemetryServicePublicApis" + } ] } ], "source": { "path": "src/plugins/telemetry/public/plugin.ts", - "lineNumber": 33 + "lineNumber": 56 }, "lifecycle": "setup", "initialIsOpen": true } }, "server": { - "classes": [ + "classes": [], + "functions": [], + "interfaces": [ { - "id": "def-server.FetcherTask", - "type": "Class", + "id": "def-server.DataTelemetryBasePayload", + "type": "Interface", + "label": "DataTelemetryBasePayload", + "description": [ + "\nCommon counters for the {@link DataTelemetryDocument}s" + ], "tags": [], - "label": "FetcherTask", - "description": [], "children": [ { - "id": "def-server.FetcherTask.Unnamed", - "type": "Function", - "label": "Constructor", - "signature": [ - "any" - ], - "description": [], - "children": [ - { - "id": "def-server.FetcherTask.Unnamed.$1", - "type": "Object", - "label": "initializerContext", - "isRequired": true, - "signature": [ - { - "pluginId": "core", - "scope": "server", - "docId": "kibCorePluginApi", - "section": "def-server.PluginInitializerContext", - "text": "PluginInitializerContext" - }, - ">" - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/server/fetcher.ts", - "lineNumber": 58 - } - } - ], "tags": [], - "returnComment": [], + "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/fetcher.ts", - "lineNumber": 58 + "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", + "lineNumber": 22 } }, { - "id": "def-server.FetcherTask.start", - "type": "Function", - "label": "start", - "signature": [ - "({ savedObjects, elasticsearch }: ", - { - "pluginId": "core", - "scope": "server", - "docId": "kibCorePluginApi", - "section": "def-server.CoreStart", - "text": "CoreStart" - }, - ", { telemetryCollectionManager }: ", - "FetcherTaskDepsStart", - ") => void" - ], - "description": [], - "children": [ - { - "id": "def-server.FetcherTask.start.$1", - "type": "Object", - "label": "{ savedObjects, elasticsearch }", - "isRequired": true, - "signature": [ - { - "pluginId": "core", - "scope": "server", - "docId": "kibCorePluginApi", - "section": "def-server.CoreStart", - "text": "CoreStart" - } - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/server/fetcher.ts", - "lineNumber": 65 - } - }, - { - "id": "def-server.FetcherTask.start.$2", - "type": "Object", - "label": "{ telemetryCollectionManager }", - "isRequired": true, - "signature": [ - "FetcherTaskDepsStart" - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/server/fetcher.ts", - "lineNumber": 66 - } - } - ], "tags": [], - "returnComment": [], + "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/fetcher.ts", - "lineNumber": 64 - } + "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", + "lineNumber": 24 + }, + "signature": [ + "number | undefined" + ] }, { - "id": "def-server.FetcherTask.stop", - "type": "Function", - "label": "stop", - "signature": [ - "() => void" - ], - "description": [], - "children": [], "tags": [], - "returnComment": [], + "id": "def-server.DataTelemetryBasePayload.doc_count", + "type": "number", + "label": "doc_count", + "description": [ + "How many documents are among all the identified indices" + ], "source": { - "path": "src/plugins/telemetry/server/fetcher.ts", - "lineNumber": 77 - } - } - ], - "source": { - "path": "src/plugins/telemetry/server/fetcher.ts", - "lineNumber": 45 - }, - "initialIsOpen": false - } - ], - "functions": [ - { - "id": "def-server.buildDataTelemetryPayload", - "type": "Function", - "label": "buildDataTelemetryPayload", - "signature": [ - "(indices: ", - { - "pluginId": "telemetry", - "scope": "server", - "docId": "kibTelemetryPluginApi", - "section": "def-server.DataTelemetryIndex", - "text": "DataTelemetryIndex" + "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", + "lineNumber": 26 + }, + "signature": [ + "number | undefined" + ] }, - "[]) => ", { - "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" - }, - "[]" + "tags": [], + "id": "def-server.DataTelemetryBasePayload.size_in_bytes", + "type": "number", + "label": "size_in_bytes", + "description": [ + "Total size in bytes among all the identified indices" ], - "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, + "lineNumber": 28 + }, "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 - } + "number | undefined" + ] } ], - "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 + "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", + "lineNumber": 20 }, - "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 - } - } - ], + "id": "def-server.DataTelemetryDocument", + "type": "Interface", + "label": "DataTelemetryDocument", "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", + "pluginId": "telemetry", "scope": "server", - "docId": "kibTelemetryCollectionManagerPluginApi", - "section": "def-server.StatsCollectionContext", - "text": "StatsCollectionContext" + "docId": "kibTelemetryPluginApi", + "section": "def-server.DataTelemetryDocument", + "text": "DataTelemetryDocument" }, - ") => Promise<{ timestamp: string; cluster_uuid: string; cluster_name: string; version: string; cluster_stats: any; collection: string; stack_stats: { data: ", + " extends ", { "pluginId": "telemetry", "scope": "server", "docId": "kibTelemetryPluginApi", - "section": "def-server.DataTelemetryPayload", - "text": "DataTelemetryPayload" - }, - " | undefined; kibana: { count: number; indices: number; os: {}; versions: { version: string; count: number; }[]; plugins: { [plugin: string]: any; }; } | undefined; }; }[]>" + "section": "def-server.DataTelemetryBasePayload", + "text": "DataTelemetryBasePayload" + } ], "description": [ - "\nGet statistics for all products joined by Elasticsearch cluster." + "\nDepending on the type of index, we'll populate different keys as we identify them." ], - "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", + "tags": [], + "id": "def-server.DataTelemetryDocument.data_stream", "type": "Object", - "label": "uiSettingsClient", - "isRequired": true, - "signature": [ - { - "pluginId": "core", - "scope": "server", - "docId": "kibCorePluginApi", - "section": "def-server.IUiSettingsClient", - "text": "IUiSettingsClient" - } + "label": "data_stream", + "description": [ + "For data-stream indices. Reporting their details" ], - "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 + "lineNumber": 36 }, "signature": [ - "string | undefined" + "{ dataset?: string | undefined; type?: string | undefined; } | 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 - }, - "signature": [ - "string | undefined" - ] - }, - { - "tags": [], - "id": "def-server.DataTelemetryIndex.dataStreamType", - "type": "string", - "label": "dataStreamType", - "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_data_telemetry/get_data_telemetry.ts", "lineNumber": 43 }, "signature": [ - "string | undefined" + "{ name: string; } | undefined" ] }, { "tags": [], - "id": "def-server.DataTelemetryIndex.shipper", + "id": "def-server.DataTelemetryDocument.shipper", "type": "string", "label": "shipper", - "description": [], + "description": [ + "What's the process indexing the data? (i.e.: \"beats\", \"logstash\")" + ], "source": { "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 44 + "lineNumber": 48 }, "signature": [ "string | undefined" @@ -610,80 +514,81 @@ }, { "tags": [], - "id": "def-server.DataTelemetryIndex.isECS", + "id": "def-server.DataTelemetryDocument.pattern_name", "type": "CompoundType", - "label": "isECS", - "description": [], + "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_data_telemetry/get_data_telemetry.ts", - "lineNumber": 45 + "lineNumber": 50 }, "signature": [ - "boolean | 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_data_telemetry/get_data_telemetry.ts", + "lineNumber": 34 + }, + "initialIsOpen": false + }, + { + "id": "def-server.NodeUsage", + "type": "Interface", + "label": "NodeUsage", + "signature": [ { - "tags": [], - "id": "def-server.DataTelemetryIndex.docCount", - "type": "number", - "label": "docCount", - "description": [], - "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 49 - }, - "signature": [ - "number | undefined" - ] + "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": [ { "tags": [], - "id": "def-server.DataTelemetryIndex.sizeInBytes", - "type": "number", - "label": "sizeInBytes", - "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_data_telemetry/get_data_telemetry.ts", - "lineNumber": 50 - }, - "signature": [ - "number | undefined" - ] + "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", + "lineNumber": 21 + } } ], "source": { - "path": "src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts", - "lineNumber": 38 + "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", + "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[]" @@ -695,13 +600,15 @@ "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: any; collection: string; stack_stats: { data: DataTelemetryPayload | undefined; kibana: { count: number; indices: number; os: {}; versions: { version: string; count: number; }[]; plugins: { [plugin: string]: any; }; } | undefined; }; }" + "{ 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; }; }" ], "initialIsOpen": false } @@ -711,7 +618,9 @@ "id": "def-server.TelemetryPluginSetup", "type": "Interface", "label": "TelemetryPluginSetup", - "description": [], + "description": [ + "\nServer's setup exposed APIs by the telemetry plugin" + ], "tags": [], "children": [ { @@ -724,7 +633,7 @@ ], "source": { "path": "src/plugins/telemetry/server/plugin.ts", - "lineNumber": 53 + "lineNumber": 56 }, "signature": [ "() => Promise<", @@ -735,7 +644,7 @@ ], "source": { "path": "src/plugins/telemetry/server/plugin.ts", - "lineNumber": 48 + "lineNumber": 51 }, "lifecycle": "setup", "initialIsOpen": true @@ -744,7 +653,9 @@ "id": "def-server.TelemetryPluginStart", "type": "Interface", "label": "TelemetryPluginStart", - "description": [], + "description": [ + "\nServer's start exposed APIs by the telemetry plugin" + ], "tags": [], "children": [ { @@ -757,7 +668,7 @@ ], "source": { "path": "src/plugins/telemetry/server/plugin.ts", - "lineNumber": 62 + "lineNumber": 68 }, "signature": [ "() => Promise" @@ -766,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 bf91eb198f08e..995c9b22e268a 100644 --- a/api_docs/telemetry.mdx +++ b/api_docs/telemetry.mdx @@ -19,6 +19,9 @@ import telemetryObj from './telemetry.json'; ### Start +### Interfaces + + ## Server ### Setup @@ -27,12 +30,6 @@ import telemetryObj from './telemetry.json'; ### Start -### Functions - - -### Classes - - ### Interfaces diff --git a/dev_docs/best_practices.mdx b/dev_docs/best_practices.mdx index 4d51263f93372..54aaaa6b9497a 100644 --- a/dev_docs/best_practices.mdx +++ b/dev_docs/best_practices.mdx @@ -65,6 +65,7 @@ Every publicly exposed function, class, interface, type, parameter and property - Use `@returns` tags for return types. - Use `@throws` when appropriate. - Use `@beta` or `@deprecated` when appropriate. +- Use `@removeBy {version}` on `@deprecated` APIs. The version should be the last version the API will work in. For example, `@removeBy 7.15` means the API will be removed in 7.16. This lets us avoid mid-release cycle coordination. The API can be removed as soon as the 7.15 branch is cut. - Use `@internal` to indicate this API item is intended for internal use only, which will also remove it from the docs. #### Interfaces vs inlined types diff --git a/dev_docs/tutorials/data/search.mdx b/dev_docs/tutorials/data/search.mdx index 69b4d5dab58b5..9cf46bb96c72a 100644 --- a/dev_docs/tutorials/data/search.mdx +++ b/dev_docs/tutorials/data/search.mdx @@ -355,7 +355,7 @@ export class SearchEmbeddable this.updateOutput({ loading: true, error: undefined }); // Make the request, wait for the final result - const resp = await searchSource.fetch$({ + const {rawResponse: resp} = await searchSource.fetch$({ sessionId: searchSessionId, }).toPromise(); diff --git a/docs/api/actions-and-connectors/create.asciidoc b/docs/api/actions-and-connectors/create.asciidoc index 554e84615d568..c9ea31c98cf19 100644 --- a/docs/api/actions-and-connectors/create.asciidoc +++ b/docs/api/actions-and-connectors/create.asciidoc @@ -73,6 +73,7 @@ The API returns the following: "refresh": false, "executionTimeField": null }, - "is_preconfigured": false + "is_preconfigured": false, + "is_missing_secrets": false } -------------------------------------------------- diff --git a/docs/api/actions-and-connectors/get.asciidoc b/docs/api/actions-and-connectors/get.asciidoc index 0d9af45c4ef0c..95336e7f55d30 100644 --- a/docs/api/actions-and-connectors/get.asciidoc +++ b/docs/api/actions-and-connectors/get.asciidoc @@ -50,6 +50,7 @@ The API returns the following: "refresh": false, "executionTimeField": null }, - "is_preconfigured": false + "is_preconfigured": false, + "is_missing_secrets": false } -------------------------------------------------- diff --git a/docs/api/actions-and-connectors/get_all.asciidoc b/docs/api/actions-and-connectors/get_all.asciidoc index e4e67a9bbde73..8036b9fea7f95 100644 --- a/docs/api/actions-and-connectors/get_all.asciidoc +++ b/docs/api/actions-and-connectors/get_all.asciidoc @@ -56,6 +56,7 @@ The API returns the following: "executionTimeField": null }, "is_preconfigured": false, + "is_missing_secrets": false, "referenced_by_count": 3 } ] diff --git a/docs/api/actions-and-connectors/legacy/create.asciidoc b/docs/api/actions-and-connectors/legacy/create.asciidoc index 0361c4222986b..e0d531a2befb9 100644 --- a/docs/api/actions-and-connectors/legacy/create.asciidoc +++ b/docs/api/actions-and-connectors/legacy/create.asciidoc @@ -75,6 +75,7 @@ The API returns the following: "refresh": false, "executionTimeField": null }, - "isPreconfigured": false + "isPreconfigured": false, + "isMissingSecrets": false } -------------------------------------------------- diff --git a/docs/api/actions-and-connectors/legacy/get.asciidoc b/docs/api/actions-and-connectors/legacy/get.asciidoc index 6413fce558f5b..dab462e3ae4fb 100644 --- a/docs/api/actions-and-connectors/legacy/get.asciidoc +++ b/docs/api/actions-and-connectors/legacy/get.asciidoc @@ -52,6 +52,7 @@ The API returns the following: "refresh": false, "executionTimeField": null }, - "isPreconfigured": false + "isPreconfigured": false, + "isMissingSecrets": false } -------------------------------------------------- diff --git a/docs/api/actions-and-connectors/legacy/get_all.asciidoc b/docs/api/actions-and-connectors/legacy/get_all.asciidoc index 191eccb6f8d39..2180720ce6542 100644 --- a/docs/api/actions-and-connectors/legacy/get_all.asciidoc +++ b/docs/api/actions-and-connectors/legacy/get_all.asciidoc @@ -56,7 +56,8 @@ The API returns the following: "refresh": false, "executionTimeField": null }, - "isPreconfigured": false + "isPreconfigured": false, + "isMissingSecrets": false } ] -------------------------------------------------- diff --git a/docs/api/actions-and-connectors/legacy/update.asciidoc b/docs/api/actions-and-connectors/legacy/update.asciidoc index 6a33e765cf063..5202f8124e6a8 100644 --- a/docs/api/actions-and-connectors/legacy/update.asciidoc +++ b/docs/api/actions-and-connectors/legacy/update.asciidoc @@ -70,6 +70,7 @@ The API returns the following: "refresh": false, "executionTimeField": null }, - "isPreconfigured": false + "isPreconfigured": false, + "isMissingSecrets": false } -------------------------------------------------- diff --git a/docs/api/actions-and-connectors/update.asciidoc b/docs/api/actions-and-connectors/update.asciidoc index f522cb8d048e0..0b7dcc898a122 100644 --- a/docs/api/actions-and-connectors/update.asciidoc +++ b/docs/api/actions-and-connectors/update.asciidoc @@ -68,6 +68,7 @@ The API returns the following: "refresh": false, "executionTimeField": null }, - "is_preconfigured": false + "is_preconfigured": false, + "is_missing_secrets": false } -------------------------------------------------- diff --git a/docs/apm/apm-app-users.asciidoc b/docs/apm/apm-app-users.asciidoc index 3f0a42251304c..9b8a9c64ac43b 100644 --- a/docs/apm/apm-app-users.asciidoc +++ b/docs/apm/apm-app-users.asciidoc @@ -10,7 +10,7 @@ Users and privileges ++++ -You can use role-based access control to grant users access to secured +Use role-based access control to grant users access to secured resources. The roles that you set up depend on your organization's security requirements and the minimum privileges required to use specific features. @@ -24,6 +24,13 @@ In general, there are three types of privileges you'll work with: * **Elasticsearch index privileges**: Control access to the data in specific indices your cluster. * **Kibana space privileges**: Grant users write or read access to features and apps within Kibana. +Select your use-case to get started: + +* <> +* <> +* <> +* <> + //// *********************************** *********************************** //// @@ -36,13 +43,25 @@ In general, there are three types of privileges you'll work with: Create an APM reader user ++++ +APM reader users typically need to view the APM app and dashboards and visualizations that use APM data. +These users might also need to create and edit dashboards, visualizations, and machine learning jobs. + [[apm-app-reader-full]] -==== Full APM reader +==== APM reader -APM reader users typically need to view the APM app, dashboards, and visualizations that contain APM data. -These users might also need to create and edit dashboards, visualizations, and machine learning jobs. +To create an APM reader user: + +. Create a new role, named something like `read-apm`, and assign the following privileges: ++ +-- +include::./tab-widgets/apm-app-reader/widget.asciidoc[] +-- ++ +TIP: Using the {apm-server-ref-v}/apm-integration.html[APM integration for Elastic Agent]? +Add the privileges under the **Data streams** tab. -. Assign the following built-in roles: +. Assign the `read-apm` role created in the previous step, and the following built-in roles to +any APM reader users: + [options="header"] |==== @@ -51,9 +70,6 @@ These users might also need to create and edit dashboards, visualizations, and m |`kibana_admin` |Grants access to all features in Kibana. -|`apm_user` -|Grants the privileges required for APM users on +{beat_default_index_prefix}*+ indices - |`machine_learning_admin` |Grants the privileges required to create, update, and view machine learning jobs |==== @@ -63,14 +79,14 @@ These users might also need to create and edit dashboards, visualizations, and m In some instances, you may wish to restrict certain Kibana apps that a user has access to. -. Assign the following built in roles: +. Create a new role, named something like `read-apm-partial`, and assign the following privileges: + -[options="header"] -|==== -|Role | Purpose -|`apm_user` -|Grants the privileges required for APM users on +{beat_default_index_prefix}*+ indices -|==== +-- +include::./tab-widgets/apm-app-reader/widget.asciidoc[] +-- ++ +TIP: Using the {apm-server-ref-v}/apm-integration.html[APM integration for Elastic Agent]? +Add the privileges under the **Data streams** tab. . Assign space privileges to any Kibana space that the user needs access to. Here are two examples: @@ -98,6 +114,8 @@ Here are two examples: |Grants the privileges required to create, update, and view machine learning jobs |==== +include::./tab-widgets/code.asciidoc[] + //// *********************************** *********************************** //// @@ -138,7 +156,7 @@ and assign the following privileges: ^1^ +\{ANNOTATION_INDEX\}+ should be the index name you've defined in <>. -. Assign the `annotation_user` created previously, and the built-in roles necessary to create +. Assign the `annotation_user` created previously, and the roles and privileges necessary to create a <> or <> APM reader to any users that need to view annotations in the APM app [[apm-app-annotation-api]] @@ -163,17 +181,17 @@ See <>. Central configuration users need to be able to view, create, update, and delete Agent configurations. -. Assign the following built-in roles: +. Create a new role, named something like `central-config-manager`, and assign the following privileges: + -[options="header"] -|==== -|Role | Purpose - -|`apm_user` -|Grants the privileges required for APM users on +{beat_default_index_prefix}*+ indices -|==== +-- +include::./tab-widgets/central-config-users/widget.asciidoc[] +-- ++ +TIP: Using the {apm-server-ref-v}/apm-integration.html[APM integration for Elastic Agent]? +Add the privileges under the **Data streams** tab. -. Assign the following Kibana space privileges: +. Assign the `central-config-manager` role created in the previous step, and the following Kibana space privileges to +anyone who needs to manage central configurations: + [options="header"] |==== @@ -190,16 +208,17 @@ Central configuration users need to be able to view, create, update, and delete In some instances, you may wish to create a user that can only read central configurations, but not create, update, or delete them. -. Assign the following built-in roles: +. Create a new role, named something like `central-config-reader`, and assign the following privileges: + -[options="header"] -|==== -|Role | Purpose -|`apm_user` -|Grants the privileges required for APM users on +{beat_default_index_prefix}*+ indices -|==== +-- +include::./tab-widgets/central-config-users/widget.asciidoc[] +-- ++ +TIP: Using the {apm-server-ref-v}/apm-integration.html[APM integration for Elastic Agent]? +Add the privileges under the **Data streams** tab. -. Assign the following Kibana space privileges: +. Assign the `central-config-reader` role created in the previous step, and the following Kibana space privileges to +anyone who needs to read central configurations: + [options="header"] |==== @@ -215,6 +234,8 @@ but not create, update, or delete them. See <>. +include::./tab-widgets/code.asciidoc[] + //// *********************************** *********************************** //// diff --git a/docs/apm/tab-widgets/apm-app-reader/content.asciidoc b/docs/apm/tab-widgets/apm-app-reader/content.asciidoc new file mode 100644 index 0000000000000..6b9c996035f6c --- /dev/null +++ b/docs/apm/tab-widgets/apm-app-reader/content.asciidoc @@ -0,0 +1,45 @@ +// tag::classic-indices[] +[options="header"] +|==== +|Type |Privilege |Purpose + +|Index +|`read` on `apm-*` +|Read-only access to `apm-*` data + +|Index +|`view_index_metadata` on `apm-*` +|Read-only access to `apm-*` index metadata +|==== +// end::classic-indices[] + +// tag::data-streams[] +[options="header"] +|==== +|Type |Privilege |Purpose + +|Index +|`read` on `logs-apm*` +|Read-only access to `logs-apm*` data + +|Index +|`view_index_metadata` on `logs-apm*` +|Read-only access to `logs-apm*` index metadata + +|Index +|`read` on `metrics-apm*` +|Read-only access to `metrics-apm*` data + +|Index +|`view_index_metadata` on `metrics-apm*` +|Read-only access to `metrics-apm*` index metadata + +|Index +|`read` on `traces-apm*` +|Read-only access to `traces-apm*` data + +|Index +|`view_index_metadata` on `traces-apm*` +|Read-only access to `traces-apm*` index metadata +|==== +// end::data-streams[] diff --git a/docs/apm/tab-widgets/apm-app-reader/widget.asciidoc b/docs/apm/tab-widgets/apm-app-reader/widget.asciidoc new file mode 100644 index 0000000000000..51c01367786b6 --- /dev/null +++ b/docs/apm/tab-widgets/apm-app-reader/widget.asciidoc @@ -0,0 +1,40 @@ +++++ +
+
+ + +
+
+++++ + +include::content.asciidoc[tag=classic-indices] + +++++ +
+ +
+++++ \ No newline at end of file diff --git a/docs/apm/tab-widgets/central-config-users/content.asciidoc b/docs/apm/tab-widgets/central-config-users/content.asciidoc new file mode 100644 index 0000000000000..0945050d9a861 --- /dev/null +++ b/docs/apm/tab-widgets/central-config-users/content.asciidoc @@ -0,0 +1,53 @@ +// tag::classic-indices[] +[options="header"] +|==== +|Type |Privilege |Purpose + +|Index +|`read` on `apm-*` +|Read-only access to `apm-*` data + +|Index +|`view_index_metadata` on `apm-*` +|Read-only access to `apm-*` index metadata +|==== +// end::classic-indices[] + +// tag::data-streams[] +[options="header"] +|==== +|Type |Privilege |Purpose + +|Index +|`read` on `apm-agent-configuration` +|Read-only access to `apm-agent-configuration` data + +|Index +|`view_index_metadata` on `apm-agent-configuration` +|Read-only access to `apm-agent-configuration` index metadata + +|Index +|`read` on `logs-apm*` +|Read-only access to `logs-apm*` data + +|Index +|`view_index_metadata` on `logs-apm*` +|Read-only access to `logs-apm*` index metadata + +|Index +|`read` on `metrics-apm*` +|Read-only access to `metrics-apm*` data + +|Index +|`view_index_metadata` on `metrics-apm*` +|Read-only access to `metrics-apm*` index metadata + +|Index +|`read` on `traces-apm*` +|Read-only access to `traces-apm*` data + +|Index +|`view_index_metadata` on `traces-apm*` +|Read-only access to `traces-apm*` index metadata +|==== +// end::data-streams[] diff --git a/docs/apm/tab-widgets/central-config-users/widget.asciidoc b/docs/apm/tab-widgets/central-config-users/widget.asciidoc new file mode 100644 index 0000000000000..68bef4e50c549 --- /dev/null +++ b/docs/apm/tab-widgets/central-config-users/widget.asciidoc @@ -0,0 +1,40 @@ +++++ +
+
+ + +
+
+++++ + +include::content.asciidoc[tag=classic-indices] + +++++ +
+ +
+++++ \ No newline at end of file diff --git a/docs/apm/tab-widgets/code.asciidoc b/docs/apm/tab-widgets/code.asciidoc new file mode 100644 index 0000000000000..6a30cf55c8dbb --- /dev/null +++ b/docs/apm/tab-widgets/code.asciidoc @@ -0,0 +1,166 @@ +// Defining styles and script here for simplicity. +++++ + + + +++++ \ No newline at end of file diff --git a/docs/developer/contributing/development-tests.asciidoc b/docs/developer/contributing/development-tests.asciidoc index 7aabc480cdaa2..715b1a15ab5ed 100644 --- a/docs/developer/contributing/development-tests.asciidoc +++ b/docs/developer/contributing/development-tests.asciidoc @@ -19,7 +19,7 @@ root) |Functional |`test/**/config.js` `x-pack/test/**/config.js` -|`node scripts/functional_tests_server --config [directory]/config.js``node scripts/functional_test_runner_ --config [directory]/config.js --grep=regexp` +|`node scripts/functional_tests_server --config [directory]/config.js` `node scripts/functional_test_runner --config [directory]/config.js --grep=regexp` |=== Test runner arguments: - Where applicable, the optional arguments diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 6e2d41e5ed679..31a153cdb3490 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -62,12 +62,16 @@ yarn kbn watch-bazel === List of Already Migrated Packages to Bazel - @elastic/datemath +- @elastic/eslint-config-kibana - @elastic/safer-lodash-set +- @kbn/apm-config-loader - @kbn/apm-utils - @kbn/babel-code-parser - @kbn/babel-preset - @kbn/config-schema +- @kbn/dev-utils - @kbn/expect +- @kbn/logging - @kbn/std - @kbn/tinymath - @kbn/utility-types diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 7d7d2c1246872..ad58cd040ff35 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -6,7 +6,7 @@ NOTE: node scripts/build_plugin_list_docs - You can update the template within packages/kbn-dev-utils/target/plugin_list/generate_plugin_list.js + You can update the template within node_modules/@kbn/dev-utils/target/plugin_list/generate_plugin_list.js //// @@ -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/development/core/public/kibana-plugin-core-public.iuisettingsclient.getsaved_.md b/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.getsaved_.md deleted file mode 100644 index 953bb75625c97..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.getsaved_.md +++ /dev/null @@ -1,17 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) > [getSaved$](./kibana-plugin-core-public.iuisettingsclient.getsaved_.md) - -## IUiSettingsClient.getSaved$ property - -Returns an Observable that notifies subscribers of each update to the uiSettings, including the key, newValue, and oldValue of the setting that changed. - -Signature: - -```typescript -getSaved$: () => Observable<{ - key: string; - newValue: T; - oldValue: T; - }>; -``` diff --git a/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.md b/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.md index 87ef5784a6c6d..d6f3b3186b542 100644 --- a/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.md +++ b/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.md @@ -19,14 +19,12 @@ export interface IUiSettingsClient | [get](./kibana-plugin-core-public.iuisettingsclient.get.md) | <T = any>(key: string, defaultOverride?: T) => T | Gets the value for a specific uiSetting. If this setting has no user-defined value then the defaultOverride parameter is returned (and parsed if setting is of type "json" or "number). If the parameter is not defined and the key is not registered by any plugin then an error is thrown, otherwise reads the default value defined by a plugin. | | [get$](./kibana-plugin-core-public.iuisettingsclient.get_.md) | <T = any>(key: string, defaultOverride?: T) => Observable<T> | Gets an observable of the current value for a config key, and all updates to that config key in the future. Providing a defaultOverride argument behaves the same as it does in \#get() | | [getAll](./kibana-plugin-core-public.iuisettingsclient.getall.md) | () => Readonly<Record<string, PublicUiSettingsParams & UserProvidedValues>> | Gets the metadata about all uiSettings, including the type, default value, and user value for each key. | -| [getSaved$](./kibana-plugin-core-public.iuisettingsclient.getsaved_.md) | <T = any>() => Observable<{
key: string;
newValue: T;
oldValue: T;
}> | Returns an Observable that notifies subscribers of each update to the uiSettings, including the key, newValue, and oldValue of the setting that changed. | | [getUpdate$](./kibana-plugin-core-public.iuisettingsclient.getupdate_.md) | <T = any>() => Observable<{
key: string;
newValue: T;
oldValue: T;
}> | Returns an Observable that notifies subscribers of each update to the uiSettings, including the key, newValue, and oldValue of the setting that changed. | | [getUpdateErrors$](./kibana-plugin-core-public.iuisettingsclient.getupdateerrors_.md) | () => Observable<Error> | Returns an Observable that notifies subscribers of each error while trying to update the settings, containing the actual Error class. | | [isCustom](./kibana-plugin-core-public.iuisettingsclient.iscustom.md) | (key: string) => boolean | Returns true if the setting wasn't registered by any plugin, but was either added directly via set(), or is an unknown setting found in the uiSettings saved object | | [isDeclared](./kibana-plugin-core-public.iuisettingsclient.isdeclared.md) | (key: string) => boolean | Returns true if the key is a "known" uiSetting, meaning it is either registered by any plugin or was previously added as a custom setting via the set() method. | | [isDefault](./kibana-plugin-core-public.iuisettingsclient.isdefault.md) | (key: string) => boolean | Returns true if the setting has no user-defined value or is unknown | | [isOverridden](./kibana-plugin-core-public.iuisettingsclient.isoverridden.md) | (key: string) => boolean | Shows whether the uiSettings value set by the user. | -| [overrideLocalDefault](./kibana-plugin-core-public.iuisettingsclient.overridelocaldefault.md) | (key: string, newDefault: any) => void | Overrides the default value for a setting in this specific browser tab. If the page is reloaded the default override is lost. | | [remove](./kibana-plugin-core-public.iuisettingsclient.remove.md) | (key: string) => Promise<boolean> | Removes the user-defined value for a setting, causing it to revert to the default. This method behaves the same as calling set(key, null), including the synchronization, custom setting, and error behavior of that method. | | [set](./kibana-plugin-core-public.iuisettingsclient.set.md) | (key: string, value: any) => Promise<boolean> | Sets the value for a uiSetting. If the setting is not registered by any plugin it will be stored as a custom setting. The new value will be synchronously available via the get() method and sent to the server in the background. If the request to the server fails then a updateErrors$ will be notified and the setting will be reverted to its value before set() was called. | diff --git a/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.overridelocaldefault.md b/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.overridelocaldefault.md deleted file mode 100644 index 0ae52e4959e10..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.overridelocaldefault.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) > [overrideLocalDefault](./kibana-plugin-core-public.iuisettingsclient.overridelocaldefault.md) - -## IUiSettingsClient.overrideLocalDefault property - -Overrides the default value for a setting in this specific browser tab. If the page is reloaded the default override is lost. - -Signature: - -```typescript -overrideLocalDefault: (key: string, newDefault: any) => void; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md index 193a2e5a24f3f..5fffc5436e9c6 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md @@ -7,7 +7,7 @@ Signature: ```typescript -SearchBar: React.ComponentClass, "query" | "placeholder" | "isLoading" | "iconType" | "indexPatterns" | "filters" | "dataTestSubj" | "isClearable" | "refreshInterval" | "nonKqlMode" | "nonKqlModeHelpText" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +SearchBar: React.ComponentClass, "query" | "placeholder" | "isLoading" | "iconType" | "indexPatterns" | "filters" | "dataTestSubj" | "refreshInterval" | "isClearable" | "nonKqlMode" | "nonKqlModeHelpText" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; } ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md deleted file mode 100644 index 984f99004ebe8..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md +++ /dev/null @@ -1,22 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [getSerializableOptions](./kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md) - -## SearchInterceptor.getSerializableOptions() method - -Signature: - -```typescript -protected getSerializableOptions(options?: ISearchOptions): Pick; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| options | ISearchOptions | | - -Returns: - -`Pick` - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md deleted file mode 100644 index 8ecd8b8c5ac22..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md +++ /dev/null @@ -1,15 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [getTimeoutMode](./kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md) - -## SearchInterceptor.getTimeoutMode() method - -Signature: - -```typescript -protected getTimeoutMode(): TimeoutErrorMode; -``` -Returns: - -`TimeoutErrorMode` - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md deleted file mode 100644 index f6421d65bc551..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md +++ /dev/null @@ -1,24 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [handleSearchError](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) - -## SearchInterceptor.handleSearchError() method - -Signature: - -```typescript -protected handleSearchError(e: KibanaServerError | AbortError, options?: ISearchOptions, isTimeout?: boolean): Error; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| e | KibanaServerError | AbortError | | -| options | ISearchOptions | | -| isTimeout | boolean | | - -Returns: - -`Error` - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md index 653f052dd5a3a..e1b27f45c6339 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md @@ -16,19 +16,11 @@ export declare class SearchInterceptor | --- | --- | --- | | [(constructor)(deps)](./kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md) | | Constructs a new instance of the SearchInterceptor class | -## Properties - -| Property | Modifiers | Type | Description | -| --- | --- | --- | --- | -| [deps](./kibana-plugin-plugins-data-public.searchinterceptor.deps.md) | | SearchInterceptorDeps | | - ## Methods | Method | Modifiers | Description | | --- | --- | --- | -| [getSerializableOptions(options)](./kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md) | | | -| [getTimeoutMode()](./kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md) | | | -| [handleSearchError(e, options, isTimeout)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | | -| [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | +| [search({ id, ...request }, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | | [showError(e)](./kibana-plugin-plugins-data-public.searchinterceptor.showerror.md) | | | +| [stop()](./kibana-plugin-plugins-data-public.searchinterceptor.stop.md) | | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md index a54b43da4add8..ab5ecbac119f3 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md @@ -9,19 +9,19 @@ Searches using the given `search` method. Overrides the `AbortSignal` with one t Signature: ```typescript -search(request: IKibanaSearchRequest, options?: ISearchOptions): Observable; +search({ id, ...request }: IKibanaSearchRequest, options?: IAsyncSearchOptions): import("rxjs").Observable>; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| request | IKibanaSearchRequest | | -| options | ISearchOptions | | +| { id, ...request } | IKibanaSearchRequest | | +| options | IAsyncSearchOptions | | Returns: -`Observable` +`import("rxjs").Observable>` `Observable` emitting the search response or an error. diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.deps.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.stop.md similarity index 54% rename from docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.deps.md rename to docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.stop.md index b517fb036798a..e0d77c5d13537 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.deps.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.stop.md @@ -1,11 +1,15 @@ -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [deps](./kibana-plugin-plugins-data-public.searchinterceptor.deps.md) +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [stop](./kibana-plugin-plugins-data-public.searchinterceptor.stop.md) -## SearchInterceptor.deps property +## SearchInterceptor.stop() method Signature: ```typescript -protected readonly deps: SearchInterceptorDeps; +stop(): void; ``` +Returns: + +`void` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md index 4369cf7c087da..8bc4b7606ab51 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md @@ -9,7 +9,7 @@ Fetch this source from Elasticsearch, returning an observable over the response( Signature: ```typescript -fetch$(options?: ISearchOptions): Observable>; +fetch$(options?: ISearchOptions): Observable>>; ``` ## Parameters @@ -20,5 +20,5 @@ fetch$(options?: ISearchOptions): Observable>; Returns: -`Observable>` +`Observable>>` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timeouterrormode.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timeouterrormode.md index 8ad63e2c1e9b4..bbf789b7dcf26 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timeouterrormode.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timeouterrormode.md @@ -14,7 +14,6 @@ export declare enum TimeoutErrorMode | Member | Value | Description | | --- | --- | --- | -| CHANGE | 2 | | -| CONTACT | 1 | | -| UPGRADE | 0 | | +| CHANGE | 1 | | +| CONTACT | 0 | | 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-add-filter.png b/docs/discover/images/discover-add-filter.png index e6f4685d31d60..4a5f6d1a9fa99 100644 Binary files a/docs/discover/images/discover-add-filter.png and b/docs/discover/images/discover-add-filter.png differ 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-from-visualize.png b/docs/discover/images/discover-from-visualize.png new file mode 100644 index 0000000000000..cbf64dff18b15 Binary files /dev/null and b/docs/discover/images/discover-from-visualize.png differ diff --git a/docs/discover/images/discover-index-pattern.png b/docs/discover/images/discover-index-pattern.png index a9bbc35e363d7..b9f96f1f0cedd 100644 Binary files a/docs/discover/images/discover-index-pattern.png and b/docs/discover/images/discover-index-pattern.png differ diff --git a/docs/discover/images/discover-sidebar-available-fields.png b/docs/discover/images/discover-sidebar-available-fields.png index 7f3514757eed5..b34119ca4a039 100644 Binary files a/docs/discover/images/discover-sidebar-available-fields.png and b/docs/discover/images/discover-sidebar-available-fields.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/document-table-expanded.png b/docs/discover/images/document-table-expanded.png index f92fdd8747fa4..87c8e2047a0cb 100644 Binary files a/docs/discover/images/document-table-expanded.png and b/docs/discover/images/document-table-expanded.png differ diff --git a/docs/discover/images/document-table.png b/docs/discover/images/document-table.png index 5b0383b522cea..f47ca5353124e 100644 Binary files a/docs/discover/images/document-table.png and b/docs/discover/images/document-table.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/images/find-manufacturer-field.png b/docs/discover/images/find-manufacturer-field.png new file mode 100644 index 0000000000000..f02672f531edf Binary files /dev/null and b/docs/discover/images/find-manufacturer-field.png differ diff --git a/docs/discover/images/saved-search.png b/docs/discover/images/saved-search.png new file mode 100644 index 0000000000000..cf0a89fe1a79f Binary files /dev/null and b/docs/discover/images/saved-search.png differ diff --git a/docs/discover/save-search.asciidoc b/docs/discover/save-search.asciidoc index b2baf8ee64672..edfdae9a6b081 100644 --- a/docs/discover/save-search.asciidoc +++ b/docs/discover/save-search.asciidoc @@ -1,38 +1,42 @@ [[save-open-search]] -== Save a search -A saved search persists your current view of Discover for -later retrieval and reuse. You can reload a saved search into Discover, -add it to a dashboard, and use it as the basis for a visualization. +== Save a search for reuse -A saved search includes the query text, filters, and optionally, the time filter. A saved search also includes the selected columns in the document table, the sort order, and the current index pattern. +A saved search is a convenient way to reuse a search +that you've created in *Discover*. +Saved searches are good for adding search results to a dashboard, +and can also serve as a foundation for building visualizations. + +[role="screenshot"] +image::discover/images/saved-search.png[Example of Discover's save search option] + + +A saved search stores the query text, filters, and +current view of *Discover*—the columns selected in the document table, +the sort order, and the index pattern. +Saved searches are different from <>, which +are primarily used for storing query text and are available in any app with a query bar. [role="xpack"] [[discover-read-only-access]] [float] === Read-only access -When you have insufficient privileges to save searches, the following indicator in Kibana will be -displayed and the *Save* button won't be visible. For more information on granting access to -Kibana see <>. +If you don't have sufficient privileges to save searches, the following indicator is +displayed and the *Save* button is not visible. For more information, refer to <>. [role="screenshot"] image::discover/images/read-only-badge.png[Example of Discover's read only access indicator in Kibana's header] [float] === Save a search -To save the current search: -. Click *Save* in the toolbar. +. Once you've created a search worth saving, click *Save* in the toolbar. . Enter a name for the search and click *Save*. - -To import, export, and delete saved searches, open the main menu, -then click *Stack Management > Saved Objects*. - -[float] -=== Open a saved search -To load a saved search into Discover: - -. Click *Open* in the toolbar. -. Select the search you want to open. - +. To reload your search results in *Discover*, click *Open* in the toolbar, and select the saved search. ++ If the saved search is associated with a different index pattern than is currently selected, opening the saved search changes the selected index pattern. The query language -used for the saved search will also be automatically selected. +used for the saved search is also automatically selected. +. To add your search results to an existing dashboard: +.. Open the dashboard, then click *Edit*. +.. Click *Add from library*. +.. Open the *Types* menu, then select *Saved search*. +.. Select the the saved search that you want. 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/maps/connect-to-ems.asciidoc b/docs/maps/connect-to-ems.asciidoc index 8e4695bfc6662..88301123bae3f 100644 --- a/docs/maps/connect-to-ems.asciidoc +++ b/docs/maps/connect-to-ems.asciidoc @@ -40,11 +40,9 @@ To disable EMS, change your <> file. [id=elastic-maps-server] === Host Elastic Maps Service locally -beta::[] - If you cannot connect to Elastic Maps Service from the {kib} server or browser clients, and your cluster has the appropriate license level, you can opt to host the service on your own infrastructure. -{hosted-ems} is a self-managed version of Elastic Maps Service offered as a Docker image that provides both the EMS basemaps and EMS boundaries. You must first download and run the image. After connecting it to your {es} cluster for license validation, you're guided to download and configure the basemaps database, which must be retrieved separately. +{hosted-ems} is a self-managed version of Elastic Maps Service offered as a Docker image that provides both the EMS basemaps and EMS boundaries. The image is bundled with basemaps up to zoom level 8. After connecting it to your {es} cluster for license validation, you have the option to download and configure a more detailed basemaps database. IMPORTANT: {hosted-ems} does not serve raster tiles, needed by Vega, coordinate, and region map visualizations. @@ -69,7 +67,7 @@ docker run --rm --init --publish 8080:8080 \ {ems-docker-image} ---------------------------------- -Once {hosted-ems} is running, follow instructions from the webpage at `localhost:8080` to define a configuration file and download the basemaps database. +Once {hosted-ems} is running, follow instructions from the webpage at `localhost:8080` to define a configuration file and optionally download a more detailed basemaps database. [role="screenshot"] image::images/elastic-maps-server-instructions.png[Set-up instructions] @@ -92,6 +90,9 @@ endif::[] | `port` | Specifies the port used by the backend server. Default: *`8080`*. <>. +| `basePath` + | Specify a path at which to mount the server if you are running behind a proxy. This setting cannot end in a slash (`/`). <>. + | `ui` | Controls the display of the status page and the layer preview. *Default: `true`* @@ -190,9 +191,13 @@ services: [[elastic-maps-server-data]] ==== Data -{hosted-ems} hosts vector layer boundaries and vector tile basemaps for the entire planet. Boundaries include world countries, global administrative regions, and specific country regions. A minimal basemap is provided with {hosted-ems}. This can be used for testing environments but won't be functional for normal operations. The full basemap (around 90GB file) needs to be mounted on the Docker container for {hosted-ems} to run normally. +{hosted-ems} hosts vector layer boundaries and vector tile basemaps for the entire planet. Boundaries include world countries, global administrative regions, and specific country regions. Basemaps up to zoom level 8 are bundled in the Docker image. These basemaps are sufficient for maps and dashboards at the country level. To present maps with higher detail, follow the instructions of the front page to download and configure the appropriate basemaps database. The most detailed basemaps at zoom level 14 are good for street level maps, but require ~90GB of disk space. + + +[role="screenshot"] +image::images/elastic-maps-server-basemaps.png[Basemaps download options] -TIP: The available basemaps and boundaries can be explored from the `/maps` endpoint in a web page that is your self-managed equivalent to https://maps.elastic.co +TIP: The available basemaps and boundaries can be explored from the `/maps` endpoint in a web page that is your self-managed equivalent to https://maps.elastic.co. [float] diff --git a/docs/maps/images/elastic-maps-server-basemaps.png b/docs/maps/images/elastic-maps-server-basemaps.png new file mode 100644 index 0000000000000..3f51153d2394b Binary files /dev/null and b/docs/maps/images/elastic-maps-server-basemaps.png differ diff --git a/docs/maps/images/elastic-maps-server-instructions.png b/docs/maps/images/elastic-maps-server-instructions.png index 17e9163a845c2..5c0b47ce8f49f 100644 Binary files a/docs/maps/images/elastic-maps-server-instructions.png and b/docs/maps/images/elastic-maps-server-instructions.png differ 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/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index c748d63484e28..50ed0d2652c6f 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -47,6 +47,88 @@ You can configure the following settings in the `kibana.yml` file. | A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly added to the allowed hosts. An empty list `[]` can be used to block built-in actions from making any external connections. + + Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically added to allowed hosts. If you are not using the default `[*]` setting, you must ensure that the corresponding endpoints are added to the allowed hosts as well. + +| `xpack.actions.customHostSettings` {ess-icon} + | A list of custom host settings to override existing global settings. + Defaults to an empty list. + + + + Each entry in the list must have a `url` property, to associate a connection + type (mail or https), hostname and port with the remaining options in the + entry. + + + In the following example, two custom host settings + are defined. The first provides a custom host setting for mail server + `mail.example.com` using port 465 that supplies server certificate authorization + data from both a file and inline, and requires TLS for the + connection. The second provides a custom host setting for https server + `webhook.example.com` which turns off server certificate authorization. + +|=== + +[source,yaml] +-- +xpack.actions.customHostSettings: + - url: smtp://mail.example.com:465 + tls: + certificateAuthoritiesFiles: [ 'one.crt' ] + certificateAuthoritiesData: | + -----BEGIN CERTIFICATE----- + ... multiple lines of certificate data here ... + -----END CERTIFICATE----- + smtp: + requireTLS: true + - url: https://webhook.example.com + tls: + rejectUnauthorized: false +-- + +[cols="2*<"] +|=== + +| `xpack.actions.customHostSettings[n]` +`.url` {ess-icon} + | A URL associated with this custom host setting. Should be in the form of + `protocol://hostname:port`, where `protocol` is `https` or `smtp`. If the + port is not provided, 443 is used for `https` and 25 is used for + `smtp`. The `smtp` URLs are used for the Email actions that use this + server, and the `https` URLs are used for actions which use `https` to + connect to services. + + + + Entries with `https` URLs can use the `tls` options, and entries with `smtp` + URLs can use both the `tls` and `smtp` options. + + + + No other URL values should be part of this URL, including paths, + query strings, and authentication information. When an http or smtp request + is made as part of executing an action, only the protocol, hostname, and + port of the URL for that request are used to look up these configuration + values. + +| `xpack.actions.customHostSettings[n]` +`.smtp.ignoreTLS` {ess-icon} + | A boolean value indicating that TLS must not be used for this connection. + The options `smtp.ignoreTLS` and `smtp.requireTLS` can not both be set to true. + +| `xpack.actions.customHostSettings[n]` +`.smtp.requireTLS` {ess-icon} + | A boolean value indicating that TLS must be used for this connection. + The options `smtp.ignoreTLS` and `smtp.requireTLS` can not both be set to true. + +| `xpack.actions.customHostSettings[n]` +`.tls.rejectUnauthorized` {ess-icon} + | A boolean value indicating whether to bypass server certificate validation. + Overrides the general `xpack.actions.rejectUnauthorized` configuration + for requests made for this hostname/port. + +| `xpack.actions.customHostSettings[n]` +`.tls.certificateAuthoritiesFiles` + | A file name or list of file names of PEM-encoded certificate files to use + to validate the server. + +| `xpack.actions.customHostSettings[n]` +`.tls.certificateAuthoritiesData` {ess-icon} + | The contents of a PEM-encoded certificate file, or multiple files appended + into a single string. This configuration can be used for environments where + the files cannot be made available. | `xpack.actions.enabledActionTypes` {ess-icon} | A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, and `.webhook`. An empty list `[]` will disable all action types. + @@ -79,13 +161,18 @@ a|`xpack.actions.` | `xpack.actions.rejectUnauthorized` {ess-icon} | Set to `false` to bypass certificate validation for actions. Defaults to `true`. + + - As an alternative to setting both `xpack.actions.proxyRejectUnauthorizedCertificates` and `xpack.actions.rejectUnauthorized`, you can point the OS level environment variable `NODE_EXTRA_CA_CERTS` to a file that contains the root CAs needed to trust certificates. + As an alternative to setting `xpack.actions.rejectUnauthorized`, you can use the setting + `xpack.actions.customHostSettings` to set TLS options for specific servers. | `xpack.actions.maxResponseContentLength` {ess-icon} | Specifies the max number of bytes of the http response for requests to external resources. Defaults to 1000000 (1MB). | `xpack.actions.responseTimeout` {ess-icon} - | Specifies the time allowed for requests to external resources. Requests that take longer are aborted. The time is formatted as [ms|s|m|h|d|w|M|Y], for example, '20m', '24h', '7d', '1w'. Defaults to 60s. + | Specifies the time allowed for requests to external resources. Requests that take longer are aborted. The time is formatted as: + + + + `[ms,s,m,h,d,w,M,Y]` + + + + For example, `20m`, `24h`, `7d`, `1w`. Defaults to `60s`. |=== diff --git a/docs/user/alerting/alerting-troubleshooting.asciidoc b/docs/user/alerting/alerting-troubleshooting.asciidoc index f4673d10bc248..6d4a0e9375678 100644 --- a/docs/user/alerting/alerting-troubleshooting.asciidoc +++ b/docs/user/alerting/alerting-troubleshooting.asciidoc @@ -53,3 +53,19 @@ Alerting and action tasks are identified by their type. When diagnosing issues related to Alerting, focus on the tasks that begin with `alerting:` and `actions:`. For more details on monitoring and diagnosing task execution in Task Manager, see <>. + +[float] +[[connector-tls-settings]] +=== Connectors have TLS errors when executing actions + +*Problem*: + +When executing actions, a connector gets a TLS socket error when connecting to +the server. + +*Resolution*: + +Configuration options are available to specialize connections to TLS servers, +including ignoring server certificate validation, and providing certificate +authority data to verify servers using custom certificates. For more details, +see <>. diff --git a/docs/user/discover.asciidoc b/docs/user/discover.asciidoc index 4565f7c9616c3..6e3a7f697073d 100644 --- a/docs/user/discover.asciidoc +++ b/docs/user/discover.asciidoc @@ -34,7 +34,7 @@ data and understand what’s going on at any given time. You’ll learn to: -- **Select** data for your exploration, and then set a time range for that data, +- **Select** data for your exploration, set a time range for that data, search it with the {kib} Query Language, and filter the results. - **Explore** the details of your data, view individual documents, and create tables that summarize the contents of the data. @@ -50,7 +50,8 @@ data in *Discover*. - If you don’t already have {kib}, set it up with https://www.elastic.co/cloud/elasticsearch-service/signup?baymax=docs-body&elektra=docs[our free trial]. - You must have data in {es}. This tutorial uses the <>, but you can use your own data. -- You should have an understanding of {ref}/documents-indices.html[{es} documents and indices]. +- You should have an understanding of {ref}/documents-indices.html[{es} documents and indices] +and <>. [float] @@ -68,11 +69,11 @@ your {es} data. To view the ecommerce sample data, make sure the index pattern is set to **kibana_sample_data_ecommerce**. + [role="screenshot"] -image::images/discover-index-pattern.png[How to set the index pattern in Discover] +image::images/discover-index-pattern.png[How to set the index pattern in Discover, width=50%] -. Adjust the time range to view data for the *Last 7 days*. +. Adjust the <> to view data for the *Last 7 days*. + -NOTE: The range selection is based on the default time field in your data. +The range selection is based on the default time field in your data. If you are using the sample data, this value was set when you added the data. If you are using your own data, and it does not have a time field, the range selection is not available. @@ -87,29 +88,29 @@ click and drag the mouse over the histogram. By default, the table includes columns for the time field and the document `_source`, which can be overwhelming. You’ll modify this table to display only your fields of interest. -. Scan through the list of **Available fields** to see -what’s in your data. You can also search for a field by name. +. Scan through the list of **Available fields** until you find the `manufacturer` field. +You can also search for the field by name. + [role="screenshot"] image:images/discover-sidebar-available-fields.png[Fields list that displays the top five search results, width=50%] -. Find the `manufacturer` field, and then click it to view the five most popular values for that field. -+ -**Discover** fetches a maximum of 500 documents, which it uses to calculate the popular values. +. Click the `manufacturer` field to view its five most popular values. + [role="screenshot"] -image:images/filter-field.png[Fields list that displays the top five search results] +image:images/find-manufacturer-field.png[Fields list that displays the top five search results, width=75%] . Click image:images/add-icon.png[Add icon] to toggle the field into the document table. -. Add `day of week` so your document table looks like this: +. Find the `day of week` field and add it to your document table. Your table should look like this: + [role="screenshot"] image:images/document-table.png[Document table with fields for manufacturer, geo.country_iso_code, and day_of_week] + . To rearrange the table columns, hover the mouse over a column header, and then use the move and sort controls. + [float] [[search-in-discover]] === Search your data @@ -142,16 +143,16 @@ Search the ecommerce data for documents where the country matches US: Whereas the query defines the set of documents you are interested in, filters enable you to zero in on different subsets of those documents. You can filter results to include or exclude specific fields, filter for a value in a range, -and more. The **Add filter** popup prompts you with the fields you can filter -and the operators you can use. - -[role="screenshot"] -image:images/discover-add-filter.png[Add filter dialog in Discover] +and more. Exclude documents where day of week is not Wednesday: . Click **Add filter**. . Set **Field** to *day_of_week*, **Operator** to *is not*, and **Value** to *Wednesday*. ++ +[role="screenshot"] +image:images/discover-add-filter.png[Add filter dialog in Discover] + . Save the filter. . Continue your exploration by adding more filters. . To remove a filter, @@ -164,21 +165,22 @@ click the close icon (x) next to its name in the filter bar. Dive into an individual document to view its fields and the documents that occurred before and after it. -. In the document table, expand any document. +. In the document table, expand any document. To view more of the document table, click *Hide chart*. + [role="screenshot"] image:images/document-table-expanded.png[Table view with document expanded] . Scan through the fields and their values. If you find a field of interest, -hover of its name for filters and other controls. +hover over 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. + [float] [[save-your-search]] === Save your search for later use @@ -209,22 +211,17 @@ image:images/discover-visualize.png[Discover sidebar field popover with visualiz the visualization builder pane. + [role="screenshot"] -image:images/visualize-from-discover.png[Visualization that opens from Discover based on your data] +image:images/discover-from-visualize.png[Visualization that opens from Discover based on your data] . Save your visualization for use on a dashboard. - -If your documents contain geo point fields (image:images/geoip-icon.png[Geo point field icon, width=20px]), you can visualize them in **Maps**. - -. Make sure the index pattern is set to **kibana_sample_data_ecommerce** and the configured time range -contains data. - -. From the **Available fields** list, click `geoip.location`, and then click **Visualize**. ++ +For geo point fields (image:images/geoip-icon.png[Geo point field icon, width=20px]), +if you click **Visualize**, +your data appears in a map. + [role="screenshot"] image:images/discover-maps.png[Map containing documents] -. Save your map for use on a dashboard. - [float] === What’s next? @@ -233,17 +230,15 @@ image:images/discover-maps.png[Map containing documents] * <>. +* <>. + * <> to better meet your needs. -In **Advanced Settings**, you can configure the number of documents to show, +Go to **Advanced Settings** to configure the number of documents to show, the table columns that display by default, and more. -* <> with even more visualizations of your findings, such as treemaps, metrics, and tables. - -* <>. - -- -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/examples/search_examples/public/search/app.tsx b/examples/search_examples/public/search/app.tsx index 65d939088515a..c9ede2ff2b45f 100644 --- a/examples/search_examples/public/search/app.tsx +++ b/examples/search_examples/public/search/app.tsx @@ -233,7 +233,7 @@ export const SearchExamplesApp = ({ } setRequest(searchSource.getSearchRequestBody()); - const res = await searchSource.fetch$().toPromise(); + const { rawResponse: res } = await searchSource.fetch$().toPromise(); setResponse(res); const message = Searched {res.hits.total} documents.; diff --git a/package.json b/package.json index 24355e25f6a8b..6be19669d25e1 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "@hapi/wreck": "^17.1.0", "@kbn/ace": "link:packages/kbn-ace", "@kbn/analytics": "link:packages/kbn-analytics", - "@kbn/apm-config-loader": "link:packages/kbn-apm-config-loader", + "@kbn/apm-config-loader": "link:bazel-bin/packages/kbn-apm-config-loader/npm_module", "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils/npm_module", "@kbn/config": "link:packages/kbn-config", "@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema/npm_module", @@ -132,7 +132,7 @@ "@kbn/interpreter": "link:packages/kbn-interpreter", "@kbn/io-ts-utils": "link:packages/kbn-io-ts-utils", "@kbn/legacy-logging": "link:packages/kbn-legacy-logging", - "@kbn/logging": "link:packages/kbn-logging", + "@kbn/logging": "link:bazel-bin/packages/kbn-logging/npm_module", "@kbn/monaco": "link:packages/kbn-monaco", "@kbn/server-http-tools": "link:packages/kbn-server-http-tools", "@kbn/server-route-repository": "link:packages/kbn-server-route-repository", @@ -208,7 +208,7 @@ "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", - "elastic-apm-node": "^3.10.0", + "elastic-apm-node": "^3.14.0", "elasticsearch": "^16.7.0", "execa": "^4.0.2", "exit-hook": "^2.2.0", @@ -369,7 +369,7 @@ "semver": "^7.3.2", "set-value": "^3.0.2", "source-map-support": "^0.5.19", - "squel": "^5.13.0", + "safe-squel": "^5.12.5", "stats-lite": "^2.2.0", "strip-ansi": "^6.0.0", "style-it": "^2.1.3", @@ -380,7 +380,7 @@ "tar": "4.4.13", "tinycolor2": "1.4.1", "tinygradient": "0.4.3", - "topojson-client": "3.0.0", + "topojson-client": "3.1.0", "tree-kill": "^1.2.2", "ts-easing": "^0.2.0", "tslib": "^2.0.0", @@ -426,12 +426,12 @@ "@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", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/eslint-config-kibana": "link:packages/elastic-eslint-config-kibana", + "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana/npm_module", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", "@elastic/makelogs": "^6.0.0", @@ -440,7 +440,7 @@ "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser/npm_module", "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset/npm_module", "@kbn/cli-dev-mode": "link:packages/kbn-cli-dev-mode", - "@kbn/dev-utils": "link:packages/kbn-dev-utils", + "@kbn/dev-utils": "link:bazel-bin/packages/kbn-dev-utils/npm_module", "@kbn/docs-utils": "link:packages/kbn-docs-utils", "@kbn/es": "link:packages/kbn-es", "@kbn/es-archiver": "link:packages/kbn-es-archiver", @@ -658,7 +658,7 @@ "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^89.0.0", + "chromedriver": "^90.0.0", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compression-webpack-plugin": "^4.0.0", @@ -757,7 +757,7 @@ "mochawesome-merge": "^4.2.0", "mock-fs": "^4.12.0", "mock-http-server": "1.3.0", - "ms-chromium-edge-driver": "^0.2.3", + "ms-chromium-edge-driver": "^0.4.2", "multimatch": "^4.0.0", "mutation-observer": "^1.0.3", "ncp": "^2.0.0", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 902c2804ee012..2aec108f97047 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -4,12 +4,16 @@ filegroup( name = "build", srcs = [ "//packages/elastic-datemath:build", + "//packages/elastic-eslint-config-kibana:build", "//packages/elastic-safer-lodash-set:build", + "//packages/kbn-apm-config-loader:build", "//packages/kbn-apm-utils:build", "//packages/kbn-babel-code-parser:build", "//packages/kbn-babel-preset:build", "//packages/kbn-config-schema:build", + "//packages/kbn-dev-utils:build", "//packages/kbn-expect:build", + "//packages/kbn-logging:build", "//packages/kbn-std:build", "//packages/kbn-tinymath:build", "//packages/kbn-utility-types:build", diff --git a/packages/elastic-eslint-config-kibana/BUILD.bazel b/packages/elastic-eslint-config-kibana/BUILD.bazel new file mode 100644 index 0000000000000..1745d919acdaa --- /dev/null +++ b/packages/elastic-eslint-config-kibana/BUILD.bazel @@ -0,0 +1,56 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "elastic-eslint-config-kibana" +PKG_REQUIRE_NAME = "@elastic/eslint-config-kibana" + +SOURCE_FILES = glob([ + ".eslintrc.js", + "javascript.js", + "jest.js", + "react.js", + "restricted_globals.js", + "typescript.js", +]) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +DEPS = [ + "//packages/kbn-dev-utils", + "@npm//eslint-config-prettier", + "@npm//semver", +] + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES + [ + ":srcs", + ], + deps = DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/elastic-eslint-config-kibana/package.json b/packages/elastic-eslint-config-kibana/package.json index 71283df00a8dd..5fb485b86fd38 100644 --- a/packages/elastic-eslint-config-kibana/package.json +++ b/packages/elastic-eslint-config-kibana/package.json @@ -16,8 +16,5 @@ "bugs": { "url": "https://github.com/elastic/kibana/tree/master/packages/elastic-eslint-config-kibana" }, - "homepage": "https://github.com/elastic/kibana/tree/master/packages/elastic-eslint-config-kibana", - "dependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" - } + "homepage": "https://github.com/elastic/kibana/tree/master/packages/elastic-eslint-config-kibana" } \ No newline at end of file diff --git a/packages/kbn-ace/package.json b/packages/kbn-ace/package.json index 5b4b0312aa1ae..30a87dbd1e21b 100644 --- a/packages/kbn-ace/package.json +++ b/packages/kbn-ace/package.json @@ -8,8 +8,5 @@ "scripts": { "build": "node ./scripts/build.js", "kbn:bootstrap": "yarn build --dev" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-analytics/package.json b/packages/kbn-analytics/package.json index 5b9db79febd77..2195de578081e 100644 --- a/packages/kbn-analytics/package.json +++ b/packages/kbn-analytics/package.json @@ -12,8 +12,5 @@ "build": "node scripts/build", "kbn:bootstrap": "node scripts/build --source-maps", "kbn:watch": "node scripts/build --source-maps --watch" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-analytics/src/reporter.ts b/packages/kbn-analytics/src/reporter.ts index 44e6758eb4643..c7c9cf1541d21 100644 --- a/packages/kbn-analytics/src/reporter.ts +++ b/packages/kbn-analytics/src/reporter.ts @@ -68,7 +68,7 @@ export class Reporter { } }; - private log(message: any) { + private log(message: unknown) { if (this.debug) { // eslint-disable-next-line console.debug(message); diff --git a/packages/kbn-analytics/src/storage.ts b/packages/kbn-analytics/src/storage.ts index b080a53029724..ac1084e807fc7 100644 --- a/packages/kbn-analytics/src/storage.ts +++ b/packages/kbn-analytics/src/storage.ts @@ -8,10 +8,10 @@ import { Report } from './report'; -export interface Storage { - get: (key: string) => T | null; +export interface Storage { + get: (key: string) => T | undefined; set: (key: string, value: T) => S; - remove: (key: string) => T | null; + remove: (key: string) => T | undefined; clear: () => void; } diff --git a/packages/kbn-analytics/src/util.ts b/packages/kbn-analytics/src/util.ts index 96e18c43e104f..b3768b4df94b8 100644 --- a/packages/kbn-analytics/src/util.ts +++ b/packages/kbn-analytics/src/util.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -export function wrapArray(subj: T | T[]): T[] { +export function wrapArray(subj: T | T[]): T[] { return Array.isArray(subj) ? subj : [subj]; } diff --git a/packages/kbn-apm-config-loader/BUILD.bazel b/packages/kbn-apm-config-loader/BUILD.bazel new file mode 100644 index 0000000000000..58a86ccfcf018 --- /dev/null +++ b/packages/kbn-apm-config-loader/BUILD.bazel @@ -0,0 +1,87 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-apm-config-loader" +PKG_REQUIRE_NAME = "@kbn/apm-config-loader" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*" + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md" +] + +SRC_DEPS = [ + "//packages/elastic-safer-lodash-set", + "//packages/kbn-utils", + "@npm//js-yaml", + "@npm//lodash", +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/js-yaml", + "@npm//@types/lodash", + "@npm//@types/node", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = [":tsc"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-apm-config-loader/package.json b/packages/kbn-apm-config-loader/package.json index b9dc324ec5e78..c096ed2efb92a 100644 --- a/packages/kbn-apm-config-loader/package.json +++ b/packages/kbn-apm-config-loader/package.json @@ -4,10 +4,5 @@ "types": "./target/index.d.ts", "version": "1.0.0", "license": "SSPL-1.0 OR Elastic License 2.0", - "private": true, - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - } + "private": true } \ No newline at end of file diff --git a/packages/kbn-apm-config-loader/tsconfig.json b/packages/kbn-apm-config-loader/tsconfig.json index 250195785b931..aa34b05061600 100644 --- a/packages/kbn-apm-config-loader/tsconfig.json +++ b/packages/kbn-apm-config-loader/tsconfig.json @@ -1,11 +1,12 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "./target", "stripInternal": false, "declaration": true, "declarationMap": true, + "rootDir": "./src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-apm-config-loader/src", "types": [ diff --git a/packages/kbn-cli-dev-mode/package.json b/packages/kbn-cli-dev-mode/package.json index cc91be0df4550..9def59623c938 100644 --- a/packages/kbn-cli-dev-mode/package.json +++ b/packages/kbn-cli-dev-mode/package.json @@ -15,9 +15,7 @@ }, "dependencies": { "@kbn/config": "link:../kbn-config", - "@kbn/logging": "link:../kbn-logging", "@kbn/server-http-tools": "link:../kbn-server-http-tools", - "@kbn/optimizer": "link:../kbn-optimizer", - "@kbn/dev-utils": "link:../kbn-dev-utils" + "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts index ff25f2a7bf55e..2fd53dd83a1bd 100644 --- a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts +++ b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts @@ -42,21 +42,25 @@ it('produces the right watch and ignore list', () => { /\\\\\\.\\(md\\|sh\\|txt\\)\\$/, /debug\\\\\\.log\\$/, /src/plugins/*/test/**, + /src/plugins/*/integration_tests/**, /src/plugins/*/build/**, /src/plugins/*/target/**, /src/plugins/*/scripts/**, /src/plugins/*/docs/**, /test/plugin_functional/plugins/*/test/**, + /test/plugin_functional/plugins/*/integration_tests/**, /test/plugin_functional/plugins/*/build/**, /test/plugin_functional/plugins/*/target/**, /test/plugin_functional/plugins/*/scripts/**, /test/plugin_functional/plugins/*/docs/**, /x-pack/plugins/*/test/**, + /x-pack/plugins/*/integration_tests/**, /x-pack/plugins/*/build/**, /x-pack/plugins/*/target/**, /x-pack/plugins/*/scripts/**, /x-pack/plugins/*/docs/**, /x-pack/test/plugin_functional/plugins/resolver_test/test/**, + /x-pack/test/plugin_functional/plugins/resolver_test/integration_tests/**, /x-pack/test/plugin_functional/plugins/resolver_test/build/**, /x-pack/test/plugin_functional/plugins/resolver_test/target/**, /x-pack/test/plugin_functional/plugins/resolver_test/scripts/**, diff --git a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts index 53aa53b5aa63a..4a9dae5c6fee2 100644 --- a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts +++ b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts @@ -28,6 +28,7 @@ export function getServerWatchPaths({ pluginPaths, pluginScanDirs }: Options) { (acc: string[], path) => [ ...acc, Path.resolve(path, 'test/**'), + Path.resolve(path, 'integration_tests/**'), Path.resolve(path, 'build/**'), Path.resolve(path, 'target/**'), Path.resolve(path, 'scripts/**'), diff --git a/packages/kbn-config-schema/tsconfig.json b/packages/kbn-config-schema/tsconfig.json index 5490f37a943fc..eee6900f07327 100644 --- a/packages/kbn-config-schema/tsconfig.json +++ b/packages/kbn-config-schema/tsconfig.json @@ -7,7 +7,7 @@ "outDir": "target", "rootDir": "src", "sourceMap": true, - "sourceRoot": "../../../../../packages/kbn-config-schema/src", + "sourceRoot": "../../../../packages/kbn-config-schema/src", "stripInternal": true, "types": [ "jest", diff --git a/packages/kbn-config/package.json b/packages/kbn-config/package.json index 1611da9aa60d4..b114cb13933d1 100644 --- a/packages/kbn-config/package.json +++ b/packages/kbn-config/package.json @@ -8,12 +8,5 @@ "scripts": { "build": "../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build" - }, - "dependencies": { - "@kbn/logging": "link:../kbn-logging" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", - "@kbn/utility-types": "link:../kbn-utility-types" } } \ No newline at end of file diff --git a/packages/kbn-crypto/package.json b/packages/kbn-crypto/package.json index 7e26b96218319..0787427c60b10 100644 --- a/packages/kbn-crypto/package.json +++ b/packages/kbn-crypto/package.json @@ -9,9 +9,5 @@ "build": "../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build", "kbn:watch": "yarn build --watch" - }, - "dependencies": {}, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-dev-utils/BUILD.bazel b/packages/kbn-dev-utils/BUILD.bazel new file mode 100644 index 0000000000000..e3935040240dc --- /dev/null +++ b/packages/kbn-dev-utils/BUILD.bazel @@ -0,0 +1,128 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-dev-utils" +PKG_REQUIRE_NAME = "@kbn/dev-utils" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*" + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +filegroup( + name = "certs", + srcs = glob( + [ + "certs/**/*", + ], + exclude = [ + "**/README.md" + ], + ), +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", + ":certs", + "ci_stats_reporter/package.json", + "stdio/package.json", + "tooling_log/package.json" +] + +SRC_DEPS = [ + "//packages/kbn-expect", + "//packages/kbn-utils", + "@npm//@babel/core", + "@npm//axios", + "@npm//chalk", + "@npm//chance", + "@npm//cheerio", + "@npm//dedent", + "@npm//execa", + "@npm//exit-hook", + "@npm//getopts", + "@npm//globby", + "@npm//jest-styled-components", + "@npm//load-json-file", + "@npm//markdown-it", + "@npm//moment", + "@npm//normalize-path", + "@npm//rxjs", + "@npm//tree-kill", + "@npm//tslib", + "@npm//typescript", + "@npm//vinyl" +] + +TYPES_DEPS = [ + "@npm//@types/babel__core", + "@npm//@types/cheerio", + "@npm//@types/dedent", + "@npm//@types/flot", + "@npm//@types/jest", + "@npm//@types/markdown-it", + "@npm//@types/node", + "@npm//@types/normalize-path", + "@npm//@types/react", + "@npm//@types/testing-library__jest-dom", + "@npm//@types/vinyl" +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = [":tsc"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-dev-utils/package.json b/packages/kbn-dev-utils/package.json index 4ce2880afbbda..90c5ef17d1859 100644 --- a/packages/kbn-dev-utils/package.json +++ b/packages/kbn-dev-utils/package.json @@ -5,15 +5,7 @@ "license": "SSPL-1.0 OR Elastic License 2.0", "main": "./target/index.js", "types": "./target/index.d.ts", - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - }, "kibana": { "devOnly": true - }, - "devDependencies": { - "@kbn/expect": "link:../kbn-expect" } } \ No newline at end of file diff --git a/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts b/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts index b88382c3b0da4..127e2a9904a4f 100644 --- a/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts +++ b/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import Fs from 'fs'; import Path from 'path'; import normalizePath from 'normalize-path'; @@ -49,7 +48,7 @@ NOTE: node scripts/build_plugin_list_docs You can update the template within ${normalizePath( - Path.relative(REPO_ROOT, Fs.realpathSync(Path.resolve(__dirname, __filename))) + Path.relative(REPO_ROOT, Path.resolve(__dirname, __filename)) )} //// diff --git a/packages/kbn-dev-utils/tsconfig.json b/packages/kbn-dev-utils/tsconfig.json index 65536c576b679..5bb7bd0424daf 100644 --- a/packages/kbn-dev-utils/tsconfig.json +++ b/packages/kbn-dev-utils/tsconfig.json @@ -1,12 +1,13 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "target", "stripInternal": false, "target": "ES2019", "declaration": true, "declarationMap": true, + "rootDir": "src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-dev-utils/src", "types": [ diff --git a/packages/kbn-docs-utils/package.json b/packages/kbn-docs-utils/package.json index e2db07001b543..6aca554f0f945 100644 --- a/packages/kbn-docs-utils/package.json +++ b/packages/kbn-docs-utils/package.json @@ -13,7 +13,6 @@ "kbn:watch": "../../node_modules/.bin/tsc --watch" }, "dependencies": { - "@kbn/config": "link:../kbn-config", - "@kbn/dev-utils": "link:../kbn-dev-utils" + "@kbn/config": "link:../kbn-config" } } \ No newline at end of file diff --git a/packages/kbn-es-archiver/package.json b/packages/kbn-es-archiver/package.json index 0e4c9884d2c39..c86d94c70d739 100644 --- a/packages/kbn-es-archiver/package.json +++ b/packages/kbn-es-archiver/package.json @@ -13,7 +13,6 @@ "kbn:watch": "rm -rf target && ../../node_modules/.bin/tsc --watch" }, "dependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", "@kbn/test": "link:../kbn-test" } } \ No newline at end of file diff --git a/packages/kbn-es/package.json b/packages/kbn-es/package.json index f47f042505cad..e7356794b6113 100644 --- a/packages/kbn-es/package.json +++ b/packages/kbn-es/package.json @@ -11,8 +11,5 @@ "build": "node scripts/build", "kbn:bootstrap": "node scripts/build", "kbn:watch": "node scripts/build --watch" - }, - "dependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index 236d5cf252136..c55e5d3513c44 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -246,7 +246,10 @@ exports.Cluster = class Cluster { this._log.info(chalk.bold('Starting')); this._log.indent(4); - const esArgs = ['action.destructive_requires_name=true'].concat(options.esArgs || []); + const esArgs = [ + 'action.destructive_requires_name=true', + 'ingest.geoip.downloader.enabled=false', + ].concat(options.esArgs || []); // Add to esArgs if ssl is enabled if (this._ssl) { @@ -272,7 +275,7 @@ exports.Cluster = class Cluster { // especially because we currently run many instances of ES on the same machine during CI options.esEnvVars.ES_JAVA_OPTS = (options.esEnvVars.ES_JAVA_OPTS ? `${options.esEnvVars.ES_JAVA_OPTS} ` : '') + - '-Xms2g -Xmx2g'; + '-Xms1g -Xmx1g'; this._process = execa(ES_BIN, args, { cwd: installPath, diff --git a/packages/kbn-es/src/integration_tests/cluster.test.js b/packages/kbn-es/src/integration_tests/cluster.test.js index 9a9ffb2afc331..6b4025840283f 100644 --- a/packages/kbn-es/src/integration_tests/cluster.test.js +++ b/packages/kbn-es/src/integration_tests/cluster.test.js @@ -266,6 +266,7 @@ describe('#start(installPath)', () => { Array [ Array [ "action.destructive_requires_name=true", + "ingest.geoip.downloader.enabled=false", ], undefined, Object { @@ -344,6 +345,7 @@ describe('#run()', () => { Array [ Array [ "action.destructive_requires_name=true", + "ingest.geoip.downloader.enabled=false", ], undefined, Object { diff --git a/packages/kbn-i18n/package.json b/packages/kbn-i18n/package.json index 570110589490b..1f9d21f724ea8 100644 --- a/packages/kbn-i18n/package.json +++ b/packages/kbn-i18n/package.json @@ -10,8 +10,5 @@ "build": "node scripts/build", "kbn:bootstrap": "node scripts/build --source-maps", "kbn:watch": "node scripts/build --watch --source-maps" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-interpreter/package.json b/packages/kbn-interpreter/package.json index 491a7205be210..997fbb0eb8a4f 100644 --- a/packages/kbn-interpreter/package.json +++ b/packages/kbn-interpreter/package.json @@ -11,8 +11,5 @@ }, "dependencies": { "@kbn/i18n": "link:../kbn-i18n" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-logging/BUILD.bazel b/packages/kbn-logging/BUILD.bazel new file mode 100644 index 0000000000000..f42ca22ae5256 --- /dev/null +++ b/packages/kbn-logging/BUILD.bazel @@ -0,0 +1,82 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-logging" +PKG_REQUIRE_NAME = "@kbn/logging" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*" + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md" +] + +SRC_DEPS = [ + "//packages/kbn-std" +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/node", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = [":tsc"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-logging/package.json b/packages/kbn-logging/package.json index 596eda1fe625a..d80cc1c40d7e1 100644 --- a/packages/kbn-logging/package.json +++ b/packages/kbn-logging/package.json @@ -4,10 +4,5 @@ "private": true, "license": "SSPL-1.0 OR Elastic License 2.0", "main": "./target/index.js", - "types": "./target/index.d.ts", - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - } + "types": "./target/index.d.ts" } \ No newline at end of file diff --git a/packages/kbn-logging/tsconfig.json b/packages/kbn-logging/tsconfig.json index adec4c1966036..78985b823dd95 100644 --- a/packages/kbn-logging/tsconfig.json +++ b/packages/kbn-logging/tsconfig.json @@ -1,11 +1,12 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "target", "stripInternal": false, "declaration": true, "declarationMap": true, + "rootDir": "src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-logging/src", "types": [ diff --git a/packages/kbn-monaco/package.json b/packages/kbn-monaco/package.json index f4309e08f5bdb..75f1d74f1c9c9 100644 --- a/packages/kbn-monaco/package.json +++ b/packages/kbn-monaco/package.json @@ -10,9 +10,6 @@ "kbn:bootstrap": "yarn build --dev", "build:antlr4ts": "../../node_modules/antlr4ts-cli/antlr4ts ./src/painless/antlr/painless_lexer.g4 ./src/painless/antlr/painless_parser.g4 && node ./scripts/fix_generated_antlr.js" }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" - }, "dependencies": { "@kbn/i18n": "link:../kbn-i18n" } 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/packages/kbn-optimizer/package.json b/packages/kbn-optimizer/package.json index 423bba0fd8c7a..f193fcf898a3d 100644 --- a/packages/kbn-optimizer/package.json +++ b/packages/kbn-optimizer/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@kbn/config": "link:../kbn-config", - "@kbn/dev-utils": "link:../kbn-dev-utils", "@kbn/ui-shared-deps": "link:../kbn-ui-shared-deps" } } \ No newline at end of file diff --git a/packages/kbn-plugin-generator/package.json b/packages/kbn-plugin-generator/package.json index ae4dfbc670f19..583085430d915 100644 --- a/packages/kbn-plugin-generator/package.json +++ b/packages/kbn-plugin-generator/package.json @@ -8,8 +8,5 @@ "scripts": { "kbn:bootstrap": "node scripts/build", "kbn:watch": "node scripts/build --watch" - }, - "dependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-plugin-helpers/package.json b/packages/kbn-plugin-helpers/package.json index 6b9dd4d51baf9..2d642d9ede13b 100644 --- a/packages/kbn-plugin-helpers/package.json +++ b/packages/kbn-plugin-helpers/package.json @@ -17,7 +17,6 @@ "kbn:watch": "../../node_modules/.bin/tsc --watch" }, "dependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-pm/package.json b/packages/kbn-pm/package.json index c46906112b2e2..72061c9625b09 100644 --- a/packages/kbn-pm/package.json +++ b/packages/kbn-pm/package.json @@ -11,8 +11,5 @@ "build": "../../node_modules/.bin/webpack", "kbn:watch": "../../node_modules/.bin/webpack --watch", "prettier": "../../node_modules/.bin/prettier --write './src/**/*.ts'" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-storybook/package.json b/packages/kbn-storybook/package.json index fdc7359aab58d..0e70f7c340a90 100644 --- a/packages/kbn-storybook/package.json +++ b/packages/kbn-storybook/package.json @@ -12,8 +12,5 @@ "build": "../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build", "watch": "yarn build --watch" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-telemetry-tools/package.json b/packages/kbn-telemetry-tools/package.json index 2ae1f596a1c68..31fac5c043832 100644 --- a/packages/kbn-telemetry-tools/package.json +++ b/packages/kbn-telemetry-tools/package.json @@ -12,9 +12,5 @@ "build": "../../node_modules/.bin/babel src --out-dir target --delete-dir-on-start --extensions .ts --source-maps=inline", "kbn:bootstrap": "yarn build", "kbn:watch": "yarn build --watch" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", - "@kbn/utility-types": "link:../kbn-utility-types" } } \ No newline at end of file diff --git a/packages/kbn-test/package.json b/packages/kbn-test/package.json index 9bf8a01e031cc..15d6ac90b2ebe 100644 --- a/packages/kbn-test/package.json +++ b/packages/kbn-test/package.json @@ -17,9 +17,5 @@ "@kbn/es": "link:../kbn-es", "@kbn/i18n": "link:../kbn-i18n", "@kbn/optimizer": "link:../kbn-optimizer" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", - "@kbn/expect": "link:../kbn-expect" } } \ No newline at end of file diff --git a/packages/kbn-tinymath/grammar/grammar.peggy b/packages/kbn-tinymath/grammar/grammar.peggy index 70f275776e45d..cbcb0b91bfea9 100644 --- a/packages/kbn-tinymath/grammar/grammar.peggy +++ b/packages/kbn-tinymath/grammar/grammar.peggy @@ -43,7 +43,7 @@ Literal "literal" // Quoted variables are interpreted as strings // but unquoted variables are more restrictive Variable - = _ Quote chars:(ValidChar / Space)* Quote _ { + = _ [\'] chars:(ValidChar / Space / [\"])* [\'] _ { return { type: 'variable', value: chars.join(''), @@ -51,6 +51,14 @@ Variable text: text() }; } + / _ [\"] chars:(ValidChar / Space / [\'])* [\"] _ { + return { + type: 'variable', + value: chars.join(''), + location: simpleLocation(location()), + text: text() + }; + } / _ rest:ValidChar+ _ { return { type: 'variable', @@ -103,10 +111,9 @@ Argument_List "arguments" } String - = [\"] value:(ValidChar)+ [\"] { return value.join(''); } - / [\'] value:(ValidChar)+ [\'] { return value.join(''); } - / value:(ValidChar)+ { return value.join(''); } - + = '"' chars:("\\\"" { return "\""; } / [^"])* '"' { return chars.join(''); } + / "'" chars:("\\\'" { return "\'"; } / [^'])* "'" { return chars.join(''); } + / chars:(ValidChar)+ { return chars.join(''); } Argument = name:[a-zA-Z_]+ _ '=' _ value:(Number / String) _ { diff --git a/packages/kbn-tinymath/test/library.test.js b/packages/kbn-tinymath/test/library.test.js index 5ddf1b049b8d4..bf1c7a9dbc5fb 100644 --- a/packages/kbn-tinymath/test/library.test.js +++ b/packages/kbn-tinymath/test/library.test.js @@ -73,6 +73,7 @@ describe('Parser', () => { expect(parse('"foo bar"')).toEqual(variableEqual('foo bar')); expect(parse('"foo bar fizz buzz"')).toEqual(variableEqual('foo bar fizz buzz')); expect(parse('"foo bar baby"')).toEqual(variableEqual('foo bar baby')); + expect(parse(`"f'oo"`)).toEqual(variableEqual(`f'oo`)); }); it('strings with single quotes', () => { @@ -88,6 +89,7 @@ describe('Parser', () => { expect(parse("' foo bar'")).toEqual(variableEqual(" foo bar")); expect(parse("'foo bar '")).toEqual(variableEqual("foo bar ")); expect(parse("'0foo'")).toEqual(variableEqual("0foo")); + expect(parse(`'f"oo'`)).toEqual(variableEqual(`f"oo`)); /* eslint-enable prettier/prettier */ }); @@ -138,10 +140,18 @@ describe('Parser', () => { ); }); + it('named argument is empty string', () => { + expect(parse('foo(q="")')).toEqual(functionEqual('foo', [namedArgumentEqual('q', '')])); + expect(parse(`foo(q='')`)).toEqual(functionEqual('foo', [namedArgumentEqual('q', '')])); + }); + it('named and positional', () => { expect(parse('foo(ref, q="bar")')).toEqual( functionEqual('foo', [variableEqual('ref'), namedArgumentEqual('q', 'bar')]) ); + expect(parse(`foo(ref, q='ba"r')`)).toEqual( + functionEqual('foo', [variableEqual('ref'), namedArgumentEqual('q', `ba"r`)]) + ); }); it('numerically named', () => { @@ -182,6 +192,21 @@ describe('Parser', () => { it('invalid named', () => { expect(() => parse('foo(offset-type="1d")')).toThrow('but "(" found'); }); + + it('named with complex strings', () => { + expect(parse(`foo(filter='😀 > "\ttab"')`)).toEqual( + functionEqual('foo', [namedArgumentEqual('filter', `😀 > "\ttab"`)]) + ); + }); + + it('named with escape characters', () => { + expect(parse(`foo(filter='Women\\'s Clothing')`)).toEqual( + functionEqual('foo', [namedArgumentEqual('filter', `Women's Clothing`)]) + ); + expect(parse(`foo(filter="\\"Quoted inner string\\"")`)).toEqual( + functionEqual('foo', [namedArgumentEqual('filter', `"Quoted inner string"`)]) + ); + }); }); it('Missing expression', () => { diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index 00c6f677cd223..8b08f64ba0f62 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -12,8 +12,5 @@ "@kbn/analytics": "link:../kbn-analytics", "@kbn/i18n": "link:../kbn-i18n", "@kbn/monaco": "link:../kbn-monaco" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file 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..724998699da85 100644 --- a/src/cli/serve/integration_tests/invalid_config.test.ts +++ b/src/cli/serve/integration_tests/invalid_config.test.ts @@ -14,7 +14,7 @@ const INVALID_CONFIG_PATH = require.resolve('./__fixtures__/invalid_config.yml') interface LogEntry { message: string; - tags: string[]; + tags?: string[]; type: string; } @@ -32,18 +32,25 @@ describe('cli invalid config support', function () { } ); - const [fatalLogLine] = stdout - .toString('utf8') - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line) as LogEntry) - .filter((line) => line.tags.includes('fatal')) - .map((obj) => ({ - ...obj, - pid: '## PID ##', - '@timestamp': '## @timestamp ##', - error: '## Error with stack trace ##', - })); + let fatalLogLine; + try { + [fatalLogLine] = stdout + .toString('utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as LogEntry) + .filter((line) => line.tags?.includes('fatal')) + .map((obj) => ({ + ...obj, + pid: '## PID ##', + '@timestamp': '## @timestamp ##', + error: '## Error with stack trace ##', + })); + } catch (e) { + throw new Error( + `error parsing log output:\n\n${e.stack}\n\nstdout: \n${stdout}\n\nstderr:\n${stderr}` + ); + } expect(error).toBe(undefined); diff --git a/src/core/public/core_app/status/lib/load_status.test.ts b/src/core/public/core_app/status/lib/load_status.test.ts index e15c0e81f3d20..349f20a2385c6 100644 --- a/src/core/public/core_app/status/lib/load_status.test.ts +++ b/src/core/public/core_app/status/lib/load_status.test.ts @@ -100,6 +100,51 @@ describe('response processing', () => { expect(data.name).toEqual('My computer'); }); + test('throws when an error occurs', async () => { + http.get.mockReset(); + + http.get.mockRejectedValue(new Error()); + + await expect(loadStatus({ http, notifications })).rejects.toThrowError(); + expect(notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + }); + + test('throws when a 503 occurs which does not contain an appropriate payload', async () => { + const error = new Error() as any; + error.response = { status: 503 }; + error.body = {}; + + http.get.mockReset(); + http.get.mockRejectedValue(error); + + await expect(loadStatus({ http, notifications })).rejects.toThrowError(); + expect(notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + }); + + test('does not throw when a 503 occurs which contains an appropriate payload', async () => { + const error = new Error() as any; + error.response = { status: 503 }; + error.body = mockedResponse; + + http.get.mockReset(); + http.get.mockRejectedValue(error); + + const data = await loadStatus({ http, notifications }); + expect(data.name).toEqual('My computer'); + }); + + test('throws when a non-503 occurs which contains an appropriate payload', async () => { + const error = new Error() as any; + error.response = { status: 500 }; + error.body = mockedResponse; + + http.get.mockReset(); + http.get.mockRejectedValue(error); + + await expect(loadStatus({ http, notifications })).rejects.toThrowError(); + expect(notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + }); + test('includes the plugin statuses', async () => { const data = await loadStatus({ http, notifications }); expect(data.statuses).toEqual([ diff --git a/src/core/public/core_app/status/lib/load_status.ts b/src/core/public/core_app/status/lib/load_status.ts index 3c31da4c5397f..0748c3dfe1dec 100644 --- a/src/core/public/core_app/status/lib/load_status.ts +++ b/src/core/public/core_app/status/lib/load_status.ts @@ -113,21 +113,32 @@ export async function loadStatus({ try { response = await http.get('/api/status'); } catch (e) { - if ((e.response?.status ?? 0) >= 400) { - notifications.toasts.addDanger( - i18n.translate('core.statusPage.loadStatus.serverStatusCodeErrorMessage', { - defaultMessage: 'Failed to request server status with status code {responseStatus}', - values: { responseStatus: e.response?.status }, - }) - ); + // API returns a 503 response if not all services are available. + // In this case, we want to treat this as a successful API call, so that we can + // display Kibana's status correctly. + // 503 responses can happen for other reasons (such as proxies), so we make an educated + // guess here to determine if the response payload looks like an appropriate `StatusResponse`. + const ignoreError = e.response?.status === 503 && typeof e.body?.name === 'string'; + + if (ignoreError) { + response = e.body; } else { - notifications.toasts.addDanger( - i18n.translate('core.statusPage.loadStatus.serverIsDownErrorMessage', { - defaultMessage: 'Failed to request server status. Perhaps your server is down?', - }) - ); + if ((e.response?.status ?? 0) >= 400) { + notifications.toasts.addDanger( + i18n.translate('core.statusPage.loadStatus.serverStatusCodeErrorMessage', { + defaultMessage: 'Failed to request server status with status code {responseStatus}', + values: { responseStatus: e.response?.status }, + }) + ); + } else { + notifications.toasts.addDanger( + i18n.translate('core.statusPage.loadStatus.serverIsDownErrorMessage', { + defaultMessage: 'Failed to request server status. Perhaps your server is down?', + }) + ); + } + throw e; } - throw e; } return { diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 26e2986abb928..1f502007f51dd 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -911,11 +911,6 @@ export interface IUiSettingsClient { get$: (key: string, defaultOverride?: T) => Observable; get: (key: string, defaultOverride?: T) => T; getAll: () => Readonly>; - getSaved$: () => Observable<{ - key: string; - newValue: T; - oldValue: T; - }>; getUpdate$: () => Observable<{ key: string; newValue: T; @@ -926,7 +921,6 @@ export interface IUiSettingsClient { isDeclared: (key: string) => boolean; isDefault: (key: string) => boolean; isOverridden: (key: string) => boolean; - overrideLocalDefault: (key: string, newDefault: any) => void; remove: (key: string) => Promise; set: (key: string, value: any) => Promise; } diff --git a/src/core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap b/src/core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap index cd233704d2f54..b9526f26a0c1e 100644 --- a/src/core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap +++ b/src/core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap @@ -44,81 +44,6 @@ Array [ ] `; -exports[`#overrideLocalDefault key has no user value calls subscriber with new and previous value: single subscriber call 1`] = ` -Array [ - Array [ - Object { - "key": "dateFormat", - "newValue": "bar", - "oldValue": "Browser", - }, - ], -] -`; - -exports[`#overrideLocalDefault key has no user value synchronously modifies the default value returned by get(): get after override 1`] = `"bar"`; - -exports[`#overrideLocalDefault key has no user value synchronously modifies the default value returned by get(): get before override 1`] = `"Browser"`; - -exports[`#overrideLocalDefault key has no user value synchronously modifies the value returned by getAll(): getAll after override 1`] = ` -Object { - "dateFormat": Object { - "value": "bar", - }, -} -`; - -exports[`#overrideLocalDefault key has no user value synchronously modifies the value returned by getAll(): getAll before override 1`] = ` -Object { - "dateFormat": Object { - "value": "Browser", - }, -} -`; - -exports[`#overrideLocalDefault key with user value does not modify the return value of get: get after override 1`] = `"foo"`; - -exports[`#overrideLocalDefault key with user value does not modify the return value of get: get before override 1`] = `"foo"`; - -exports[`#overrideLocalDefault key with user value is included in the return value of getAll: getAll after override 1`] = ` -Object { - "dateFormat": Object { - "userValue": "foo", - "value": "bar", - }, -} -`; - -exports[`#overrideLocalDefault key with user value is included in the return value of getAll: getAll before override 1`] = ` -Object { - "dateFormat": Object { - "userValue": "foo", - "value": "Browser", - }, -} -`; - -exports[`#overrideLocalDefault key with user value returns default override when setting removed: get after override 1`] = `"bar"`; - -exports[`#overrideLocalDefault key with user value returns default override when setting removed: get before override 1`] = `"foo"`; - -exports[`#overrideLocalDefault key with user value returns default override when setting removed: getAll after override 1`] = ` -Object { - "dateFormat": Object { - "value": "bar", - }, -} -`; - -exports[`#overrideLocalDefault key with user value returns default override when setting removed: getAll before override 1`] = ` -Object { - "dateFormat": Object { - "userValue": "foo", - "value": "bar", - }, -} -`; - exports[`#remove throws an error if key is overridden 1`] = `"Unable to update \\"bar\\" because its value is overridden by the Kibana server"`; exports[`#set throws an error if key is overridden 1`] = `"Unable to update \\"foo\\" because its value is overridden by the Kibana server"`; diff --git a/src/core/public/ui_settings/types.ts b/src/core/public/ui_settings/types.ts index 4ae09094861e4..77c1feb25b6bc 100644 --- a/src/core/public/ui_settings/types.ts +++ b/src/core/public/ui_settings/types.ts @@ -53,12 +53,6 @@ export interface IUiSettingsClient { */ set: (key: string, value: any) => Promise; - /** - * Overrides the default value for a setting in this specific browser tab. If the page - * is reloaded the default override is lost. - */ - overrideLocalDefault: (key: string, newDefault: any) => void; - /** * Removes the user-defined value for a setting, causing it to revert to the default. This * method behaves the same as calling `set(key, null)`, including the synchronization, custom @@ -99,16 +93,6 @@ export interface IUiSettingsClient { oldValue: T; }>; - /** - * Returns an Observable that notifies subscribers of each update to the uiSettings, - * including the key, newValue, and oldValue of the setting that changed. - */ - getSaved$: () => Observable<{ - key: string; - newValue: T; - oldValue: T; - }>; - /** * Returns an Observable that notifies subscribers of each error while trying to update * the settings, containing the actual Error class. diff --git a/src/core/public/ui_settings/ui_settings_client.test.ts b/src/core/public/ui_settings/ui_settings_client.test.ts index 40e04a46c0001..f8c5dbfc347dd 100644 --- a/src/core/public/ui_settings/ui_settings_client.test.ts +++ b/src/core/public/ui_settings/ui_settings_client.test.ts @@ -279,119 +279,3 @@ describe('#getUpdate$', () => { expect(onComplete).toHaveBeenCalled(); }); }); - -describe('#overrideLocalDefault', () => { - describe('key has no user value', () => { - it('synchronously modifies the default value returned by get()', () => { - const { client } = setup(); - - expect(client.get('dateFormat')).toMatchSnapshot('get before override'); - client.overrideLocalDefault('dateFormat', 'bar'); - expect(client.get('dateFormat')).toMatchSnapshot('get after override'); - }); - - it('synchronously modifies the value returned by getAll()', () => { - const { client } = setup(); - - expect(client.getAll()).toMatchSnapshot('getAll before override'); - client.overrideLocalDefault('dateFormat', 'bar'); - expect(client.getAll()).toMatchSnapshot('getAll after override'); - }); - - it('calls subscriber with new and previous value', () => { - const handler = jest.fn(); - const { client } = setup(); - - client.getUpdate$().subscribe(handler); - client.overrideLocalDefault('dateFormat', 'bar'); - expect(handler.mock.calls).toMatchSnapshot('single subscriber call'); - }); - }); - - describe('key with user value', () => { - it('does not modify the return value of get', () => { - const { client } = setup(); - - client.set('dateFormat', 'foo'); - expect(client.get('dateFormat')).toMatchSnapshot('get before override'); - client.overrideLocalDefault('dateFormat', 'bar'); - expect(client.get('dateFormat')).toMatchSnapshot('get after override'); - }); - - it('is included in the return value of getAll', () => { - const { client } = setup(); - - client.set('dateFormat', 'foo'); - expect(client.getAll()).toMatchSnapshot('getAll before override'); - client.overrideLocalDefault('dateFormat', 'bar'); - expect(client.getAll()).toMatchSnapshot('getAll after override'); - }); - - it('does not call subscriber', () => { - const handler = jest.fn(); - const { client } = setup(); - - client.set('dateFormat', 'foo'); - client.getUpdate$().subscribe(handler); - client.overrideLocalDefault('dateFormat', 'bar'); - expect(handler).not.toHaveBeenCalled(); - }); - - it('returns default override when setting removed', () => { - const { client } = setup(); - - client.set('dateFormat', 'foo'); - client.overrideLocalDefault('dateFormat', 'bar'); - - expect(client.get('dateFormat')).toMatchSnapshot('get before override'); - expect(client.getAll()).toMatchSnapshot('getAll before override'); - - client.remove('dateFormat'); - - expect(client.get('dateFormat')).toMatchSnapshot('get after override'); - expect(client.getAll()).toMatchSnapshot('getAll after override'); - }); - }); - - describe('#isOverridden()', () => { - it('returns false if key is unknown', () => { - const { client } = setup(); - expect(client.isOverridden('foo')).toBe(false); - }); - - it('returns false if key is no overridden', () => { - const { client } = setup({ - initialSettings: { - foo: { - userValue: 1, - }, - bar: { - isOverridden: true, - userValue: 2, - }, - }, - }); - expect(client.isOverridden('foo')).toBe(false); - }); - - it('returns true when key is overridden', () => { - const { client } = setup({ - initialSettings: { - foo: { - userValue: 1, - }, - bar: { - isOverridden: true, - userValue: 2, - }, - }, - }); - expect(client.isOverridden('bar')).toBe(true); - }); - - it('returns false for object prototype properties', () => { - const { client } = setup(); - expect(client.isOverridden('hasOwnProperty')).toBe(false); - }); - }); -}); diff --git a/src/core/public/ui_settings/ui_settings_client.ts b/src/core/public/ui_settings/ui_settings_client.ts index ab7c91803549b..ee5d5da8d29b9 100644 --- a/src/core/public/ui_settings/ui_settings_client.ts +++ b/src/core/public/ui_settings/ui_settings_client.ts @@ -24,7 +24,6 @@ interface UiSettingsClientParams { export class UiSettingsClient implements IUiSettingsClient { private readonly update$ = new Subject<{ key: string; newValue: any; oldValue: any }>(); - private readonly saved$ = new Subject<{ key: string; newValue: any; oldValue: any }>(); private readonly updateErrors$ = new Subject(); private readonly api: UiSettingsApi; @@ -39,7 +38,6 @@ export class UiSettingsClient implements IUiSettingsClient { params.done$.subscribe({ complete: () => { this.update$.complete(); - this.saved$.complete(); this.updateErrors$.complete(); }, }); @@ -116,37 +114,10 @@ You can use \`IUiSettingsClient.get("${key}", defaultValue)\`, which will just r return this.isDeclared(key) && Boolean(this.cache[key].isOverridden); } - overrideLocalDefault(key: string, newDefault: any) { - // capture the previous value - const prevDefault = this.defaults[key] ? this.defaults[key].value : undefined; - - // update defaults map - this.defaults[key] = { - ...(this.defaults[key] || {}), - value: newDefault, - }; - - // update cached default value - this.cache[key] = { - ...(this.cache[key] || {}), - value: newDefault, - }; - - // don't broadcast change if userValue was already overriding the default - if (this.cache[key].userValue == null) { - this.update$.next({ key, newValue: newDefault, oldValue: prevDefault }); - this.saved$.next({ key, newValue: newDefault, oldValue: prevDefault }); - } - } - getUpdate$() { return this.update$.asObservable(); } - getSaved$() { - return this.saved$.asObservable(); - } - getUpdateErrors$() { return this.updateErrors$.asObservable(); } @@ -178,7 +149,6 @@ You can use \`IUiSettingsClient.get("${key}", defaultValue)\`, which will just r try { const { settings } = await this.api.batchSet(key, newVal); this.cache = defaultsDeep({}, defaults, settings); - this.saved$.next({ key, newValue: newVal, oldValue: initialVal }); return true; } catch (error) { this.setLocally(key, initialVal); diff --git a/src/core/public/ui_settings/ui_settings_service.mock.ts b/src/core/public/ui_settings/ui_settings_service.mock.ts index 1222fc2a685de..72f03be415475 100644 --- a/src/core/public/ui_settings/ui_settings_service.mock.ts +++ b/src/core/public/ui_settings/ui_settings_service.mock.ts @@ -22,14 +22,11 @@ const createSetupContractMock = () => { isDefault: jest.fn(), isCustom: jest.fn(), isOverridden: jest.fn(), - overrideLocalDefault: jest.fn(), getUpdate$: jest.fn(), - getSaved$: jest.fn(), getUpdateErrors$: jest.fn(), }; setupContract.get$.mockReturnValue(new Rx.Subject()); setupContract.getUpdate$.mockReturnValue(new Rx.Subject()); - setupContract.getSaved$.mockReturnValue(new Rx.Subject()); setupContract.getUpdateErrors$.mockReturnValue(new Rx.Subject()); setupContract.getAll.mockReturnValue({}); diff --git a/src/core/public/ui_settings/ui_settings_service.test.ts b/src/core/public/ui_settings/ui_settings_service.test.ts index 4e42c960bf914..9f0c6ac5fc937 100644 --- a/src/core/public/ui_settings/ui_settings_service.test.ts +++ b/src/core/public/ui_settings/ui_settings_service.test.ts @@ -34,12 +34,7 @@ describe('#stop', () => { service.stop(); await expect( - Rx.combineLatest( - client.getUpdate$(), - client.getSaved$(), - client.getUpdateErrors$(), - loadingCount$! - ).toPromise() + Rx.combineLatest(client.getUpdate$(), client.getUpdateErrors$(), loadingCount$!).toPromise() ).resolves.toBe(undefined); }); }); diff --git a/src/core/server/bootstrap.ts b/src/core/server/bootstrap.ts index a2267635e86f2..18a5eceb1b2d3 100644 --- a/src/core/server/bootstrap.ts +++ b/src/core/server/bootstrap.ts @@ -46,22 +46,22 @@ export async function bootstrap({ configs, cliArgs, applyConfigOverrides }: Boot const root = new Root(rawConfigService, env, onRootShutdown); - process.on('SIGHUP', () => reloadLoggingConfig()); + process.on('SIGHUP', () => reloadConfiguration()); // This is only used by the LogRotator service // in order to be able to reload the log configuration // under the cluster mode process.on('message', (msg) => { - if (!msg || msg.reloadLoggingConfig !== true) { + if (!msg || msg.reloadConfiguration !== true) { return; } - reloadLoggingConfig(); + reloadConfiguration(); }); - function reloadLoggingConfig() { + function reloadConfiguration() { const cliLogger = root.logger.get('cli'); - cliLogger.info('Reloading logging configuration due to SIGHUP.', { tags: ['config'] }); + cliLogger.info('Reloading Kibana configuration due to SIGHUP.', { tags: ['config'] }); try { rawConfigService.reloadConfig(); @@ -69,7 +69,7 @@ export async function bootstrap({ configs, cliArgs, applyConfigOverrides }: Boot return shutdown(err); } - cliLogger.info('Reloaded logging configuration due to SIGHUP.', { tags: ['config'] }); + cliLogger.info('Reloaded Kibana configuration due to SIGHUP.', { tags: ['config'] }); } process.on('SIGINT', () => shutdown()); diff --git a/src/core/server/logging/logging_system.test.ts b/src/core/server/logging/logging_system.test.ts index 9c4313bc0c49d..8eed2aecb21d6 100644 --- a/src/core/server/logging/logging_system.test.ts +++ b/src/core/server/logging/logging_system.test.ts @@ -470,3 +470,59 @@ test('subsequent calls to setContextConfig() for the same context name can disab }, }); }); + +test('buffers log records for already created appenders', async () => { + // a default config + await system.upgrade( + config.schema.validate({ + appenders: { default: { type: 'console', layout: { type: 'json' } } }, + root: { level: 'info' }, + }) + ); + + const logger = system.get('test', 'context'); + + const bufferAppendSpy = jest.spyOn((system as any).bufferAppender, 'append'); + + const upgradePromise = system.upgrade( + config.schema.validate({ + appenders: { default: { type: 'console', layout: { type: 'json' } } }, + root: { level: 'all' }, + }) + ); + + logger.trace('message to the known context'); + expect(bufferAppendSpy).toHaveBeenCalledTimes(1); + expect(mockConsoleLog).toHaveBeenCalledTimes(0); + + await upgradePromise; + expect(JSON.parse(mockConsoleLog.mock.calls[0][0]).message).toBe('message to the known context'); +}); + +test('buffers log records for appenders created during config upgrade', async () => { + // a default config + await system.upgrade( + config.schema.validate({ + appenders: { default: { type: 'console', layout: { type: 'json' } } }, + root: { level: 'info' }, + }) + ); + + const bufferAppendSpy = jest.spyOn((system as any).bufferAppender, 'append'); + + const upgradePromise = system.upgrade( + config.schema.validate({ + appenders: { default: { type: 'console', layout: { type: 'json' } } }, + root: { level: 'all' }, + }) + ); + + const logger = system.get('test', 'context'); + logger.trace('message to a new context'); + + expect(bufferAppendSpy).toHaveBeenCalledTimes(1); + expect(mockConsoleLog).toHaveBeenCalledTimes(0); + + await upgradePromise; + expect(JSON.parse(mockConsoleLog.mock.calls[0][0]).message).toBe('message to a new context'); +}); diff --git a/src/core/server/logging/logging_system.ts b/src/core/server/logging/logging_system.ts index d7c34b48c4101..45a687493c163 100644 --- a/src/core/server/logging/logging_system.ts +++ b/src/core/server/logging/logging_system.ts @@ -167,17 +167,13 @@ export class LoggingSystem implements LoggerFactory { } private async applyBaseConfig(newBaseConfig: LoggingConfig) { + this.enforceBufferAppendersUsage(); + const computedConfig = [...this.contextConfigs.values()].reduce( (baseConfig, contextConfig) => baseConfig.extend(contextConfig), newBaseConfig ); - // reconfigure all the loggers without configuration to have them use the buffer - // appender while we are awaiting for the appenders to be disposed. - for (const [loggerKey, loggerAdapter] of this.loggers) { - loggerAdapter.updateLogger(this.createLogger(loggerKey, undefined)); - } - // Appenders must be reset, so we first dispose of the current ones, then // build up a new set of appenders. await Promise.all([...this.appenders.values()].map((a) => a.dispose())); @@ -204,18 +200,32 @@ export class LoggingSystem implements LoggerFactory { } } - for (const [loggerKey, loggerAdapter] of this.loggers) { - loggerAdapter.updateLogger(this.createLogger(loggerKey, computedConfig)); - } - + this.enforceConfiguredAppendersUsage(computedConfig); // We keep a reference to the base config so we can properly extend it // on each config change. this.baseConfig = newBaseConfig; - this.computedConfig = computedConfig; // Re-log all buffered log records with newly configured appenders. for (const logRecord of this.bufferAppender.flush()) { this.get(logRecord.context).log(logRecord); } } + + // reconfigure all the loggers to have them use the buffer appender + // while we are awaiting for the appenders to be disposed. + private enforceBufferAppendersUsage() { + for (const [loggerKey, loggerAdapter] of this.loggers) { + loggerAdapter.updateLogger(this.createLogger(loggerKey, undefined)); + } + + // new loggers created during applyBaseConfig execution should use the buffer appender as well + this.computedConfig = undefined; + } + + private enforceConfiguredAppendersUsage(config: LoggingConfig) { + for (const [loggerKey, loggerAdapter] of this.loggers) { + loggerAdapter.updateLogger(this.createLogger(loggerKey, config)); + } + this.computedConfig = config; + } } 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/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts index 997105587da68..48bb282da18f6 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts @@ -53,8 +53,7 @@ function createRoot() { ); } -// CI FAILURE: https://github.com/elastic/kibana/issues/98352 -describe.skip('migration v2', () => { +describe('migration v2', () => { let esServer: kbnTestServer.TestElasticsearchUtils; let root: Root; diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts index ed6a448b115d0..9f7e32c49ef15 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts @@ -88,8 +88,7 @@ function createRoot() { ); } -// CI FAILURE: https://github.com/elastic/kibana/issues/98351 -describe.skip('migration v2', () => { +describe('migration v2', () => { let esServer: kbnTestServer.TestElasticsearchUtils; let root: Root; diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts index 161d4a7219c8d..bffe590a39432 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts @@ -10,10 +10,11 @@ import { migrationStateActionMachine } from './migrations_state_action_machine'; import { loggingSystemMock, elasticsearchServiceMock } from '../../mocks'; import * as Either from 'fp-ts/lib/Either'; import * as Option from 'fp-ts/lib/Option'; -import { AllControlStates, State } from './types'; -import { createInitialState } from './model'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { elasticsearchClientMock } from '../../elasticsearch/client/mocks'; +import { LoggerAdapter } from '../../logging/logger_adapter'; +import { AllControlStates, State } from './types'; +import { createInitialState } from './model'; const esClient = elasticsearchServiceMock.createElasticsearchClient(); describe('migrationsStateActionMachine', () => { @@ -146,6 +147,37 @@ describe('migrationsStateActionMachine', () => { } `); }); + + // see https://github.com/elastic/kibana/issues/98406 + it('correctly logs state transition when using a logger adapter', async () => { + const underlyingLogger = mockLogger.get(); + const logger = new LoggerAdapter(underlyingLogger); + + await expect( + migrationStateActionMachine({ + initialState, + logger, + model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'LEGACY_DELETE', 'DONE']), + next, + client: esClient, + }) + ).resolves.toEqual(expect.anything()); + + const allLogs = loggingSystemMock.collect(mockLogger); + const stateTransitionLogs = allLogs.info + .map((call) => call[0]) + .filter((log) => log.match('control state')); + + expect(stateTransitionLogs).toMatchInlineSnapshot(` + Array [ + "[.my-so-index] Log from LEGACY_REINDEX control state", + "[.my-so-index] Log from LEGACY_DELETE control state", + "[.my-so-index] Log from LEGACY_DELETE control state", + "[.my-so-index] Log from DONE control state", + ] + `); + }); + it('resolves when reaching the DONE state', async () => { await expect( migrationStateActionMachine({ diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts index dede52f9758e9..85cc86fe0a468 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts @@ -49,14 +49,15 @@ const logStateTransition = ( tookMs: number ) => { if (newState.logs.length > oldState.logs.length) { - newState.logs.slice(oldState.logs.length).forEach((log) => { - const getLogger = (level: keyof Logger) => { - if (level === 'error') { - return logger[level] as Logger['error']; - } - return logger[level] as Logger['info']; - }; - getLogger(log.level)(logMessagePrefix + log.message); + newState.logs.slice(oldState.logs.length).forEach(({ message, level }) => { + switch (level) { + case 'error': + return logger.error(logMessagePrefix + message); + case 'info': + return logger.info(logMessagePrefix + message); + default: + throw new Error(`unexpected log level ${level}`); + } }); } diff --git a/src/core/server/saved_objects/migrationsv2/types.ts b/src/core/server/saved_objects/migrationsv2/types.ts index b84d483cf6203..50664bc9398fb 100644 --- a/src/core/server/saved_objects/migrationsv2/types.ts +++ b/src/core/server/saved_objects/migrationsv2/types.ts @@ -13,6 +13,13 @@ import { AliasAction } from './actions'; import { IndexMapping } from '../mappings'; import { SavedObjectsRawDoc } from '..'; +export type MigrationLogLevel = 'error' | 'info'; + +export interface MigrationLog { + level: MigrationLogLevel; + message: string; +} + export interface BaseState extends ControlState { /** The first part of the index name such as `.kibana` or `.kibana_task_manager` */ readonly indexPrefix: string; @@ -70,7 +77,7 @@ export interface BaseState extends ControlState { * In this case, you should set a smaller batchSize value and restart the migration process again. */ readonly batchSize: number; - readonly logs: Array<{ level: 'error' | 'info'; message: string }>; + readonly logs: MigrationLog[]; /** * The current alias e.g. `.kibana` which always points to the latest * version index diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 220bd2c91057d..3b2feeecabb7c 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -162,6 +162,7 @@ kibana_vars=( timelion.enabled vega.enableExternalUrls xpack.actions.allowedHosts + xpack.actions.customHostSettings xpack.actions.enabled xpack.actions.enabledActionTypes xpack.actions.preconfiguredAlertHistoryEsIndex diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx index fd11314bdbd66..b897c1c73b89b 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx @@ -59,7 +59,6 @@ function mockConfig() { isCustom: (key: string) => false, isOverridden: (key: string) => Boolean(config.getAll()[key].isOverridden), getRegistered: () => ({} as Readonly>), - overrideLocalDefault: (key: string, value: any) => {}, getUpdate$: () => new Observable<{ key: string; diff --git a/src/plugins/advanced_settings/public/management_app/types.ts b/src/plugins/advanced_settings/public/management_app/types.ts index 50b39114d2143..854a70ae48a97 100644 --- a/src/plugins/advanced_settings/public/management_app/types.ts +++ b/src/plugins/advanced_settings/public/management_app/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { ReactElement } from 'react'; import { UiCounterMetricType } from '@kbn/analytics'; import { UiSettingsType, StringValidation, ImageValidation } from '../../../../core/public'; @@ -13,7 +14,7 @@ export interface FieldSetting { displayName: string; name: string; value: unknown; - description?: string; + description?: string | ReactElement; options?: string[]; optionLabels?: Record; requiresPageReload: boolean; diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 138d665866af0..44beed5e4a89b 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -160,14 +160,12 @@ exports[`DashboardEmptyScreen renders correctly with edit mode 1`] = ` }, "get$": [MockFunction], "getAll": [MockFunction], - "getSaved$": [MockFunction], "getUpdate$": [MockFunction], "getUpdateErrors$": [MockFunction], "isCustom": [MockFunction], "isDeclared": [MockFunction], "isDefault": [MockFunction], "isOverridden": [MockFunction], - "overrideLocalDefault": [MockFunction], "remove": [MockFunction], "set": [MockFunction], } @@ -493,14 +491,12 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` }, "get$": [MockFunction], "getAll": [MockFunction], - "getSaved$": [MockFunction], "getUpdate$": [MockFunction], "getUpdateErrors$": [MockFunction], "isCustom": [MockFunction], "isDeclared": [MockFunction], "isDefault": [MockFunction], "isOverridden": [MockFunction], - "overrideLocalDefault": [MockFunction], "remove": [MockFunction], "set": [MockFunction], } @@ -840,14 +836,12 @@ exports[`DashboardEmptyScreen renders correctly with view mode 1`] = ` }, "get$": [MockFunction], "getAll": [MockFunction], - "getSaved$": [MockFunction], "getUpdate$": [MockFunction], "getUpdateErrors$": [MockFunction], "isCustom": [MockFunction], "isDeclared": [MockFunction], "isDefault": [MockFunction], "isOverridden": [MockFunction], - "overrideLocalDefault": [MockFunction], "remove": [MockFunction], "set": [MockFunction], } diff --git a/src/plugins/data/common/search/aggs/buckets/terms.ts b/src/plugins/data/common/search/aggs/buckets/terms.ts index 03cf14a577a50..1b876051d009b 100644 --- a/src/plugins/data/common/search/aggs/buckets/terms.ts +++ b/src/plugins/data/common/search/aggs/buckets/terms.ts @@ -101,7 +101,7 @@ export const getTermsBucketAgg = () => nestedSearchSource.setField('aggs', filterAgg); - const response = await nestedSearchSource + const { rawResponse: response } = await nestedSearchSource .fetch$({ abortSignal, sessionId: searchSessionId, diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts index b30e5740fa3fb..32775464d055f 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts @@ -11,7 +11,7 @@ import type { Filter } from '../../../es_query'; import type { IndexPattern } from '../../../index_patterns'; import type { IAggConfigs } from '../../aggs'; import type { ISearchSource } from '../../search_source'; -import { searchSourceCommonMock } from '../../search_source/mocks'; +import { searchSourceCommonMock, searchSourceInstanceMock } from '../../search_source/mocks'; import { handleRequest, RequestHandlerParams } from './request_handler'; @@ -20,12 +20,20 @@ jest.mock('../../tabify', () => ({ })); import { tabifyAggResponse } from '../../tabify'; +import { of } from 'rxjs'; describe('esaggs expression function - public', () => { let mockParams: MockedKeys; beforeEach(() => { jest.clearAllMocks(); + + searchSourceInstanceMock.fetch$ = jest.fn().mockReturnValue( + of({ + rawResponse: {}, + }) + ); + mockParams = { abortSignal: (jest.fn() as unknown) as jest.Mocked, aggs: ({ diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts index 173b2067cad6b..d152ebf159a8e 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts @@ -111,7 +111,7 @@ export const handleRequest = async ({ inspectorAdapters.requests?.reset(); - const response = await requestSearchSource + const { rawResponse: response } = await requestSearchSource .fetch$({ abortSignal, sessionId: searchSessionId, diff --git a/src/plugins/data/common/search/poll_search.test.ts b/src/plugins/data/common/search/poll_search.test.ts index 037fd0fc059d1..38c52f5d5bec4 100644 --- a/src/plugins/data/common/search/poll_search.test.ts +++ b/src/plugins/data/common/search/poll_search.test.ts @@ -20,11 +20,13 @@ describe('pollSearch', () => { resolve({ isRunning: false, isPartial: finishWithError, + rawResponse: {}, }); } else { resolve({ isRunning: true, isPartial: true, + rawResponse: {}, }); } }); diff --git a/src/plugins/data/common/search/search_source/fetch/request_error.ts b/src/plugins/data/common/search/search_source/fetch/request_error.ts index d8c750d011b03..48e216fa05541 100644 --- a/src/plugins/data/common/search/search_source/fetch/request_error.ts +++ b/src/plugins/data/common/search/search_source/fetch/request_error.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import type { estypes } from '@elastic/elasticsearch'; import { KbnError } from '../../../../../kibana_utils/common'; +import { IKibanaSearchResponse } from '../../types'; import { SearchError } from './types'; /** @@ -16,9 +16,9 @@ import { SearchError } from './types'; * @param {Object} resp - optional HTTP response */ export class RequestFailure extends KbnError { - public resp?: estypes.SearchResponse; - constructor(err: SearchError | null = null, resp?: estypes.SearchResponse) { - super(`Request to Elasticsearch failed: ${JSON.stringify(resp || err?.message)}`); + public resp?: IKibanaSearchResponse; + constructor(err: SearchError | null = null, resp?: IKibanaSearchResponse) { + super(`Request to Elasticsearch failed: ${JSON.stringify(resp?.rawResponse || err?.message)}`); this.resp = resp; } diff --git a/src/plugins/data/common/search/search_source/fetch/types.ts b/src/plugins/data/common/search/search_source/fetch/types.ts index 79aa45163b913..069b2a3117a0a 100644 --- a/src/plugins/data/common/search/search_source/fetch/types.ts +++ b/src/plugins/data/common/search/search_source/fetch/types.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import type { estypes } from '@elastic/elasticsearch'; import { GetConfigFn } from '../../../types'; +import { IKibanaSearchResponse } from '../../types'; /** * @internal @@ -24,10 +24,7 @@ export interface FetchHandlers { * Callback which can be used to hook into responses, modify them, or perform * side effects like displaying UI errors on the client. */ - onResponse: ( - request: SearchRequest, - response: estypes.SearchResponse - ) => estypes.SearchResponse; + onResponse: (request: SearchRequest, response: IKibanaSearchResponse) => IKibanaSearchResponse; } export interface SearchError { 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 68e386acfd48c..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', () => { @@ -903,18 +910,26 @@ describe('SearchSource', () => { expect(next).toBeCalledTimes(2); expect(complete).toBeCalledTimes(1); expect(next.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { + Array [ + Object { + "isPartial": true, + "isRunning": true, + "rawResponse": Object { "test": 1, }, - ] + }, + ] `); expect(next.mock.calls[1]).toMatchInlineSnapshot(` - Array [ - Object { + Array [ + Object { + "isPartial": false, + "isRunning": false, + "rawResponse": Object { "test": 2, }, - ] + }, + ] `); }); @@ -958,13 +973,9 @@ describe('SearchSource', () => { expect(next).toBeCalledTimes(1); expect(error).toBeCalledTimes(1); expect(complete).toBeCalledTimes(0); - expect(next.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "test": 1, - }, - ] - `); + expect(next.mock.calls[0][0].rawResponse).toStrictEqual({ + test: 1, + }); expect(error.mock.calls[0][0]).toBe(undefined); }); }); @@ -1174,7 +1185,7 @@ describe('SearchSource', () => { expect(fetchSub.next).toHaveBeenCalledTimes(3); expect(fetchSub.complete).toHaveBeenCalledTimes(1); expect(fetchSub.error).toHaveBeenCalledTimes(0); - expect(resp).toStrictEqual({ other: 5 }); + expect(resp.rawResponse).toStrictEqual({ other: 5 }); expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(3); }); diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 585126e1184d2..5130224329ba2 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -271,7 +271,9 @@ export class SearchSource { * Fetch this source from Elasticsearch, returning an observable over the response(s) * @param options */ - fetch$(options: ISearchOptions = {}) { + fetch$( + options: ISearchOptions = {} + ): Observable>> { const { getConfig } = this.dependencies; const syncSearchByDefault = getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES); @@ -308,7 +310,11 @@ export class SearchSource { * @deprecated Use fetch$ instead */ fetch(options: ISearchOptions = {}) { - return this.fetch$(options).toPromise(); + return this.fetch$(options) + .toPromise() + .then((r) => { + return r.rawResponse as estypes.SearchResponse; + }); } /** @@ -341,7 +347,7 @@ export class SearchSource { * PRIVATE APIS ******/ - private inspectSearch(s$: Observable>, options: ISearchOptions) { + private inspectSearch(s$: Observable>, options: ISearchOptions) { const { id, title, description, adapter } = options.inspector || { title: '' }; const requestResponder = adapter?.start(title, { @@ -384,7 +390,7 @@ export class SearchSource { last(undefined, null), tap((finalResponse) => { if (finalResponse) { - requestResponder?.stats(getResponseInspectorStats(finalResponse, this)); + requestResponder?.stats(getResponseInspectorStats(finalResponse.rawResponse, this)); requestResponder?.ok({ json: finalResponse }); } }), @@ -424,8 +430,8 @@ export class SearchSource { ); } } - return response; } + return response; } /** @@ -477,7 +483,7 @@ export class SearchSource { } }); }), - map(({ rawResponse }) => onResponse(searchRequest, rawResponse)) + map((response) => onResponse(searchRequest, response)) ); } diff --git a/src/plugins/data/common/search/utils.ts b/src/plugins/data/common/search/utils.ts index e87434cd6ca83..e11957c6fa9fc 100644 --- a/src/plugins/data/common/search/utils.ts +++ b/src/plugins/data/common/search/utils.ts @@ -12,7 +12,7 @@ import type { IKibanaSearchResponse } from './types'; * @returns true if response had an error while executing in ES */ export const isErrorResponse = (response?: IKibanaSearchResponse) => { - return !response || (!response.isRunning && response.isPartial); + return !response || !response.rawResponse || (!response.isRunning && response.isPartial); }; /** diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index 573820890de71..f5def327b5473 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -33,7 +33,6 @@ const createSetupContract = (): Setup => { search: searchServiceMock.createSetupContract(), fieldFormats: fieldFormatsServiceMock.createSetupContract(), query: querySetupMock, - __enhance: jest.fn(), }; }; diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 862dd63948a22..2da3b90029980 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -16,7 +16,6 @@ import { DataPublicPluginStart, DataSetupDependencies, DataStartDependencies, - DataPublicPluginEnhancements, } from './types'; import { AutocompleteService } from './autocomplete'; import { SearchService } from './search/search_service'; @@ -122,9 +121,6 @@ export class DataPublicPlugin search: searchService, fieldFormats: this.fieldFormatsService.setup(core), query: queryService, - __enhance: (enhancements: DataPublicPluginEnhancements) => { - searchService.__enhance(enhancements.search); - }, }; } diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index cc7228268cb7a..868330ce078c7 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -10,7 +10,6 @@ import { Adapters as Adapters_2 } from 'src/plugins/inspector/common'; import { ApiResponse } from '@elastic/elasticsearch/lib/Transport'; import { ApplicationStart } from 'kibana/public'; import { Assign } from '@kbn/utility-types'; -import { BehaviorSubject } from 'rxjs'; import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; import Boom from '@hapi/boom'; import { ConfigDeprecationProvider } from '@kbn/config'; @@ -579,10 +578,6 @@ export type CustomFilter = Filter & { // // @public export interface DataPublicPluginSetup { - // Warning: (ae-forgotten-export) The symbol "DataPublicPluginEnhancements" needs to be exported by the entry point index.d.ts - // - // @internal (undocumented) - __enhance: (enhancements: DataPublicPluginEnhancements) => void; // Warning: (ae-forgotten-export) The symbol "AutocompleteSetup" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -1695,10 +1690,6 @@ export interface ISearchOptions { // // @public export interface ISearchSetup { - // Warning: (ae-forgotten-export) The symbol "SearchEnhancements" needs to be exported by the entry point index.d.ts - // - // @internal (undocumented) - __enhance: (enhancements: SearchEnhancements) => void; // Warning: (ae-forgotten-export) The symbol "AggsSetup" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -2387,25 +2378,12 @@ export interface SearchError { // @public (undocumented) export class SearchInterceptor { constructor(deps: SearchInterceptorDeps); - // @internal (undocumented) - protected application: CoreStart['application']; - // (undocumented) - protected readonly deps: SearchInterceptorDeps; - // (undocumented) - protected getSerializableOptions(options?: ISearchOptions): Pick; - // (undocumented) - protected getTimeoutMode(): TimeoutErrorMode; - // Warning: (ae-forgotten-export) The symbol "AbortError" needs to be exported by the entry point index.d.ts - // - // (undocumented) - protected handleSearchError(e: KibanaServerError | AbortError, options?: ISearchOptions, isTimeout?: boolean): Error; - // @internal - protected pendingCount$: BehaviorSubject; - // @internal (undocumented) - protected runSearch(request: IKibanaSearchRequest, options?: ISearchOptions): Promise; - search(request: IKibanaSearchRequest, options?: ISearchOptions): Observable; + // Warning: (ae-forgotten-export) The symbol "IAsyncSearchOptions" needs to be exported by the entry point index.d.ts + search({ id, ...request }: IKibanaSearchRequest, options?: IAsyncSearchOptions): import("rxjs").Observable>; // (undocumented) showError(e: Error): void; + // (undocumented) + stop(): void; } // Warning: (ae-missing-release-tag) "SearchInterceptorDeps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -2468,7 +2446,7 @@ export class SearchSource { createChild(options?: {}): SearchSource; createCopy(): SearchSource; destroy(): void; - fetch$(options?: ISearchOptions): Observable>; + fetch$(options?: ISearchOptions): Observable>>; // @deprecated fetch(options?: ISearchOptions): Promise>; getField(field: K, recurse?: boolean): SearchSourceFields[K]; @@ -2643,11 +2621,9 @@ export type TimeHistoryContract = PublicMethodsOf; // @public (undocumented) export enum TimeoutErrorMode { // (undocumented) - CHANGE = 2, - // (undocumented) - CONTACT = 1, + CHANGE = 1, // (undocumented) - UPGRADE = 0 + CONTACT = 0 } // Warning: (ae-missing-release-tag) "TimeRange" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/src/plugins/data/public/search/errors/timeout_error.test.tsx b/src/plugins/data/public/search/errors/timeout_error.test.tsx index ec803fce27bfc..f1b83ae959a9e 100644 --- a/src/plugins/data/public/search/errors/timeout_error.test.tsx +++ b/src/plugins/data/public/search/errors/timeout_error.test.tsx @@ -20,17 +20,6 @@ describe('SearchTimeoutError', () => { startMock.application.navigateToApp.mockImplementation(jest.fn()); }); - it('Should navigate to upgrade', () => { - const e = new SearchTimeoutError(new AbortError(), TimeoutErrorMode.UPGRADE); - const component = mount(e.getErrorMessage(startMock.application)); - - expect(component.find('EuiButton').length).toBe(1); - component.find('EuiButton').simulate('click'); - expect(startMock.application.navigateToUrl).toHaveBeenCalledWith( - 'https://www.elastic.co/subscriptions' - ); - }); - it('Should create contact admin message', () => { const e = new SearchTimeoutError(new AbortError(), TimeoutErrorMode.CONTACT); const component = mount(e.getErrorMessage(startMock.application)); diff --git a/src/plugins/data/public/search/errors/timeout_error.tsx b/src/plugins/data/public/search/errors/timeout_error.tsx index e640bbc6f25cd..29b921c9cc12b 100644 --- a/src/plugins/data/public/search/errors/timeout_error.tsx +++ b/src/plugins/data/public/search/errors/timeout_error.tsx @@ -13,7 +13,6 @@ import { ApplicationStart } from 'kibana/public'; import { KbnError } from '../../../../kibana_utils/common'; export enum TimeoutErrorMode { - UPGRADE, CONTACT, CHANGE, } @@ -31,11 +30,6 @@ export class SearchTimeoutError extends KbnError { private getMessage() { switch (this.mode) { - case TimeoutErrorMode.UPGRADE: - return i18n.translate('data.search.upgradeLicense', { - defaultMessage: - 'Your query has timed out. With our free Basic tier, your queries never time out.', - }); case TimeoutErrorMode.CONTACT: return i18n.translate('data.search.timeoutContactAdmin', { defaultMessage: @@ -51,11 +45,6 @@ export class SearchTimeoutError extends KbnError { private getActionText() { switch (this.mode) { - case TimeoutErrorMode.UPGRADE: - return i18n.translate('data.search.upgradeLicenseActionText', { - defaultMessage: 'Upgrade now', - }); - break; case TimeoutErrorMode.CHANGE: return i18n.translate('data.search.timeoutIncreaseSettingActionText', { defaultMessage: 'Edit setting', @@ -66,9 +55,6 @@ export class SearchTimeoutError extends KbnError { private onClick(application: ApplicationStart) { switch (this.mode) { - case TimeoutErrorMode.UPGRADE: - application.navigateToUrl('https://www.elastic.co/subscriptions'); - break; case TimeoutErrorMode.CHANGE: application.navigateToApp('management', { path: `/kibana/settings`, diff --git a/src/plugins/data/public/search/fetch/handle_response.test.ts b/src/plugins/data/public/search/fetch/handle_response.test.ts index 8854bee5c7657..1a430f860f438 100644 --- a/src/plugins/data/public/search/fetch/handle_response.test.ts +++ b/src/plugins/data/public/search/fetch/handle_response.test.ts @@ -12,7 +12,7 @@ import { handleResponse } from './handle_response'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { notificationServiceMock } from '../../../../../core/public/notifications/notifications_service.mock'; import { setNotifications } from '../../services'; -import { SearchResponse } from 'elasticsearch'; +import { IKibanaSearchResponse } from 'src/plugins/data/common'; jest.mock('@kbn/i18n', () => { return { @@ -33,8 +33,10 @@ describe('handleResponse', () => { test('should notify if timed out', () => { const request = { body: {} }; const response = { - timed_out: true, - } as SearchResponse; + rawResponse: { + timed_out: true, + }, + } as IKibanaSearchResponse; const result = handleResponse(request, response); expect(result).toBe(response); expect(notifications.toasts.addWarning).toBeCalled(); @@ -46,13 +48,15 @@ describe('handleResponse', () => { test('should notify if shards failed', () => { const request = { body: {} }; const response = { - _shards: { - failed: 1, - total: 2, - successful: 1, - skipped: 1, + rawResponse: { + _shards: { + failed: 1, + total: 2, + successful: 1, + skipped: 1, + }, }, - } as SearchResponse; + } as IKibanaSearchResponse; const result = handleResponse(request, response); expect(result).toBe(response); expect(notifications.toasts.addWarning).toBeCalled(); @@ -63,7 +67,9 @@ describe('handleResponse', () => { test('returns the response', () => { const request = {}; - const response = {} as SearchResponse; + const response = { + rawResponse: {}, + } as IKibanaSearchResponse; const result = handleResponse(request, response); expect(result).toBe(response); }); diff --git a/src/plugins/data/public/search/fetch/handle_response.tsx b/src/plugins/data/public/search/fetch/handle_response.tsx index 57ee5737e50a2..58e4da6b95fae 100644 --- a/src/plugins/data/public/search/fetch/handle_response.tsx +++ b/src/plugins/data/public/search/fetch/handle_response.tsx @@ -9,14 +9,15 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer } from '@elastic/eui'; -import type { estypes } from '@elastic/elasticsearch'; +import { IKibanaSearchResponse } from 'src/plugins/data/common'; import { ShardFailureOpenModalButton } from '../../ui/shard_failure_modal'; import { toMountPoint } from '../../../../kibana_react/public'; import { getNotifications } from '../../services'; import { SearchRequest } from '..'; -export function handleResponse(request: SearchRequest, response: estypes.SearchResponse) { - if (response.timed_out) { +export function handleResponse(request: SearchRequest, response: IKibanaSearchResponse) { + const { rawResponse } = response; + if (rawResponse.timed_out) { getNotifications().toasts.addWarning({ title: i18n.translate('data.search.searchSource.fetch.requestTimedOutNotificationMessage', { defaultMessage: 'Data might be incomplete because your request timed out', @@ -24,12 +25,12 @@ export function handleResponse(request: SearchRequest, response: estypes.SearchR }); } - if (response._shards && response._shards.failed) { + if (rawResponse._shards && rawResponse._shards.failed) { const title = i18n.translate('data.search.searchSource.fetch.shardsFailedNotificationMessage', { defaultMessage: '{shardsFailed} of {shardsTotal} shards failed', values: { - shardsFailed: response._shards.failed, - shardsTotal: response._shards.total, + shardsFailed: rawResponse._shards.failed, + shardsTotal: rawResponse._shards.total, }, }); const description = i18n.translate( @@ -43,7 +44,7 @@ export function handleResponse(request: SearchRequest, response: estypes.SearchR <> {description} - + ); diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index 92a5c36202e6f..21d607eedb152 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -12,7 +12,6 @@ export { ISearchSetup, ISearchStart, ISearchStartSearchSource, - SearchEnhancements, SearchUsageCollector, } from './types'; diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index 273bbfe9e7b08..562b367b92c92 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -15,7 +15,6 @@ import { createSearchUsageCollectorMock } from './collectors/mocks'; function createSetupContract(): jest.Mocked { return { aggs: searchAggsSetupMock(), - __enhance: jest.fn(), session: getSessionServiceMock(), sessionsClient: getSessionsClientMock(), usageCollector: createSearchUsageCollectorMock(), diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts deleted file mode 100644 index e74581e9a6ffe..0000000000000 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ /dev/null @@ -1,323 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import type { MockedKeys } from '@kbn/utility-types/jest'; -import { CoreSetup, CoreStart } from '../../../../core/public'; -import { coreMock } from '../../../../core/public/mocks'; -import { IEsSearchRequest } from '../../common/search'; -import { SearchInterceptor } from './search_interceptor'; -import { AbortError } from '../../../kibana_utils/public'; -import { SearchTimeoutError, PainlessError, TimeoutErrorMode, EsError } from './errors'; -import { searchServiceMock } from './mocks'; -import { ISearchStart, ISessionService } from '.'; -import { bfetchPluginMock } from '../../../bfetch/public/mocks'; -import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; - -import * as searchPhaseException from '../../common/search/test_data/search_phase_execution_exception.json'; -import * as resourceNotFoundException from '../../common/search/test_data/resource_not_found_exception.json'; - -let searchInterceptor: SearchInterceptor; -let mockCoreSetup: MockedKeys; -let bfetchSetup: jest.Mocked; -let fetchMock: jest.Mock; - -const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); -jest.useFakeTimers(); - -describe('SearchInterceptor', () => { - let searchMock: jest.Mocked; - let mockCoreStart: MockedKeys; - beforeEach(() => { - mockCoreSetup = coreMock.createSetup(); - mockCoreStart = coreMock.createStart(); - searchMock = searchServiceMock.createStartContract(); - fetchMock = jest.fn(); - bfetchSetup = bfetchPluginMock.createSetupContract(); - bfetchSetup.batchedFunction.mockReturnValue(fetchMock); - searchInterceptor = new SearchInterceptor({ - bfetch: bfetchSetup, - toasts: mockCoreSetup.notifications.toasts, - startServices: new Promise((resolve) => { - resolve([mockCoreStart, {}, {}]); - }), - uiSettings: mockCoreSetup.uiSettings, - http: mockCoreSetup.http, - session: searchMock.session, - }); - }); - - describe('showError', () => { - test('Ignores an AbortError', async () => { - searchInterceptor.showError(new AbortError()); - expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled(); - expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); - }); - - test('Ignores a SearchTimeoutError', async () => { - searchInterceptor.showError(new SearchTimeoutError(new Error(), TimeoutErrorMode.UPGRADE)); - expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled(); - expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); - }); - - test('Renders a PainlessError', async () => { - searchInterceptor.showError( - new PainlessError({ - statusCode: 400, - message: 'search_phase_execution_exception', - attributes: searchPhaseException.error, - }) - ); - expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); - expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); - }); - - test('Renders a general error', async () => { - searchInterceptor.showError(new Error('Oopsy')); - expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled(); - expect(mockCoreSetup.notifications.toasts.addError).toBeCalledTimes(1); - }); - }); - - describe('search', () => { - test('Observable should resolve if fetch is successful', async () => { - const mockResponse: any = { result: 200 }; - fetchMock.mockResolvedValueOnce(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - const response = searchInterceptor.search(mockRequest); - await expect(response.toPromise()).resolves.toBe(mockResponse); - }); - - describe('Search session', () => { - const setup = ( - opts: { - isRestore?: boolean; - isStored?: boolean; - sessionId: string; - } | null - ) => { - const sessionServiceMock = searchMock.session as jest.Mocked; - sessionServiceMock.getSearchOptions.mockImplementation(() => - opts - ? { - sessionId: opts.sessionId, - isRestore: opts.isRestore ?? false, - isStored: opts.isStored ?? false, - } - : null - ); - fetchMock.mockResolvedValue({ result: 200 }); - }; - - const mockRequest: IEsSearchRequest = { - params: {}, - }; - - afterEach(() => { - const sessionServiceMock = searchMock.session as jest.Mocked; - sessionServiceMock.getSearchOptions.mockReset(); - fetchMock.mockReset(); - }); - - test('gets session search options from session service', async () => { - const sessionId = 'sid'; - setup({ - isRestore: true, - isStored: true, - sessionId, - }); - - await searchInterceptor.search(mockRequest, { sessionId }).toPromise(); - expect(fetchMock.mock.calls[0][0]).toEqual( - expect.objectContaining({ - options: { sessionId, isStored: true, isRestore: true, strategy: 'es' }, - }) - ); - - expect( - (searchMock.session as jest.Mocked).getSearchOptions - ).toHaveBeenCalledWith(sessionId); - }); - - test("doesn't forward sessionId if search options return null", async () => { - const sessionId = 'sid'; - setup(null); - - await searchInterceptor.search(mockRequest, { sessionId }).toPromise(); - expect(fetchMock.mock.calls[0][0]).toEqual( - expect.not.objectContaining({ - options: { sessionId }, - }) - ); - - expect( - (searchMock.session as jest.Mocked).getSearchOptions - ).toHaveBeenCalledWith(sessionId); - }); - }); - - describe('Should throw typed errors', () => { - test('Observable should fail if fetch has an internal error', async () => { - const mockResponse: any = new Error('Internal Error'); - fetchMock.mockRejectedValue(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - const response = searchInterceptor.search(mockRequest); - await expect(response.toPromise()).rejects.toThrow('Internal Error'); - }); - - describe('Should handle Timeout errors', () => { - test('Should throw SearchTimeoutError on server timeout AND show toast', async () => { - const mockResponse: any = { - statusCode: 500, - message: 'Request timed out', - }; - fetchMock.mockRejectedValueOnce(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - const response = searchInterceptor.search(mockRequest); - await expect(response.toPromise()).rejects.toThrow(SearchTimeoutError); - expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); - }); - - test('Timeout error should show multiple times if not in a session', async () => { - const mockResponse: any = { - statusCode: 500, - message: 'Request timed out', - }; - fetchMock.mockRejectedValue(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - - await expect(searchInterceptor.search(mockRequest).toPromise()).rejects.toThrow( - SearchTimeoutError - ); - await expect(searchInterceptor.search(mockRequest).toPromise()).rejects.toThrow( - SearchTimeoutError - ); - expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(2); - }); - - test('Timeout error should show once per each session', async () => { - const mockResponse: any = { - statusCode: 500, - message: 'Request timed out', - }; - fetchMock.mockRejectedValue(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - - await expect( - searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise() - ).rejects.toThrow(SearchTimeoutError); - await expect( - searchInterceptor.search(mockRequest, { sessionId: 'def' }).toPromise() - ).rejects.toThrow(SearchTimeoutError); - expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(2); - }); - - test('Timeout error should show once in a single session', async () => { - const mockResponse: any = { - statusCode: 500, - message: 'Request timed out', - }; - fetchMock.mockRejectedValue(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - await expect( - searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise() - ).rejects.toThrow(SearchTimeoutError); - await expect( - searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise() - ).rejects.toThrow(SearchTimeoutError); - expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); - }); - }); - - test('Should throw Painless error on server error with OSS format', async () => { - const mockResponse: any = { - statusCode: 400, - message: 'search_phase_execution_exception', - attributes: searchPhaseException.error, - }; - fetchMock.mockRejectedValueOnce(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - const response = searchInterceptor.search(mockRequest); - await expect(response.toPromise()).rejects.toThrow(PainlessError); - }); - - test('Should throw ES error on ES server error', async () => { - const mockResponse: any = { - statusCode: 400, - message: 'resource_not_found_exception', - attributes: resourceNotFoundException.error, - }; - fetchMock.mockRejectedValueOnce(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - const response = searchInterceptor.search(mockRequest); - await expect(response.toPromise()).rejects.toThrow(EsError); - }); - - test('Observable should fail if user aborts (test merged signal)', async () => { - const abortController = new AbortController(); - fetchMock.mockImplementationOnce((options: any) => { - return new Promise((resolve, reject) => { - options.signal.addEventListener('abort', () => { - reject(new AbortError()); - }); - - setTimeout(resolve, 500); - }); - }); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - const response = searchInterceptor.search(mockRequest, { - abortSignal: abortController.signal, - }); - - const next = jest.fn(); - const error = (e: any) => { - expect(next).not.toBeCalled(); - expect(e).toBeInstanceOf(AbortError); - }; - response.subscribe({ next, error }); - setTimeout(() => abortController.abort(), 200); - jest.advanceTimersByTime(5000); - - await flushPromises(); - }); - - test('Immediately aborts if passed an aborted abort signal', async (done) => { - const abort = new AbortController(); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - const response = searchInterceptor.search(mockRequest, { abortSignal: abort.signal }); - abort.abort(); - - const error = (e: any) => { - expect(e).toBeInstanceOf(AbortError); - expect(fetchMock).not.toBeCalled(); - done(); - }; - response.subscribe({ error }); - }); - }); - }); -}); diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts deleted file mode 100644 index f0df6f9216c0f..0000000000000 --- a/src/plugins/data/public/search/search_interceptor.ts +++ /dev/null @@ -1,245 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { memoize } from 'lodash'; -import { BehaviorSubject, throwError, defer, from, Observable } from 'rxjs'; -import { catchError, finalize } from 'rxjs/operators'; -import { PublicMethodsOf } from '@kbn/utility-types'; -import { CoreStart, CoreSetup, ToastsSetup } from 'kibana/public'; -import { i18n } from '@kbn/i18n'; -import { BatchedFunc, BfetchPublicSetup } from 'src/plugins/bfetch/public'; -import { - ES_SEARCH_STRATEGY, - IKibanaSearchRequest, - IKibanaSearchResponse, - ISearchOptions, - ISearchOptionsSerializable, -} from '../../common'; -import { SearchUsageCollector } from './collectors'; -import { - SearchTimeoutError, - PainlessError, - isPainlessError, - TimeoutErrorMode, - isEsError, - EsError, - getHttpError, -} from './errors'; -import { toMountPoint } from '../../../kibana_react/public'; -import { AbortError, KibanaServerError } from '../../../kibana_utils/public'; -import { ISessionService } from './session'; - -export interface SearchInterceptorDeps { - bfetch: BfetchPublicSetup; - http: CoreSetup['http']; - uiSettings: CoreSetup['uiSettings']; - startServices: Promise<[CoreStart, any, unknown]>; - toasts: ToastsSetup; - usageCollector?: SearchUsageCollector; - session: ISessionService; -} - -export class SearchInterceptor { - /** - * Observable that emits when the number of pending requests changes. - * @internal - */ - protected pendingCount$ = new BehaviorSubject(0); - - /** - * @internal - */ - protected application!: CoreStart['application']; - private batchedFetch!: BatchedFunc< - { request: IKibanaSearchRequest; options: ISearchOptionsSerializable }, - IKibanaSearchResponse - >; - - /* - * @internal - */ - constructor(protected readonly deps: SearchInterceptorDeps) { - this.deps.http.addLoadingCountSource(this.pendingCount$); - - this.deps.startServices.then(([coreStart]) => { - this.application = coreStart.application; - }); - - this.batchedFetch = deps.bfetch.batchedFunction({ - url: '/internal/bsearch', - }); - } - - /* - * @returns `TimeoutErrorMode` indicating what action should be taken in case of a request timeout based on license and permissions. - * @internal - */ - protected getTimeoutMode() { - return TimeoutErrorMode.UPGRADE; - } - - /* - * @returns `Error` a search service specific error or the original error, if a specific error can't be recognized. - * @internal - */ - protected handleSearchError( - e: KibanaServerError | AbortError, - options?: ISearchOptions, - isTimeout?: boolean - ): Error { - if (isTimeout || e.message === 'Request timed out') { - // Handle a client or a server side timeout - const err = new SearchTimeoutError(e, this.getTimeoutMode()); - - // Show the timeout error here, so that it's shown regardless of how an application chooses to handle errors. - // The timeout error is shown any time a request times out, or once per session, if the request is part of a session. - this.showTimeoutError(err, options?.sessionId); - return err; - } else if (e instanceof AbortError) { - // In the case an application initiated abort, throw the existing AbortError. - return e; - } else if (isEsError(e)) { - if (isPainlessError(e)) { - return new PainlessError(e, options?.indexPattern); - } else { - return new EsError(e); - } - } else { - return e instanceof Error ? e : new Error(e.message); - } - } - - protected getSerializableOptions(options?: ISearchOptions) { - const { sessionId, ...requestOptions } = options || {}; - - const serializableOptions: ISearchOptionsSerializable = {}; - const combined = { - ...requestOptions, - ...this.deps.session.getSearchOptions(sessionId), - }; - - if (combined.sessionId !== undefined) serializableOptions.sessionId = combined.sessionId; - if (combined.isRestore !== undefined) serializableOptions.isRestore = combined.isRestore; - if (combined.legacyHitsTotal !== undefined) - serializableOptions.legacyHitsTotal = combined.legacyHitsTotal; - if (combined.strategy !== undefined) serializableOptions.strategy = combined.strategy; - if (combined.isStored !== undefined) serializableOptions.isStored = combined.isStored; - - return serializableOptions; - } - - /** - * @internal - * @throws `AbortError` | `ErrorLike` - */ - protected runSearch( - request: IKibanaSearchRequest, - options?: ISearchOptions - ): Promise { - const { abortSignal } = options || {}; - return this.batchedFetch( - { - request, - options: this.getSerializableOptions(options), - }, - abortSignal - ); - } - - private showTimeoutErrorToast = (e: SearchTimeoutError, sessionId?: string) => { - this.deps.toasts.addDanger({ - title: 'Timed out', - text: toMountPoint(e.getErrorMessage(this.application)), - }); - }; - - private showTimeoutErrorMemoized = memoize( - this.showTimeoutErrorToast, - (_: SearchTimeoutError, sessionId: string) => { - return sessionId; - } - ); - - /** - * Show one error notification per session. - * @internal - */ - private showTimeoutError = (e: SearchTimeoutError, sessionId?: string) => { - if (sessionId) { - this.showTimeoutErrorMemoized(e, sessionId); - } else { - this.showTimeoutErrorToast(e, sessionId); - } - }; - - /** - * Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort - * either when the request times out, or when the original `AbortSignal` is aborted. Updates - * `pendingCount$` when the request is started/finalized. - * - * @param request - * @options - * @returns `Observable` emitting the search response or an error. - */ - public search( - request: IKibanaSearchRequest, - options: ISearchOptions = {} - ): Observable { - options = { - strategy: ES_SEARCH_STRATEGY, - ...options, - }; - - // Defer the following logic until `subscribe` is actually called - return defer(() => { - if (options.abortSignal?.aborted) { - return throwError(new AbortError()); - } - - this.pendingCount$.next(this.pendingCount$.getValue() + 1); - return from(this.runSearch(request, options)).pipe( - catchError((e: Error | AbortError) => { - return throwError(this.handleSearchError(e, options)); - }), - finalize(() => { - this.pendingCount$.next(this.pendingCount$.getValue() - 1); - }) - ); - }); - } - - /* - * - */ - public showError(e: Error) { - if (e instanceof AbortError || e instanceof SearchTimeoutError) { - // The SearchTimeoutError is shown by the interceptor in getSearchError (regardless of how the app chooses to handle errors) - return; - } else if (e instanceof EsError) { - this.deps.toasts.addDanger({ - title: i18n.translate('data.search.esErrorTitle', { - defaultMessage: 'Cannot retrieve search results', - }), - text: toMountPoint(e.getErrorMessage(this.application)), - }); - } else if (e.constructor.name === 'HttpFetchError') { - this.deps.toasts.addDanger({ - title: i18n.translate('data.search.httpErrorTitle', { - defaultMessage: 'Cannot retrieve your data', - }), - text: toMountPoint(getHttpError(e.message)), - }); - } else { - this.deps.toasts.addError(e, { - title: 'Search Error', - }); - } - } -} - -export type ISearchInterceptor = PublicMethodsOf; diff --git a/src/plugins/data/public/search/search_interceptor/index.ts b/src/plugins/data/public/search/search_interceptor/index.ts new file mode 100644 index 0000000000000..411c4beefe96c --- /dev/null +++ b/src/plugins/data/public/search/search_interceptor/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { SearchInterceptor, ISearchInterceptor, SearchInterceptorDeps } from './search_interceptor'; diff --git a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts b/src/plugins/data/public/search/search_interceptor/search_abort_controller.test.ts similarity index 94% rename from x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts rename to src/plugins/data/public/search/search_interceptor/search_abort_controller.test.ts index a52fdef9819b8..5d9d8a9903325 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts +++ b/src/plugins/data/public/search/search_interceptor/search_abort_controller.test.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { SearchAbortController } from './search_abort_controller'; diff --git a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts b/src/plugins/data/public/search/search_interceptor/search_abort_controller.ts similarity index 90% rename from x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts rename to src/plugins/data/public/search/search_interceptor/search_abort_controller.ts index 7bc74b56a3903..1e2cdae1070ab 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts +++ b/src/plugins/data/public/search/search_interceptor/search_abort_controller.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { Subscription, timer } from 'rxjs'; diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts new file mode 100644 index 0000000000000..0e81f362a030d --- /dev/null +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts @@ -0,0 +1,1384 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { MockedKeys } from '@kbn/utility-types/jest'; +import { CoreSetup, CoreStart } from '../../../../../core/public'; +import { coreMock } from '../../../../../core/public/mocks'; +import { IEsSearchRequest } from '../../../common/search'; +import { SearchInterceptor } from './search_interceptor'; +import { AbortError } from '../../../../kibana_utils/public'; +import { SearchTimeoutError, PainlessError, TimeoutErrorMode, EsError } from '../errors'; +import { ISessionService, SearchSessionState } from '../'; +import { bfetchPluginMock } from '../../../../bfetch/public/mocks'; +import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; + +import * as searchPhaseException from '../../../common/search/test_data/search_phase_execution_exception.json'; +import * as resourceNotFoundException from '../../../common/search/test_data/resource_not_found_exception.json'; +import { BehaviorSubject } from 'rxjs'; +import { dataPluginMock } from '../../mocks'; +import { UI_SETTINGS } from '../../../common'; + +jest.mock('./utils', () => ({ + createRequestHash: jest.fn().mockImplementation((input) => { + return Promise.resolve(JSON.stringify(input)); + }), +})); + +let searchInterceptor: SearchInterceptor; +let mockCoreSetup: MockedKeys; +let bfetchSetup: jest.Mocked; +let fetchMock: jest.Mock; + +const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); +jest.useFakeTimers(); + +const timeTravel = async (msToRun = 0) => { + await flushPromises(); + jest.advanceTimersByTime(msToRun); + return flushPromises(); +}; + +const next = jest.fn(); +const error = jest.fn(); +const complete = jest.fn(); + +function mockFetchImplementation(responses: any[]) { + let i = 0; + fetchMock.mockImplementation((r) => { + if (!r.request.id) i = 0; + const { time = 0, value = {}, isError = false } = responses[i++]; + value.meta = { + size: 10, + }; + return new Promise((resolve, reject) => + setTimeout(() => { + return (isError ? reject : resolve)(value); + }, time) + ); + }); +} + +describe('SearchInterceptor', () => { + let mockCoreStart: MockedKeys; + let sessionService: jest.Mocked; + let sessionState$: BehaviorSubject; + + beforeEach(() => { + mockCoreSetup = coreMock.createSetup(); + mockCoreStart = coreMock.createStart(); + sessionState$ = new BehaviorSubject(SearchSessionState.None); + const dataPluginMockStart = dataPluginMock.createStartContract(); + sessionService = { + ...(dataPluginMockStart.search.session as jest.Mocked), + state$: sessionState$, + }; + + fetchMock = jest.fn(); + mockCoreSetup.uiSettings.get.mockImplementation((name: string) => { + switch (name) { + case UI_SETTINGS.SEARCH_TIMEOUT: + return 1000; + default: + return; + } + }); + + next.mockClear(); + error.mockClear(); + complete.mockClear(); + jest.clearAllTimers(); + + const bfetchMock = bfetchPluginMock.createSetupContract(); + bfetchMock.batchedFunction.mockReturnValue(fetchMock); + + bfetchSetup = bfetchPluginMock.createSetupContract(); + bfetchSetup.batchedFunction.mockReturnValue(fetchMock); + searchInterceptor = new SearchInterceptor({ + bfetch: bfetchSetup, + toasts: mockCoreSetup.notifications.toasts, + startServices: new Promise((resolve) => { + resolve([mockCoreStart, {}, {}]); + }), + uiSettings: mockCoreSetup.uiSettings, + http: mockCoreSetup.http, + session: sessionService, + }); + }); + + describe('showError', () => { + test('Ignores an AbortError', async () => { + searchInterceptor.showError(new AbortError()); + expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled(); + expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); + }); + + test('Ignores a SearchTimeoutError', async () => { + searchInterceptor.showError(new SearchTimeoutError(new Error(), TimeoutErrorMode.CONTACT)); + expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled(); + expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); + }); + + test('Renders a PainlessError', async () => { + searchInterceptor.showError( + new PainlessError({ + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: searchPhaseException.error, + }) + ); + expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); + expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); + }); + + test('Renders a general error', async () => { + searchInterceptor.showError(new Error('Oopsy')); + expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled(); + expect(mockCoreSetup.notifications.toasts.addError).toBeCalledTimes(1); + }); + }); + + describe('search', () => { + test('Observable should resolve if fetch is successful', async () => { + const mockResponse: any = { rawResponse: {} }; + fetchMock.mockResolvedValueOnce(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest); + await expect(response.toPromise()).resolves.toBe(mockResponse); + }); + + test('should resolve immediately if first call returns full result', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: false, + isRunning: false, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + mockFetchImplementation(responses); + + const response = searchInterceptor.search({}); + response.subscribe({ next, error, complete }); + + await timeTravel(10); + + expect(next).toHaveBeenCalled(); + expect(next.mock.calls[0][0]).toStrictEqual(responses[0].value); + expect(complete).toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + test('should make secondary request if first call returns partial result', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + { + time: 20, + value: { + isPartial: false, + isRunning: false, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + + mockFetchImplementation(responses); + + const response = searchInterceptor.search({}, { pollInterval: 0 }); + response.subscribe({ next, error, complete }); + + await timeTravel(10); + + expect(next).toHaveBeenCalled(); + expect(next.mock.calls[0][0]).toStrictEqual(responses[0].value); + expect(complete).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + + await timeTravel(20); + + expect(next).toHaveBeenCalledTimes(2); + expect(next.mock.calls[1][0]).toStrictEqual(responses[1].value); + expect(complete).toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + test('should abort if request is partial and not running (ES graceful error)', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + mockFetchImplementation(responses); + + const response = searchInterceptor.search({}); + response.subscribe({ next, error }); + + await timeTravel(10); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(Error); + }); + + test('should abort on user abort', async () => { + const responses = [ + { + time: 500, + value: { + isPartial: false, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + mockFetchImplementation(responses); + + const abortController = new AbortController(); + abortController.abort(); + + const response = searchInterceptor.search({}, { abortSignal: abortController.signal }); + response.subscribe({ next, error }); + + await timeTravel(500); + + expect(next).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); + }); + + test('should DELETE a running async search on abort', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + rawResponse: {}, + id: 1, + }, + }, + { + time: 300, + value: { + isPartial: false, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + mockFetchImplementation(responses); + + const abortController = new AbortController(); + setTimeout(() => abortController.abort(), 250); + + const response = searchInterceptor.search( + {}, + { abortSignal: abortController.signal, pollInterval: 0 } + ); + response.subscribe({ next, error }); + + await timeTravel(10); + + expect(next).toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + + await timeTravel(240); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); + }); + + test('should not DELETE a running async search on async timeout prior to first response', async () => { + const responses = [ + { + time: 2000, + value: { + isPartial: false, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + mockFetchImplementation(responses); + + const response = searchInterceptor.search({}, { pollInterval: 0 }); + response.subscribe({ next, error }); + + await timeTravel(1000); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(SearchTimeoutError); + expect(fetchMock).toHaveBeenCalled(); + expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); + }); + + test('should DELETE a running async search on async timeout after first response', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + rawResponse: {}, + id: 1, + }, + }, + { + time: 2000, + value: { + isPartial: false, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + mockFetchImplementation(responses); + + const response = searchInterceptor.search({}, { pollInterval: 0 }); + response.subscribe({ next, error }); + + await timeTravel(10); + + expect(next).toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalled(); + expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); + + // Long enough to reach the timeout but not long enough to reach the next response + await timeTravel(1000); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(SearchTimeoutError); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); + }); + + test('should DELETE a running async search on async timeout on error from fetch', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + rawResponse: {}, + id: 1, + }, + }, + { + time: 10, + value: { + statusCode: 500, + message: 'oh no', + id: 1, + }, + isError: true, + }, + ]; + mockFetchImplementation(responses); + + const response = searchInterceptor.search({}, { pollInterval: 0 }); + response.subscribe({ next, error }); + + await timeTravel(10); + + expect(next).toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalled(); + expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); + + // Long enough to reach the timeout but not long enough to reach the next response + await timeTravel(10); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(Error); + expect((error.mock.calls[0][0] as Error).message).toBe('oh no'); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); + }); + + test('should NOT DELETE a running SAVED async search on abort', async () => { + const sessionId = 'sessionId'; + sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + rawResponse: {}, + id: 1, + }, + }, + { + time: 300, + value: { + isPartial: false, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + mockFetchImplementation(responses); + + const abortController = new AbortController(); + setTimeout(() => abortController.abort(), 250); + + const response = searchInterceptor.search( + {}, + { abortSignal: abortController.signal, pollInterval: 0, sessionId } + ); + response.subscribe({ next, error }); + + await timeTravel(10); + + expect(next).toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + + sessionState$.next(SearchSessionState.BackgroundLoading); + + await timeTravel(240); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); + }); + + describe('Search session', () => { + const setup = ( + opts: { + isRestore?: boolean; + isStored?: boolean; + sessionId: string; + } | null + ) => { + const sessionServiceMock = sessionService as jest.Mocked; + sessionServiceMock.getSearchOptions.mockImplementation(() => + opts + ? { + sessionId: opts.sessionId, + isRestore: opts.isRestore ?? false, + isStored: opts.isStored ?? false, + } + : null + ); + fetchMock.mockResolvedValue({ result: 200 }); + }; + + const mockRequest: IEsSearchRequest = { + params: {}, + }; + + afterEach(() => { + const sessionServiceMock = sessionService as jest.Mocked; + sessionServiceMock.getSearchOptions.mockReset(); + fetchMock.mockReset(); + }); + + test('gets session search options from session service', async () => { + const sessionId = 'sid'; + setup({ + isRestore: true, + isStored: true, + sessionId, + }); + + await searchInterceptor + .search(mockRequest, { sessionId }) + .toPromise() + .catch(() => {}); + expect(fetchMock.mock.calls[0][0]).toEqual( + expect.objectContaining({ + options: { sessionId, isStored: true, isRestore: true, strategy: 'ese' }, + }) + ); + + expect( + (sessionService as jest.Mocked).getSearchOptions + ).toHaveBeenCalledWith(sessionId); + }); + + test("doesn't forward sessionId if search options return null", async () => { + const sessionId = 'sid'; + setup(null); + + await searchInterceptor + .search(mockRequest, { sessionId }) + .toPromise() + .catch(() => {}); + expect(fetchMock.mock.calls[0][0]).toEqual( + expect.not.objectContaining({ + options: { sessionId }, + }) + ); + + expect( + (sessionService as jest.Mocked).getSearchOptions + ).toHaveBeenCalledWith(sessionId); + }); + }); + + describe('Session tracking', () => { + beforeEach(() => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + rawResponse: {}, + id: 1, + }, + }, + { + time: 300, + value: { + isPartial: false, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + + mockFetchImplementation(responses); + }); + + test('should track searches', async () => { + const sessionId = 'sessionId'; + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const response = searchInterceptor.search({}, { pollInterval: 0, sessionId }); + response.subscribe({ next, error }); + await timeTravel(10); + expect(sessionService.trackSearch).toBeCalledTimes(1); + expect(untrack).not.toBeCalled(); + await timeTravel(300); + expect(sessionService.trackSearch).toBeCalledTimes(1); + expect(untrack).toBeCalledTimes(1); + }); + + test('session service should be able to cancel search', async () => { + const sessionId = 'sessionId'; + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const response = searchInterceptor.search({}, { pollInterval: 0, sessionId }); + response.subscribe({ next, error }); + await timeTravel(10); + expect(sessionService.trackSearch).toBeCalledTimes(1); + + const abort = sessionService.trackSearch.mock.calls[0][0].abort; + expect(abort).toBeInstanceOf(Function); + + abort(); + + await timeTravel(10); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); + }); + + test("don't track non current session searches", async () => { + const sessionId = 'sessionId'; + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const response1 = searchInterceptor.search( + {}, + { pollInterval: 0, sessionId: 'something different' } + ); + response1.subscribe({ next, error }); + + const response2 = searchInterceptor.search({}, { pollInterval: 0, sessionId: undefined }); + response2.subscribe({ next, error }); + + await timeTravel(10); + expect(sessionService.trackSearch).toBeCalledTimes(0); + }); + + test("don't track if no current session", async () => { + sessionService.getSessionId.mockImplementation(() => undefined); + sessionService.isCurrentSession.mockImplementation((_sessionId) => false); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const response1 = searchInterceptor.search( + {}, + { pollInterval: 0, sessionId: 'something different' } + ); + response1.subscribe({ next, error }); + + const response2 = searchInterceptor.search({}, { pollInterval: 0, sessionId: undefined }); + response2.subscribe({ next, error }); + + await timeTravel(10); + expect(sessionService.trackSearch).toBeCalledTimes(0); + }); + }); + + describe('session client caching', () => { + const sessionId = 'sessionId'; + const basicReq = { + params: { + test: 1, + }, + }; + + const basicCompleteResponse = [ + { + time: 10, + value: { + isPartial: false, + isRunning: false, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + + const partialCompleteResponse = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + { + time: 20, + value: { + isPartial: false, + isRunning: false, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + + beforeEach(() => { + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + }); + + test('should be disabled if there is no session', async () => { + mockFetchImplementation(basicCompleteResponse); + + searchInterceptor.search(basicReq, {}).subscribe({ next, error, complete }); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, {}).subscribe({ next, error, complete }); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should fetch different requests in a single session', async () => { + mockFetchImplementation(basicCompleteResponse); + + const req2 = { + params: { + test: 2, + }, + }; + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should fetch the same request for two different sessions', async () => { + mockFetchImplementation(basicCompleteResponse); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor + .search(basicReq, { sessionId: 'anotherSession' }) + .subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should track searches that come from cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const response = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + const response2 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response.subscribe({ next, error, complete }); + response2.subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).not.toBeCalled(); + await timeTravel(300); + // Should be called only 2 times (once per partial response) + expect(fetchMock).toBeCalledTimes(2); + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).toBeCalledTimes(2); + + expect(next).toBeCalledTimes(4); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(2); + }); + + test('should cache partial responses', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + rawResponse: {}, + id: 1, + }, + }, + ]; + + mockFetchImplementation(responses); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('should not cache error responses', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: false, + id: 1, + }, + }, + ]; + + mockFetchImplementation(responses); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should deliver error to all replays', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + + mockFetchImplementation(responses); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + expect(error).toBeCalledTimes(2); + expect(error.mock.calls[0][0].message).toEqual('Received partial response'); + expect(error.mock.calls[1][0].message).toEqual('Received partial response'); + }); + + test('should ignore anything outside params when hashing', async () => { + mockFetchImplementation(basicCompleteResponse); + + const req = { + something: 123, + params: { + test: 1, + }, + }; + + const req2 = { + something: 321, + params: { + test: 1, + }, + }; + + searchInterceptor.search(req, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('should ignore preference when hashing', async () => { + mockFetchImplementation(basicCompleteResponse); + + const req = { + params: { + test: 1, + preference: 123, + }, + }; + + const req2 = { + params: { + test: 1, + preference: 321, + }, + }; + + searchInterceptor.search(req, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('should return from cache for identical requests in the same session', async () => { + mockFetchImplementation(basicCompleteResponse); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('aborting a search that didnt get any response should retrigger search', async () => { + mockFetchImplementation(basicCompleteResponse); + + const abortController = new AbortController(); + + // Start a search request + searchInterceptor + .search(basicReq, { sessionId, abortSignal: abortController.signal }) + .subscribe({ next, error, complete }); + + // Abort the search request before it started + abortController.abort(); + + // Time travel to make sure nothing appens + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(0); + expect(next).toBeCalledTimes(0); + expect(error).toBeCalledTimes(1); + expect(complete).toBeCalledTimes(0); + + const error2 = jest.fn(); + const next2 = jest.fn(); + const complete2 = jest.fn(); + + // Search for the same thing again + searchInterceptor + .search(basicReq, { sessionId }) + .subscribe({ next: next2, error: error2, complete: complete2 }); + + // Should search again + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + expect(next2).toBeCalledTimes(1); + expect(error2).toBeCalledTimes(0); + expect(complete2).toBeCalledTimes(1); + }); + + test('aborting a running first search shouldnt clear cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response.subscribe({ next, error, complete }); + await timeTravel(10); + + expect(fetchMock).toBeCalledTimes(1); + expect(next).toBeCalledTimes(1); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(0); + expect(sessionService.trackSearch).toBeCalledTimes(1); + expect(untrack).not.toBeCalled(); + + const next2 = jest.fn(); + const error2 = jest.fn(); + const complete2 = jest.fn(); + const response2 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response2.subscribe({ next: next2, error: error2, complete: complete2 }); + await timeTravel(0); + + abortController.abort(); + + await timeTravel(300); + // Both searches should be tracked and untracked + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).toBeCalledTimes(2); + + // First search should error + expect(next).toBeCalledTimes(1); + expect(error).toBeCalledTimes(1); + expect(complete).toBeCalledTimes(0); + + // Second search should complete + expect(next2).toBeCalledTimes(2); + expect(error2).toBeCalledTimes(0); + expect(complete2).toBeCalledTimes(1); + + // Should be called only 2 times (once per partial response) + expect(fetchMock).toBeCalledTimes(2); + }); + + test('aborting a running second search shouldnt clear cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response.subscribe({ next, error, complete }); + await timeTravel(10); + + expect(fetchMock).toBeCalledTimes(1); + expect(next).toBeCalledTimes(1); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(0); + expect(sessionService.trackSearch).toBeCalledTimes(1); + expect(untrack).not.toBeCalled(); + + const next2 = jest.fn(); + const error2 = jest.fn(); + const complete2 = jest.fn(); + const response2 = searchInterceptor.search(req, { + pollInterval: 0, + sessionId, + abortSignal: abortController.signal, + }); + response2.subscribe({ next: next2, error: error2, complete: complete2 }); + await timeTravel(0); + + abortController.abort(); + + await timeTravel(300); + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).toBeCalledTimes(2); + + expect(next).toBeCalledTimes(2); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(1); + + expect(next2).toBeCalledTimes(1); + expect(error2).toBeCalledTimes(1); + expect(complete2).toBeCalledTimes(0); + + // Should be called only 2 times (once per partial response) + expect(fetchMock).toBeCalledTimes(2); + }); + + test('aborting both requests should cancel underlaying search only once', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + sessionService.trackSearch.mockImplementation(() => jest.fn()); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response.subscribe({ next, error, complete }); + + const response2 = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response2.subscribe({ next, error, complete }); + await timeTravel(10); + + abortController.abort(); + + await timeTravel(300); + + expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); + }); + + test('aborting both searches should stop searching and clear cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response.subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + const response2 = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response2.subscribe({ next, error, complete }); + await timeTravel(0); + expect(fetchMock).toBeCalledTimes(1); + + abortController.abort(); + + await timeTravel(300); + + expect(next).toBeCalledTimes(2); + expect(error).toBeCalledTimes(2); + expect(complete).toBeCalledTimes(0); + expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); + expect(error.mock.calls[1][0]).toBeInstanceOf(AbortError); + + // Should be called only 1 times (one partial response) + expect(fetchMock).toBeCalledTimes(1); + + // Clear mock and research + fetchMock.mockReset(); + mockFetchImplementation(partialCompleteResponse); + // Run the search again to see that we don't hit the cache + const response3 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response3.subscribe({ next, error, complete }); + + await timeTravel(10); + await timeTravel(10); + await timeTravel(300); + + // Should be called 2 times (two partial response) + expect(fetchMock).toBeCalledTimes(2); + expect(complete).toBeCalledTimes(1); + }); + + test("aborting a completed search shouldn't effect cache", async () => { + mockFetchImplementation(basicCompleteResponse); + + const abortController = new AbortController(); + + // Start a search request + searchInterceptor + .search(basicReq, { sessionId, abortSignal: abortController.signal }) + .subscribe({ next, error, complete }); + + // Get a final response + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + // Abort the search request + abortController.abort(); + + // Search for the same thing again + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + + // Get the response from cache + expect(fetchMock).toBeCalledTimes(1); + }); + }); + + describe('Should throw typed errors', () => { + test('Observable should fail if fetch has an internal error', async () => { + const mockResponse: any = new Error('Internal Error'); + fetchMock.mockRejectedValue(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest); + await expect(response.toPromise()).rejects.toThrow('Internal Error'); + }); + + describe('Should handle Timeout errors', () => { + test('Should throw SearchTimeoutError on server timeout AND show toast', async () => { + const mockResponse: any = { + statusCode: 500, + message: 'Request timed out', + }; + fetchMock.mockRejectedValueOnce(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest); + await expect(response.toPromise()).rejects.toThrow(SearchTimeoutError); + expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); + }); + + test('Timeout error should show multiple times if not in a session', async () => { + const mockResponse: any = { + statusCode: 500, + message: 'Request timed out', + }; + fetchMock.mockRejectedValue(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + + await expect(searchInterceptor.search(mockRequest).toPromise()).rejects.toThrow( + SearchTimeoutError + ); + await expect(searchInterceptor.search(mockRequest).toPromise()).rejects.toThrow( + SearchTimeoutError + ); + expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(2); + }); + + test('Timeout error should show once per each session', async () => { + const mockResponse: any = { + statusCode: 500, + message: 'Request timed out', + }; + fetchMock.mockRejectedValue(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + + await expect( + searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise() + ).rejects.toThrow(SearchTimeoutError); + await expect( + searchInterceptor.search(mockRequest, { sessionId: 'def' }).toPromise() + ).rejects.toThrow(SearchTimeoutError); + expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(2); + }); + + test('Timeout error should show once in a single session', async () => { + const mockResponse: any = { + statusCode: 500, + message: 'Request timed out', + }; + fetchMock.mockRejectedValue(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + await expect( + searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise() + ).rejects.toThrow(SearchTimeoutError); + await expect( + searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise() + ).rejects.toThrow(SearchTimeoutError); + expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); + }); + }); + + test('Should throw Painless error on server error with OSS format', async () => { + const mockResponse: any = { + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: searchPhaseException.error, + }; + fetchMock.mockRejectedValueOnce(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest); + await expect(response.toPromise()).rejects.toThrow(PainlessError); + }); + + test('Should throw ES error on ES server error', async () => { + const mockResponse: any = { + statusCode: 400, + message: 'resource_not_found_exception', + attributes: resourceNotFoundException.error, + }; + fetchMock.mockRejectedValueOnce(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest); + await expect(response.toPromise()).rejects.toThrow(EsError); + }); + + test('Observable should fail if user aborts (test merged signal)', async () => { + const abortController = new AbortController(); + fetchMock.mockImplementationOnce((options: any) => { + return new Promise((resolve, reject) => { + options.signal.addEventListener('abort', () => { + reject(new AbortError()); + }); + + setTimeout(resolve, 500); + }); + }); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest, { + abortSignal: abortController.signal, + }); + + error.mockImplementation((e) => { + expect(next).not.toBeCalled(); + expect(e).toBeInstanceOf(AbortError); + }); + + response.subscribe({ next, error }); + setTimeout(() => abortController.abort(), 200); + jest.advanceTimersByTime(5000); + + await flushPromises(); + }); + + test('Immediately aborts if passed an aborted abort signal', async (done) => { + const abort = new AbortController(); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest, { abortSignal: abort.signal }); + abort.abort(); + + error.mockImplementation((e) => { + expect(e).toBeInstanceOf(AbortError); + expect(fetchMock).not.toBeCalled(); + done(); + }); + + response.subscribe({ error }); + }); + }); + }); +}); diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts new file mode 100644 index 0000000000000..5fa0a5c301019 --- /dev/null +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts @@ -0,0 +1,408 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { memoize, once } from 'lodash'; +import { BehaviorSubject, EMPTY, from, fromEvent, of, Subscription, throwError } from 'rxjs'; +import { + catchError, + filter, + finalize, + map, + shareReplay, + skip, + switchMap, + take, + takeUntil, + tap, +} from 'rxjs/operators'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { CoreSetup, CoreStart, ToastsSetup } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { BatchedFunc, BfetchPublicSetup } from 'src/plugins/bfetch/public'; +import { + ENHANCED_ES_SEARCH_STRATEGY, + IAsyncSearchOptions, + IKibanaSearchRequest, + IKibanaSearchResponse, + ISearchOptions, + ISearchOptionsSerializable, + pollSearch, + UI_SETTINGS, +} from '../../../common'; +import { SearchUsageCollector } from '../collectors'; +import { + EsError, + getHttpError, + isEsError, + isPainlessError, + PainlessError, + SearchTimeoutError, + TimeoutErrorMode, +} from '../errors'; +import { toMountPoint } from '../../../../kibana_react/public'; +import { AbortError, KibanaServerError } from '../../../../kibana_utils/public'; +import { ISessionService, SearchSessionState } from '../session'; +import { SearchResponseCache } from './search_response_cache'; +import { createRequestHash } from './utils'; +import { SearchAbortController } from './search_abort_controller'; + +export interface SearchInterceptorDeps { + bfetch: BfetchPublicSetup; + http: CoreSetup['http']; + uiSettings: CoreSetup['uiSettings']; + startServices: Promise<[CoreStart, any, unknown]>; + toasts: ToastsSetup; + usageCollector?: SearchUsageCollector; + session: ISessionService; +} + +const MAX_CACHE_ITEMS = 50; +const MAX_CACHE_SIZE_MB = 10; + +export class SearchInterceptor { + private uiSettingsSub: Subscription; + private searchTimeout: number; + private readonly responseCache: SearchResponseCache = new SearchResponseCache( + MAX_CACHE_ITEMS, + MAX_CACHE_SIZE_MB + ); + + /** + * Observable that emits when the number of pending requests changes. + * @internal + */ + private pendingCount$ = new BehaviorSubject(0); + + /** + * @internal + */ + private application!: CoreStart['application']; + private batchedFetch!: BatchedFunc< + { request: IKibanaSearchRequest; options: ISearchOptionsSerializable }, + IKibanaSearchResponse + >; + + /* + * @internal + */ + constructor(private readonly deps: SearchInterceptorDeps) { + this.deps.http.addLoadingCountSource(this.pendingCount$); + + this.deps.startServices.then(([coreStart]) => { + this.application = coreStart.application; + }); + + this.batchedFetch = deps.bfetch.batchedFunction({ + url: '/internal/bsearch', + }); + + this.searchTimeout = deps.uiSettings.get(UI_SETTINGS.SEARCH_TIMEOUT); + + this.uiSettingsSub = deps.uiSettings + .get$(UI_SETTINGS.SEARCH_TIMEOUT) + .subscribe((timeout: number) => { + this.searchTimeout = timeout; + }); + } + + public stop() { + this.responseCache.clear(); + this.uiSettingsSub.unsubscribe(); + } + + /* + * @returns `TimeoutErrorMode` indicating what action should be taken in case of a request timeout based on license and permissions. + * @internal + */ + private getTimeoutMode() { + return this.application.capabilities.advancedSettings?.save + ? TimeoutErrorMode.CHANGE + : TimeoutErrorMode.CONTACT; + } + + private createRequestHash$(request: IKibanaSearchRequest, options: IAsyncSearchOptions) { + const { sessionId, isRestore } = options; + // Preference is used to ensure all queries go to the same set of shards and it doesn't need to be hashed + // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-shard-routing.html#shard-and-node-preference + const { preference, ...params } = request.params || {}; + const hashOptions = { + ...params, + sessionId, + isRestore, + }; + + return from(sessionId ? createRequestHash(hashOptions) : of(undefined)); + } + + /* + * @returns `Error` a search service specific error or the original error, if a specific error can't be recognized. + * @internal + */ + private handleSearchError( + e: KibanaServerError | AbortError, + options?: ISearchOptions, + isTimeout?: boolean + ): Error { + if (isTimeout || e.message === 'Request timed out') { + // Handle a client or a server side timeout + const err = new SearchTimeoutError(e, this.getTimeoutMode()); + + // Show the timeout error here, so that it's shown regardless of how an application chooses to handle errors. + // The timeout error is shown any time a request times out, or once per session, if the request is part of a session. + this.showTimeoutError(err, options?.sessionId); + return err; + } else if (e instanceof AbortError) { + // In the case an application initiated abort, throw the existing AbortError. + return e; + } else if (isEsError(e)) { + if (isPainlessError(e)) { + return new PainlessError(e, options?.indexPattern); + } else { + return new EsError(e); + } + } else { + return e instanceof Error ? e : new Error(e.message); + } + } + + private getSerializableOptions(options?: ISearchOptions) { + const { sessionId, ...requestOptions } = options || {}; + + const serializableOptions: ISearchOptionsSerializable = {}; + const combined = { + ...requestOptions, + ...this.deps.session.getSearchOptions(sessionId), + }; + + if (combined.sessionId !== undefined) serializableOptions.sessionId = combined.sessionId; + if (combined.isRestore !== undefined) serializableOptions.isRestore = combined.isRestore; + if (combined.legacyHitsTotal !== undefined) + serializableOptions.legacyHitsTotal = combined.legacyHitsTotal; + if (combined.strategy !== undefined) serializableOptions.strategy = combined.strategy; + if (combined.isStored !== undefined) serializableOptions.isStored = combined.isStored; + + return serializableOptions; + } + + /** + * @internal + * Creates a new pollSearch that share replays its results + */ + private runSearch$( + { id, ...request }: IKibanaSearchRequest, + options: IAsyncSearchOptions, + searchAbortController: SearchAbortController + ) { + const search = () => this.runSearch({ id, ...request }, options); + const { sessionId, strategy } = options; + + // track if this search's session will be send to background + // if yes, then we don't need to cancel this search when it is aborted + let isSavedToBackground = false; + const savedToBackgroundSub = + this.deps.session.isCurrentSession(sessionId) && + this.deps.session.state$ + .pipe( + skip(1), // ignore any state, we are only interested in transition x -> BackgroundLoading + filter( + (state) => + this.deps.session.isCurrentSession(sessionId) && + state === SearchSessionState.BackgroundLoading + ), + take(1) + ) + .subscribe(() => { + isSavedToBackground = true; + }); + + const cancel = once(() => { + if (id && !isSavedToBackground) this.deps.http.delete(`/internal/search/${strategy}/${id}`); + }); + + return pollSearch(search, cancel, { + ...options, + abortSignal: searchAbortController.getSignal(), + }).pipe( + tap((response) => (id = response.id)), + catchError((e: Error) => { + cancel(); + return throwError(e); + }), + finalize(() => { + searchAbortController.cleanup(); + if (savedToBackgroundSub) { + savedToBackgroundSub.unsubscribe(); + } + }), + // This observable is cached in the responseCache. + // Using shareReplay makes sure that future subscribers will get the final response + + shareReplay(1) + ); + } + + /** + * @internal + * @throws `AbortError` | `ErrorLike` + */ + private runSearch( + request: IKibanaSearchRequest, + options?: ISearchOptions + ): Promise { + const { abortSignal } = options || {}; + return this.batchedFetch( + { + request, + options: this.getSerializableOptions(options), + }, + abortSignal + ); + } + + /** + * @internal + * Creates a new search observable and a corresponding search abort controller + * If requestHash is defined, tries to return them first from cache. + */ + private getSearchResponse$( + request: IKibanaSearchRequest, + options: IAsyncSearchOptions, + requestHash?: string + ) { + const cached = requestHash ? this.responseCache.get(requestHash) : undefined; + + const searchAbortController = + cached?.searchAbortController || new SearchAbortController(this.searchTimeout); + + // Create a new abort signal if one was not passed. This fake signal will never be aborted, + // So the underlaying search will not be aborted, even if the other consumers abort. + searchAbortController.addAbortSignal(options.abortSignal ?? new AbortController().signal); + const response$ = cached?.response$ || this.runSearch$(request, options, searchAbortController); + + if (requestHash && !this.responseCache.has(requestHash)) { + this.responseCache.set(requestHash, { + response$, + searchAbortController, + }); + } + + return { + response$, + searchAbortController, + }; + } + + /** + * Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort + * either when the request times out, or when the original `AbortSignal` is aborted. Updates + * `pendingCount$` when the request is started/finalized. + * + * @param request + * @options + * @returns `Observable` emitting the search response or an error. + */ + public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { + const searchOptions = { + strategy: ENHANCED_ES_SEARCH_STRATEGY, + ...options, + }; + const { sessionId, abortSignal } = searchOptions; + + return this.createRequestHash$(request, searchOptions).pipe( + switchMap((requestHash) => { + const { searchAbortController, response$ } = this.getSearchResponse$( + request, + searchOptions, + requestHash + ); + + this.pendingCount$.next(this.pendingCount$.getValue() + 1); + const untrackSearch = this.deps.session.isCurrentSession(sessionId) + ? this.deps.session.trackSearch({ abort: () => searchAbortController.abort() }) + : undefined; + + // Abort the replay if the abortSignal is aborted. + // The underlaying search will not abort unless searchAbortController fires. + const aborted$ = (abortSignal ? fromEvent(abortSignal, 'abort') : EMPTY).pipe( + map(() => { + throw new AbortError(); + }) + ); + + return response$.pipe( + takeUntil(aborted$), + catchError((e) => { + return throwError( + this.handleSearchError(e, searchOptions, searchAbortController.isTimeout()) + ); + }), + finalize(() => { + this.pendingCount$.next(this.pendingCount$.getValue() - 1); + if (untrackSearch && this.deps.session.isCurrentSession(sessionId)) { + // untrack if this search still belongs to current session + untrackSearch(); + } + }) + ); + }) + ); + } + + private showTimeoutErrorToast = (e: SearchTimeoutError, sessionId?: string) => { + this.deps.toasts.addDanger({ + title: 'Timed out', + text: toMountPoint(e.getErrorMessage(this.application)), + }); + }; + + private showTimeoutErrorMemoized = memoize( + this.showTimeoutErrorToast, + (_: SearchTimeoutError, sessionId: string) => { + return sessionId; + } + ); + + /** + * Show one error notification per session. + * @internal + */ + private showTimeoutError = (e: SearchTimeoutError, sessionId?: string) => { + if (sessionId) { + this.showTimeoutErrorMemoized(e, sessionId); + } else { + this.showTimeoutErrorToast(e, sessionId); + } + }; + + public showError(e: Error) { + if (e instanceof AbortError || e instanceof SearchTimeoutError) { + // The SearchTimeoutError is shown by the interceptor in getSearchError (regardless of how the app chooses to handle errors) + return; + } else if (e instanceof EsError) { + this.deps.toasts.addDanger({ + title: i18n.translate('data.search.esErrorTitle', { + defaultMessage: 'Cannot retrieve search results', + }), + text: toMountPoint(e.getErrorMessage(this.application)), + }); + } else if (e.constructor.name === 'HttpFetchError') { + this.deps.toasts.addDanger({ + title: i18n.translate('data.search.httpErrorTitle', { + defaultMessage: 'Cannot retrieve your data', + }), + text: toMountPoint(getHttpError(e.message)), + }); + } else { + this.deps.toasts.addError(e, { + title: 'Search Error', + }); + } + } +} + +export type ISearchInterceptor = PublicMethodsOf; diff --git a/x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts b/src/plugins/data/public/search/search_interceptor/search_response_cache.test.ts similarity index 97% rename from x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts rename to src/plugins/data/public/search/search_interceptor/search_response_cache.test.ts index e985de5e23f7d..df277c289f973 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts +++ b/src/plugins/data/public/search/search_interceptor/search_response_cache.test.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { interval, Observable, of, throwError } from 'rxjs'; @@ -14,7 +15,7 @@ import { SearchResponseCache } from './search_response_cache'; describe('SearchResponseCache', () => { let cache: SearchResponseCache; let searchAbortController: SearchAbortController; - const r: Array> = [ + const r: IKibanaSearchResponse[] = [ { isPartial: true, isRunning: true, diff --git a/x-pack/plugins/data_enhanced/public/search/search_response_cache.ts b/src/plugins/data/public/search/search_interceptor/search_response_cache.ts similarity index 92% rename from x-pack/plugins/data_enhanced/public/search/search_response_cache.ts rename to src/plugins/data/public/search/search_interceptor/search_response_cache.ts index 1467e5bf234ff..1af32afdc5e90 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_response_cache.ts +++ b/src/plugins/data/public/search/search_interceptor/search_response_cache.ts @@ -1,21 +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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { Observable, Subscription } from 'rxjs'; -import { IKibanaSearchResponse, isErrorResponse } from '../../../../../src/plugins/data/public'; import { SearchAbortController } from './search_abort_controller'; +import { IKibanaSearchResponse, isErrorResponse } from '../../../common'; interface ResponseCacheItem { - response$: Observable>; + response$: Observable; searchAbortController: SearchAbortController; } interface ResponseCacheItemInternal { - response$: Observable>; + response$: Observable; searchAbortController: SearchAbortController; size: number; subs: Subscription; diff --git a/x-pack/plugins/data_enhanced/public/search/utils.ts b/src/plugins/data/public/search/search_interceptor/utils.ts similarity index 74% rename from x-pack/plugins/data_enhanced/public/search/utils.ts rename to src/plugins/data/public/search/search_interceptor/utils.ts index c6c648dbb5488..2f2d06d3a5494 100644 --- a/x-pack/plugins/data_enhanced/public/search/utils.ts +++ b/src/plugins/data/public/search/search_interceptor/utils.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import stringify from 'json-stable-stringify'; diff --git a/src/plugins/data/public/search/search_service.test.ts b/src/plugins/data/public/search/search_service.test.ts index 7ac3a11c08446..debee42d8bf34 100644 --- a/src/plugins/data/public/search/search_service.test.ts +++ b/src/plugins/data/public/search/search_service.test.ts @@ -38,7 +38,6 @@ describe('Search service', () => { } as unknown) as SearchServiceSetupDependencies); expect(setup).toHaveProperty('aggs'); expect(setup).toHaveProperty('usageCollector'); - expect(setup).toHaveProperty('__enhance'); expect(setup).toHaveProperty('sessionsClient'); expect(setup).toHaveProperty('session'); }); diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index ec7a486445b71..28064f4736d31 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -15,7 +15,7 @@ import { } from 'src/core/public'; import { BehaviorSubject } from 'rxjs'; import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; -import { ISearchSetup, ISearchStart, SearchEnhancements } from './types'; +import { ISearchSetup, ISearchStart } from './types'; import { handleResponse } from './fetch'; import { @@ -147,9 +147,6 @@ export class SearchService implements Plugin { return { aggs, usageCollector: this.usageCollector!, - __enhance: (enhancements: SearchEnhancements) => { - this.searchInterceptor = enhancements.searchInterceptor; - }, session: this.sessionService, sessionsClient: this.sessionsClient, }; @@ -187,5 +184,6 @@ export class SearchService implements Plugin { public stop() { this.aggsService.stop(); this.searchSourceService.stop(); + this.searchInterceptor.stop(); } } diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 391be8e053746..8190c52b5c0c5 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -7,7 +7,6 @@ */ import { PackageInfo } from 'kibana/server'; -import { ISearchInterceptor } from './search_interceptor'; import { SearchUsageCollector } from './collectors'; import { AggsSetup, AggsSetupDependencies, AggsStartDependencies, AggsStart } from './aggs'; import { ISearchGeneric, ISearchStartSearchSource } from '../../common/search'; @@ -17,10 +16,6 @@ import { ISessionsClient, ISessionService } from './session'; export { ISearchStartSearchSource, SearchUsageCollector }; -export interface SearchEnhancements { - searchInterceptor: ISearchInterceptor; -} - /** * The setup contract exposed by the Search plugin exposes the search strategy extension * point. @@ -38,10 +33,6 @@ export interface ISearchSetup { * {@link ISessionsClient} */ sessionsClient: ISessionsClient; - /** - * @internal - */ - __enhance: (enhancements: SearchEnhancements) => void; } /** diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index 8686823ef0568..5ba4ba2bc48af 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -15,7 +15,7 @@ import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; import { AutocompleteSetup, AutocompleteStart } from './autocomplete'; import { FieldFormatsSetup, FieldFormatsStart } from './field_formats'; import { createFiltersFromRangeSelectAction, createFiltersFromValueClickAction } from './actions'; -import { ISearchSetup, ISearchStart, SearchEnhancements } from './search'; +import { ISearchSetup, ISearchStart } from './search'; import { QuerySetup, QueryStart } from './query'; import { IndexPatternsContract } from './index_patterns'; import { IndexPatternSelectProps, StatefulSearchBarProps } from './ui'; @@ -23,10 +23,6 @@ import { UsageCollectionSetup, UsageCollectionStart } from '../../usage_collecti import { Setup as InspectorSetup } from '../../inspector/public'; import { NowProviderPublicContract } from './now_provider'; -export interface DataPublicPluginEnhancements { - search: SearchEnhancements; -} - export interface DataSetupDependencies { bfetch: BfetchPublicSetup; expressions: ExpressionsSetup; @@ -47,10 +43,6 @@ export interface DataPublicPluginSetup { search: ISearchSetup; fieldFormats: FieldFormatsSetup; query: QuerySetup; - /** - * @internal - */ - __enhance: (enhancements: DataPublicPluginEnhancements) => void; } /** diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index 383e09b4a6ebe..f52c622c48ed0 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -251,7 +251,7 @@ export class SearchService implements Plugin { private registerSearchStrategy = < SearchStrategyRequest extends IKibanaSearchRequest = IEsSearchRequest, - SearchStrategyResponse extends IKibanaSearchResponse = IEsSearchResponse + SearchStrategyResponse extends IKibanaSearchResponse = IEsSearchResponse >( name: string, strategy: ISearchStrategy diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 15d3f5c403b1f..a69df282cd568 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -32,7 +32,6 @@ import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { ExpressionValueBoxed } from 'src/plugins/expressions/common'; import { FormatFactory as FormatFactory_2 } from 'src/plugins/data/common/field_formats/utils'; import { IAggConfigs as IAggConfigs_2 } from 'src/plugins/data/public'; -import { ISavedObjectsRepository } from 'src/core/server'; import { IScopedClusterClient } from 'src/core/server'; import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public'; import { ISearchSource } from 'src/plugins/data/public'; diff --git a/src/plugins/data/server/ui_settings.ts b/src/plugins/data/server/ui_settings.ts index 78d7c15cac5d6..ed3510c5776e0 100644 --- a/src/plugins/data/server/ui_settings.ts +++ b/src/plugins/data/server/ui_settings.ts @@ -715,5 +715,18 @@ export function getUiSettings(): Record> { }), schema: schema.boolean(), }, + [UI_SETTINGS.SEARCH_TIMEOUT]: { + name: i18n.translate('data.advancedSettings.searchTimeout', { + defaultMessage: 'Search Timeout', + }), + value: 600000, + description: i18n.translate('data.advancedSettings.searchTimeoutDesc', { + defaultMessage: + 'Change the maximum timeout for a search session or set to 0 to disable the timeout and allow queries to run to completion.', + }), + type: 'number', + category: ['search'], + schema: schema.number(), + }, }; } diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 4099d5e8ef7e2..c66ca19c96743 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -431,7 +431,7 @@ function discoverController($route, $scope) { }, }) .toPromise() - .then(onResults) + .then(({ rawResponse }) => onResults(rawResponse)) .catch((error) => { // If the request was aborted then no need to surface this error in the UI if (error instanceof Error && error.name === 'AbortError') return; diff --git a/src/plugins/discover/public/application/angular/helpers/row_formatter.ts b/src/plugins/discover/public/application/angular/helpers/row_formatter.tsx similarity index 71% rename from src/plugins/discover/public/application/angular/helpers/row_formatter.ts rename to src/plugins/discover/public/application/angular/helpers/row_formatter.tsx index b219dda19e10a..c5e64f38a3117 100644 --- a/src/plugins/discover/public/application/angular/helpers/row_formatter.ts +++ b/src/plugins/discover/public/application/angular/helpers/row_formatter.tsx @@ -6,24 +6,29 @@ * Side Public License, v 1. */ -import { template } from 'lodash'; +import React, { Fragment } from 'react'; +import ReactDOM from 'react-dom/server'; import { MAX_DOC_FIELDS_DISPLAYED } from '../../../../common'; import { getServices, IndexPattern } from '../../../kibana_services'; -function noWhiteSpace(html: string) { - const TAGS_WITH_WS = />\s+<'); +interface Props { + defPairs: Array<[string, unknown]>; } - -const templateHtml = ` -
- <% defPairs.forEach(function (def) { %> -
<%- def[0] %>:
-
<%= def[1] %>
- <%= ' ' %> - <% }); %> -
`; -export const doTemplate = template(noWhiteSpace(templateHtml)); +const TemplateComponent = ({ defPairs }: Props) => { + return ( +
+ {defPairs.map((pair, idx) => ( + +
{pair[0]}:
+
{' '} + + ))} +
+ ); +}; export const formatRow = (hit: Record, indexPattern: IndexPattern) => { const highlights = hit?.highlight ?? {}; @@ -38,7 +43,9 @@ export const formatRow = (hit: Record, indexPattern: IndexPattern) pairs.push([displayKey ? displayKey : key, val]); }); const maxEntries = getServices().uiSettings.get(MAX_DOC_FIELDS_DISPLAYED); - return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs].slice(0, maxEntries) }); + return ReactDOM.renderToStaticMarkup( + + ); }; export const formatTopLevelObject = ( @@ -70,5 +77,7 @@ export const formatTopLevelObject = ( pairs.push([displayKey ? displayKey : key, formatted]); }); const maxEntries = getServices().uiSettings.get(MAX_DOC_FIELDS_DISPLAYED); - return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs].slice(0, maxEntries) }); + return ReactDOM.renderToStaticMarkup( + + ); }; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx index 67dda6dd0e9a8..e11c1716efe6b 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx @@ -233,7 +233,7 @@ export function DiscoverFieldSearch({ onChange, value, types, useNewFieldsApi }: const footer = () => { return ( - + - + {i18n.translate('discover.fieldChooser.filter.filterByTypeLabel', { defaultMessage: 'Filter by type', })} diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts index dbaf07fed18c2..99ecb4c11eef2 100644 --- a/src/plugins/discover/public/application/embeddable/search_embeddable.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts @@ -325,7 +325,7 @@ export class SearchEmbeddable try { // Make the request - const resp = await searchSource + const { rawResponse: resp } = await searchSource .fetch$({ abortSignal: this.abortController.signal, sessionId: searchSessionId, 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/embeddable/public/lib/panel/_embeddable_panel.scss b/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss index f7ee1f3c741c4..9072c26576097 100644 --- a/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss +++ b/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss @@ -120,9 +120,10 @@ // EDITING MODE .embPanel--editing { - border-style: dashed; - border-color: $euiColorMediumShade; + border-style: dashed !important; + border-color: $euiColorMediumShade !important; transition: all $euiAnimSpeedFast $euiAnimSlightResistance; + border-width: $euiBorderWidthThin; &:hover, &:focus { diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts index da4e1b101914f..38864945a17d0 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts @@ -116,10 +116,15 @@ describe('telemetry_application_usage', () => { minutesOnScreen: 10, numberOfClicks: 10, }, + type: opts.type, + references: [], + score: 0, }, ], total: 1, - } as any; + per_page: 10, + page: 1, + }; case SAVED_OBJECTS_DAILY_TYPE: return { saved_objects: [ @@ -131,9 +136,21 @@ describe('telemetry_application_usage', () => { minutesOnScreen: 0.5, numberOfClicks: 1, }, + type: opts.type, + references: [], + score: 0, }, ], total: 1, + per_page: 10, + page: 1, + }; + default: + return { + saved_objects: [], + total: 0, + per_page: 10, + page: 1, }; } }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.test.ts index 7d4f03fd30edf..bc6f8c956b669 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.test.ts @@ -28,7 +28,7 @@ describe('kibana_config_usage', () => { const collectorFetchContext = createCollectorFetchContextMock(); const coreUsageDataStart = coreUsageDataServiceMock.createStartContract(); - const mockConfigUsage = (Symbol('config usage telemetry') as any) as ConfigUsageData; + const mockConfigUsage = (Symbol('config usage telemetry') as unknown) as ConfigUsageData; coreUsageDataStart.getConfigsUsageData.mockResolvedValue(mockConfigUsage); beforeAll(() => registerConfigUsageCollector(usageCollectionMock, () => coreUsageDataStart)); diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.test.ts index b671a9f93d369..5410e491a85fd 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.test.ts @@ -28,7 +28,7 @@ describe('telemetry_core', () => { const collectorFetchContext = createCollectorFetchContextMock(); const coreUsageDataStart = coreUsageDataServiceMock.createStartContract(); - const getCoreUsageDataReturnValue = (Symbol('core telemetry') as any) as CoreUsageData; + const getCoreUsageDataReturnValue = (Symbol('core telemetry') as unknown) as CoreUsageData; coreUsageDataStart.getCoreUsageData.mockResolvedValue(getCoreUsageDataReturnValue); beforeAll(() => registerCoreUsageCollector(usageCollectionMock, () => coreUsageDataStart)); diff --git a/src/plugins/kibana_usage_collection/server/collectors/kibana/get_saved_object_counts.test.ts b/src/plugins/kibana_usage_collection/server/collectors/kibana/get_saved_object_counts.test.ts index 15cbecde386f7..3d5d7854d6f9e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/kibana/get_saved_object_counts.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/kibana/get_saved_object_counts.test.ts @@ -9,13 +9,11 @@ import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; import { getSavedObjectsCounts } from './get_saved_object_counts'; -export function mockGetSavedObjectsCounts(params: any) { +export function mockGetSavedObjectsCounts(params: TBody) { const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; esClient.search.mockResolvedValue( // @ts-expect-error we only care about the response body - { - body: { ...params }, - } + { body: { ...params } } ); return esClient; } diff --git a/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts index e1afbfbcecc4e..2c75d3edc3a84 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts @@ -34,7 +34,8 @@ describe('telemetry_kibana', () => { const getMockFetchClients = (hits?: unknown[]) => { const fetchParamsMock = createCollectorFetchContextMock(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.search.mockResolvedValue({ body: { hits: { hits } } } as any); + // @ts-expect-error for the sake of the tests, we only require `hits` + esClient.search.mockResolvedValue({ body: { hits: { hits } } }); fetchParamsMock.esClient = esClient; return fetchParamsMock; }; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts index cb0b1c045397d..8295342c527ab 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts @@ -17,6 +17,7 @@ import { registerManagementUsageCollector, createCollectorFetch, } from './telemetry_management_collector'; +import { IUiSettingsClient } from 'kibana/server'; const logger = loggingSystemMock.createLogger(); @@ -30,7 +31,7 @@ describe('telemetry_application_usage_collector', () => { }); const uiSettingsClient = uiSettingsServiceMock.createClient(); - const getUiSettingsClient = jest.fn(() => uiSettingsClient); + const getUiSettingsClient = jest.fn((): IUiSettingsClient | undefined => uiSettingsClient); const mockedFetchContext = createCollectorFetchContextMock(); beforeAll(() => { @@ -42,7 +43,7 @@ describe('telemetry_application_usage_collector', () => { }); test('isReady() => false if no client', () => { - getUiSettingsClient.mockImplementationOnce(() => undefined as any); + getUiSettingsClient.mockImplementationOnce(() => undefined); expect(collector.isReady()).toBe(false); }); @@ -60,7 +61,7 @@ describe('telemetry_application_usage_collector', () => { }); test('fetch() should not fail if invoked when not ready', async () => { - getUiSettingsClient.mockImplementationOnce(() => undefined as any); + getUiSettingsClient.mockImplementationOnce(() => undefined); await expect(collector.fetch(mockedFetchContext)).resolves.toBe(undefined); }); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts index cba5140997f3f..7dd1a4dc4410e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts @@ -22,12 +22,13 @@ export function createCollectorFetch(getUiSettingsClient: () => IUiSettingsClien const userProvided = await uiSettingsClient.getUserProvided(); const modifiedEntries = Object.entries(userProvided) .filter(([key]) => key !== 'buildNum') - .reduce((obj: any, [key, { userValue }]) => { + .reduce((obj: Record, [key, { userValue }]) => { const sensitive = uiSettingsClient.isSensitive(key); obj[key] = sensitive ? REDACTED_KEYWORD : userValue; return obj; }, {}); - return modifiedEntries; + // TODO: It would be Partial, but the telemetry-tools for the schema extraction still does not support it. We need to fix it before setting the right Partial type + return (modifiedEntries as unknown) as UsageStats; }; } diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts index 51ecbf736bfc1..31cb869d14e57 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts @@ -30,6 +30,8 @@ describe('telemetry_ui_metric', () => { const registerType = jest.fn(); const mockedFetchContext = createCollectorFetchContextMock(); + const commonSavedObjectsAttributes = { score: 0, references: [], type: 'ui-metric' }; + beforeAll(() => registerUiMetricUsageCollector(usageCollectionMock, registerType, getUsageCollector) ); @@ -44,13 +46,12 @@ describe('telemetry_ui_metric', () => { test('when savedObjectClient is initialised, return something', async () => { const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation( - async () => - ({ - saved_objects: [], - total: 0, - } as any) - ); + savedObjectClient.find.mockImplementation(async () => ({ + saved_objects: [], + total: 0, + per_page: 10, + page: 1, + })); getUsageCollector.mockImplementation(() => savedObjectClient); expect(await collector.fetch(mockedFetchContext)).toStrictEqual({}); @@ -59,20 +60,33 @@ describe('telemetry_ui_metric', () => { test('results grouped by appName', async () => { const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation(async () => { - return { - saved_objects: [ - { id: 'testAppName:testKeyName1', attributes: { count: 3 } }, - { id: 'testAppName:testKeyName2', attributes: { count: 5 } }, - { id: 'testAppName2:testKeyName3', attributes: { count: 1 } }, - { - id: - 'kibana-user_agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:80.0) Gecko/20100101 Firefox/80.0', - attributes: { count: 1 }, - }, - ], - total: 3, - } as any; + savedObjectClient.find.mockResolvedValue({ + saved_objects: [ + { + ...commonSavedObjectsAttributes, + id: 'testAppName:testKeyName1', + attributes: { count: 3 }, + }, + { + ...commonSavedObjectsAttributes, + id: 'testAppName:testKeyName2', + attributes: { count: 5 }, + }, + { + ...commonSavedObjectsAttributes, + id: 'testAppName2:testKeyName3', + attributes: { count: 1 }, + }, + { + ...commonSavedObjectsAttributes, + id: + 'kibana-user_agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:80.0) Gecko/20100101 Firefox/80.0', + attributes: { count: 1 }, + }, + ], + total: 3, + per_page: 3, + page: 1, }); getUsageCollector.mockImplementation(() => savedObjectClient); diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index bc27cf061eb68..9af1bb5434bb1 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,5 +1,13 @@ .kbnTopNavMenu { - margin-right: $euiSizeXS; + @include kbnThemeStyle('v7') { + margin-right: $euiSizeXS; + } + + @include kbnThemeStyle('v8') { + button:last-child { + margin-right: 0; + } + } } .kbnTopNavMenu__badgeWrapper { diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss index b8022201acf59..4fc3651ee9f73 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss @@ -4,4 +4,9 @@ // Lighten the border color for all states border-color: $euiBorderColor !important; // sass-lint:disable-line no-important + + @include kbnThemeStyle('v8') { + border-width: $euiBorderWidthThin; + border-style: solid; + } } diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss index 870a9a945ed5d..876ee659b71d7 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss @@ -1,6 +1,12 @@ .quickButtonGroup { .quickButtonGroup__button { background-color: $euiColorEmptyShade; + @include kbnThemeStyle('v8') { + // sass-lint:disable-block no-important + border-width: $euiBorderWidthThin !important; + border-style: solid !important; + border-color: $euiBorderColor !important; + } } // Temporary fix for two tone icons to make them monochrome diff --git a/src/plugins/saved_objects_management/common/index.ts b/src/plugins/saved_objects_management/common/index.ts index 06db1eaa25de8..efabdace329c3 100644 --- a/src/plugins/saved_objects_management/common/index.ts +++ b/src/plugins/saved_objects_management/common/index.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -export { +export type { SavedObjectWithMetadata, SavedObjectMetadata, SavedObjectRelation, diff --git a/src/plugins/saved_objects_management/common/types.ts b/src/plugins/saved_objects_management/common/types.ts index a6c25a6785e1a..7899cd0938ad3 100644 --- a/src/plugins/saved_objects_management/common/types.ts +++ b/src/plugins/saved_objects_management/common/types.ts @@ -19,6 +19,7 @@ export interface SavedObjectMetadata { editUrl?: string; inAppUrl?: { path: string; uiCapabilitiesPath: string }; namespaceType?: SavedObjectsNamespaceType; + hiddenType?: boolean; } /** diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx index cf4dcb7c6efda..2a7c56faa8507 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx @@ -89,7 +89,7 @@ export class SavedObjectEdition extends Component<
this.delete()} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index fdd423e10a117..809cd7a96a0ed 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -9,10 +9,12 @@ exports[`SavedObjectsTable delete should show a confirm modal 1`] = ` Array [ Object { "id": "1", + "meta": Object {}, "type": "index-pattern", }, Object { "id": "3", + "meta": Object {}, "type": "dashboard", }, ] diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx index f6f00c95d9bf1..d589d5a700801 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { FC } from 'react'; +import React, { FC, useMemo } from 'react'; import { EuiInMemoryTable, EuiLoadingElastic, @@ -23,6 +23,7 @@ import { EuiButtonEmpty, EuiButton, EuiSpacer, + EuiCallOut, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -42,6 +43,13 @@ export const DeleteConfirmModal: FC = ({ onCancel, selectedObjects, }) => { + const undeletableObjects = useMemo(() => { + return selectedObjects.filter((obj) => obj.meta.hiddenType); + }, [selectedObjects]); + const deletableObjects = useMemo(() => { + return selectedObjects.filter((obj) => !obj.meta.hiddenType); + }, [selectedObjects]); + if (isDeleting) { return ( @@ -49,7 +57,6 @@ export const DeleteConfirmModal: FC = ({ ); } - // can't use `EuiConfirmModal` here as the confirm modal body is wrapped // inside a `

` element, causing UI glitches with the table. return ( @@ -63,6 +70,29 @@ export const DeleteConfirmModal: FC = ({ + {undeletableObjects.length > 0 && ( + <> + + } + iconType="alert" + color="warning" + > +

+ +

+ + + + )}

= ({

{ const component = shallowRender(); const mockSelectedSavedObjects = [ - { id: '1', type: 'index-pattern' }, - { id: '3', type: 'dashboard' }, + { id: '1', type: 'index-pattern', meta: {} }, + { id: '3', type: 'dashboard', meta: {} }, ] as SavedObjectWithMetadata[]; // Ensure all promises resolve @@ -498,8 +498,8 @@ describe('SavedObjectsTable', () => { it('should delete selected objects', async () => { const mockSelectedSavedObjects = [ - { id: '1', type: 'index-pattern' }, - { id: '3', type: 'dashboard' }, + { id: '1', type: 'index-pattern', meta: {} }, + { id: '3', type: 'dashboard', meta: {} }, ] as SavedObjectWithMetadata[]; const mockSavedObjects = mockSelectedSavedObjects.map((obj) => ({ @@ -529,7 +529,6 @@ describe('SavedObjectsTable', () => { await component.instance().delete(); expect(defaultProps.indexPatterns.clearCache).toHaveBeenCalled(); - expect(mockSavedObjectsClient.bulkGet).toHaveBeenCalledWith(mockSelectedSavedObjects); expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith( mockSavedObjects[0].type, mockSavedObjects[0].id, @@ -542,5 +541,44 @@ describe('SavedObjectsTable', () => { ); expect(component.state('selectedSavedObjects').length).toBe(0); }); + + it('should not delete hidden selected objects', async () => { + const mockSelectedSavedObjects = [ + { id: '1', type: 'index-pattern', meta: {} }, + { id: '3', type: 'hidden-type', meta: { hiddenType: true } }, + ] as SavedObjectWithMetadata[]; + + const mockSavedObjects = mockSelectedSavedObjects.map((obj) => ({ + id: obj.id, + type: obj.type, + source: {}, + })); + + const mockSavedObjectsClient = { + ...defaultProps.savedObjectsClient, + bulkGet: jest.fn().mockImplementation(() => ({ + savedObjects: mockSavedObjects, + })), + delete: jest.fn(), + }; + + const component = shallowRender({ savedObjectsClient: mockSavedObjectsClient }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + // Set some as selected + component.instance().onSelectionChanged(mockSelectedSavedObjects); + + await component.instance().delete(); + + expect(defaultProps.indexPatterns.clearCache).toHaveBeenCalled(); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith('index-pattern', '1', { + force: true, + }); + }); }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index 1d272e818ea1e..c207766918a70 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -455,10 +455,9 @@ export class SavedObjectsTable extends Component - savedObjectsClient.delete(object.type, object.id, { force: true }) - ); + const deletes = selectedSavedObjects + .filter((object) => !object.meta.hiddenType) + .map((object) => savedObjectsClient.delete(object.type, object.id, { force: true })); await Promise.all(deletes); // Unset this diff --git a/src/plugins/saved_objects_management/public/services/types/record.ts b/src/plugins/saved_objects_management/public/services/types/record.ts index 17bdbc3a075f5..fc92c83cfc790 100644 --- a/src/plugins/saved_objects_management/public/services/types/record.ts +++ b/src/plugins/saved_objects_management/public/services/types/record.ts @@ -15,6 +15,7 @@ export interface SavedObjectsManagementRecord { icon: string; title: string; namespaceType: SavedObjectsNamespaceType; + hiddenType: boolean; }; references: SavedObjectReference[]; namespaces?: string[]; diff --git a/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts b/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts index 686715aba7f17..0da14cbee4fd5 100644 --- a/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts +++ b/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts @@ -317,6 +317,7 @@ describe('findRelationships', () => { title: 'title', icon: 'icon', editUrl: 'editUrl', + hiddenType: false, inAppUrl: { path: 'path', uiCapabilitiesPath: 'uiCapabilitiesPath', diff --git a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts index bc775a03e276d..7b5f52d6bf968 100644 --- a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts +++ b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts @@ -49,6 +49,7 @@ describe('injectMetaAttributes', () => { uiCapabilitiesPath: 'uiCapabilitiesPath', }, namespaceType: 'single', + hiddenType: false, }, }); }); diff --git a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts index ee64010994109..d5b585371cbdf 100644 --- a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts +++ b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts @@ -25,6 +25,7 @@ export function injectMetaAttributes( result.meta.editUrl = savedObjectsManagement.getEditUrl(savedObject); result.meta.inAppUrl = savedObjectsManagement.getInAppUrl(savedObject); result.meta.namespaceType = savedObjectsManagement.getNamespaceType(savedObject); + result.meta.hiddenType = savedObjectsManagement.isHidden(savedObject); return result; } diff --git a/src/plugins/saved_objects_management/server/services/management.mock.ts b/src/plugins/saved_objects_management/server/services/management.mock.ts index 6541c0d2847f5..2ab5bea4f8440 100644 --- a/src/plugins/saved_objects_management/server/services/management.mock.ts +++ b/src/plugins/saved_objects_management/server/services/management.mock.ts @@ -19,6 +19,7 @@ const createManagementMock = () => { getEditUrl: jest.fn(), getInAppUrl: jest.fn(), getNamespaceType: jest.fn(), + isHidden: jest.fn().mockReturnValue(false), }; return mocked; }; diff --git a/src/plugins/saved_objects_management/server/services/management.ts b/src/plugins/saved_objects_management/server/services/management.ts index 395ba639846a8..176c52c5a21bc 100644 --- a/src/plugins/saved_objects_management/server/services/management.ts +++ b/src/plugins/saved_objects_management/server/services/management.ts @@ -44,4 +44,8 @@ export class SavedObjectsManagement { public getNamespaceType(savedObject: SavedObject) { return this.registry.getType(savedObject.type)?.namespaceType; } + + public isHidden(savedObject: SavedObject) { + return this.registry.getType(savedObject.type)?.hidden ?? false; + } } diff --git a/src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.test.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.test.ts index 617b5189de4a8..c93ba53230954 100644 --- a/src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.test.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.test.ts @@ -35,9 +35,10 @@ describe('getTelemetryFailureDetails: get details about server usage fetcher fai expect( getTelemetryFailureDetails({ telemetrySavedObject: { + // @ts-expect-error the test is intentionally testing malformed SOs reportFailureCount: null, reportFailureVersion: failureVersion, - } as any, + }, }) ).toStrictEqual({ failureVersion, failureCount: 0 }); expect( @@ -51,9 +52,10 @@ describe('getTelemetryFailureDetails: get details about server usage fetcher fai expect( getTelemetryFailureDetails({ telemetrySavedObject: { + // @ts-expect-error the test is intentionally testing malformed SOs reportFailureCount: 'not_a_number', reportFailureVersion: failureVersion, - } as any, + }, }) ).toStrictEqual({ failureVersion, failureCount: 0 }); }); @@ -63,9 +65,10 @@ describe('getTelemetryFailureDetails: get details about server usage fetcher fai expect( getTelemetryFailureDetails({ telemetrySavedObject: { + // @ts-expect-error the test is intentionally testing malformed SOs reportFailureVersion: null, reportFailureCount: failureCount, - } as any, + }, }) ).toStrictEqual({ failureCount, failureVersion: undefined }); expect( @@ -76,9 +79,10 @@ describe('getTelemetryFailureDetails: get details about server usage fetcher fai expect( getTelemetryFailureDetails({ telemetrySavedObject: { + // @ts-expect-error the test is intentionally testing malformed SOs reportFailureVersion: 123, reportFailureCount: failureCount, - } as any, + }, }) ).toStrictEqual({ failureCount, failureVersion: undefined }); }); diff --git a/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.test.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.test.ts index 65e4a2d43eef7..ede56688e0449 100644 --- a/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.test.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.test.ts @@ -55,6 +55,7 @@ describe('getTelemetryOptIn', () => { // build a table of tests with version checks, with results for enabled false type VersionCheckTable = Array>; + // @ts-expect-error the test is intentionally testing malformed objects const EnabledFalseVersionChecks: VersionCheckTable = [ { lastVersionChecked: '8.0.0', currentKibanaVersion: '8.0.0', result: false }, { lastVersionChecked: '8.0.0', currentKibanaVersion: '8.0.1', result: false }, @@ -112,7 +113,7 @@ describe('getTelemetryOptIn', () => { interface CallGetTelemetryOptInParams { savedObjectNotFound: boolean; savedObjectForbidden: boolean; - lastVersionChecked?: any; // should be a string, but test with non-strings + lastVersionChecked?: string; // should be a string, but test with non-strings currentKibanaVersion: string; result?: boolean | null; enabled: boolean | null | undefined; diff --git a/src/plugins/telemetry/public/components/opted_in_notice_banner.tsx b/src/plugins/telemetry/public/components/opted_in_notice_banner.tsx index b91f6ee9e4b51..a10c26c22e3fa 100644 --- a/src/plugins/telemetry/public/components/opted_in_notice_banner.tsx +++ b/src/plugins/telemetry/public/components/opted_in_notice_banner.tsx @@ -17,7 +17,7 @@ import { HttpSetup } from '../../../../core/public'; interface Props { http: HttpSetup; - onSeenBanner: () => any; + onSeenBanner: () => unknown; } export class OptedInNoticeBanner extends React.PureComponent { diff --git a/src/plugins/telemetry/public/index.ts b/src/plugins/telemetry/public/index.ts index 47ba7828eaec2..8d1747d9c33f1 100644 --- a/src/plugins/telemetry/public/index.ts +++ b/src/plugins/telemetry/public/index.ts @@ -6,9 +6,15 @@ * Side Public License, v 1. */ -import { PluginInitializerContext } from 'kibana/public'; -import { TelemetryPlugin, TelemetryPluginConfig } from './plugin'; -export type { TelemetryPluginStart, TelemetryPluginSetup } from './plugin'; +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/render_opt_in_banner.tsx b/src/plugins/telemetry/public/services/telemetry_notifications/render_opt_in_banner.tsx index 4180f577e3037..f880aef3e3235 100644 --- a/src/plugins/telemetry/public/services/telemetry_notifications/render_opt_in_banner.tsx +++ b/src/plugins/telemetry/public/services/telemetry_notifications/render_opt_in_banner.tsx @@ -13,7 +13,7 @@ import { toMountPoint } from '../../../../kibana_react/public'; interface RenderBannerConfig { overlays: CoreStart['overlays']; - setOptIn: (isOptIn: boolean) => Promise; + setOptIn: (isOptIn: boolean) => Promise; } export function renderOptInBanner({ setOptIn, overlays }: RenderBannerConfig) { 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_sender.ts b/src/plugins/telemetry/public/services/telemetry_sender.ts index 05588f4c9e704..937416d283872 100644 --- a/src/plugins/telemetry/public/services/telemetry_sender.ts +++ b/src/plugins/telemetry/public/services/telemetry_sender.ts @@ -58,8 +58,8 @@ export class TelemetrySender { this.isSending = true; try { const telemetryUrl = this.telemetryService.getTelemetryUrl(); - const telemetryData: any | any[] = await this.telemetryService.fetchTelemetry(); - const clusters: string[] = [].concat(telemetryData); + const telemetryData: string | string[] = await this.telemetryService.fetchTelemetry(); + const clusters: string[] = ([] as string[]).concat(telemetryData); await Promise.all( clusters.map( async (cluster) => 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/collectors/usage/ensure_deep_object.ts b/src/plugins/telemetry/server/collectors/usage/ensure_deep_object.ts index 6b79cc6c7410a..c5624d1f62bf7 100644 --- a/src/plugins/telemetry/server/collectors/usage/ensure_deep_object.ts +++ b/src/plugins/telemetry/server/collectors/usage/ensure_deep_object.ts @@ -8,12 +8,15 @@ // // THIS IS A DIRECT COPY OF -// '../../../../../../../../src/core/server/config/ensure_deep_object' +// 'packages/kbn-config/src/raw/ensure_deep_object.ts' // BECAUSE THAT IS BLOCKED FOR IMPORTING BY OUR LINTER. // // IF THAT IS EXPOSED, WE SHOULD USE IT RATHER THAN CLONE IT. // +/* eslint-disable @typescript-eslint/no-explicit-any */ +// ^ Disabling the rule for the entire file because of the complexity to type this + const separator = '.'; /** diff --git a/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts b/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts index ac439f0753a2b..2acc6676d13db 100644 --- a/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts +++ b/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts @@ -15,10 +15,11 @@ import { readTelemetryFile, MAX_FILE_SIZE, } from './telemetry_usage_collector'; +import { usageCollectionPluginMock } from '../../../../usage_collection/server/mocks'; -const mockUsageCollector = () => ({ - makeUsageCollector: jest.fn().mockImplementationOnce((arg: object) => arg), -}); +const mockUsageCollector = () => { + return usageCollectionPluginMock.createSetupContract(); +}; describe('telemetry_usage_collector', () => { const tempDir = tmpdir(); @@ -105,14 +106,15 @@ describe('telemetry_usage_collector', () => { // dir // the `makeUsageCollector` is mocked above to return the argument passed to it - const usageCollector = mockUsageCollector() as any; + const usageCollector = mockUsageCollector(); const collectorOptions = createTelemetryUsageCollector( usageCollector, async () => tempFiles.unreadable ); expect(collectorOptions.type).toBe('static_telemetry'); - expect(await collectorOptions.fetch({} as any)).toEqual(expectedObject); // Sending any as the callCluster client because it's not needed in this collector but TS requires it when calling it. + // @ts-expect-error this collector does not require any arguments in the fetch method, but TS complains + expect(await collectorOptions.fetch()).toEqual(expectedObject); }); }); }); diff --git a/src/plugins/telemetry/server/fetcher.ts b/src/plugins/telemetry/server/fetcher.ts index 5db1b62cb3e26..fb188a2414b98 100644 --- a/src/plugins/telemetry/server/fetcher.ts +++ b/src/plugins/telemetry/server/fetcher.ts @@ -9,10 +9,7 @@ import { Observable, Subscription, timer } from 'rxjs'; import { take } from 'rxjs/operators'; import fetch from 'node-fetch'; -import { - TelemetryCollectionManagerPluginStart, - UsageStatsPayload, -} from 'src/plugins/telemetry_collection_manager/server'; +import type { TelemetryCollectionManagerPluginStart } from 'src/plugins/telemetry_collection_manager/server'; import { PluginInitializerContext, Logger, @@ -40,6 +37,7 @@ interface TelemetryConfig { telemetryUrl: string; failureCount: number; failureVersion: string | undefined; + currentVersion: string; } export class FetcherTask { @@ -104,7 +102,7 @@ export class FetcherTask { return; } - let clusters: Array = []; + let clusters: string[] = []; this.isSending = true; try { @@ -160,6 +158,7 @@ export class FetcherTask { telemetryUrl, failureCount, failureVersion, + currentVersion: currentKibanaVersion, }; } @@ -187,11 +186,11 @@ export class FetcherTask { private shouldSendReport({ telemetryOptIn, telemetrySendUsageFrom, - reportFailureCount, + failureCount, + failureVersion, currentVersion, - reportFailureVersion, - }: any) { - if (reportFailureCount > 2 && reportFailureVersion === currentVersion) { + }: TelemetryConfig) { + if (failureCount > 2 && failureVersion === currentVersion) { return false; } @@ -209,7 +208,7 @@ export class FetcherTask { }); } - private async sendTelemetry(url: string, cluster: any): Promise { + private async sendTelemetry(url: string, cluster: string): Promise { this.logger.debug(`Sending usage stats.`); /** * send OPTIONS before sending usage data. diff --git a/src/plugins/telemetry/server/index.ts b/src/plugins/telemetry/server/index.ts index 1c335426ffd03..530f7c499c3f2 100644 --- a/src/plugins/telemetry/server/index.ts +++ b/src/plugins/telemetry/server/index.ts @@ -8,11 +8,8 @@ import { PluginInitializerContext, PluginConfigDescriptor } from 'kibana/server'; import { TelemetryPlugin } from './plugin'; -import * as constants from '../common/constants'; import { configSchema, TelemetryConfigType } from './config'; -export { FetcherTask } from './fetcher'; -export { handleOldSettings } from './handle_old_settings'; export type { TelemetryPluginSetup, TelemetryPluginStart } from './plugin'; export const config: PluginConfigDescriptor = { @@ -30,16 +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, } 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_info.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.test.ts index 9e70e31925226..cd414beb42182 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.test.ts @@ -9,14 +9,10 @@ import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { getClusterInfo } from './get_cluster_info'; -export function mockGetClusterInfo(clusterInfo: any) { +export function mockGetClusterInfo(clusterInfo: ClusterInfo) { const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.info.mockResolvedValue( - // @ts-expect-error we only care about the response body - { - body: { ...clusterInfo }, - } - ); + // @ts-expect-error we only care about the response body + esClient.info.mockResolvedValue({ body: { ...clusterInfo } }); return esClient; } diff --git a/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.test.ts index a2c22fbbb0a78..06d3ebeb7ea0e 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.test.ts @@ -10,15 +10,9 @@ import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { getClusterStats } from './get_cluster_stats'; import { TIMEOUT } from './constants'; -export function mockGetClusterStats(clusterStats: any) { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.cluster.stats.mockResolvedValue(clusterStats); - return esClient; -} - describe('get_cluster_stats', () => { it('uses the esClient to get the response from the `cluster.stats` API', async () => { - const response = Promise.resolve({ body: { cluster_uuid: '1234' } }); + const response = { body: { cluster_uuid: '1234' } }; const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; esClient.cluster.stats.mockImplementationOnce( // @ts-expect-error the method only cares about the response body @@ -26,8 +20,8 @@ describe('get_cluster_stats', () => { return response; } ); - const result = getClusterStats(esClient); + const result = await getClusterStats(esClient); expect(esClient.cluster.stats).toHaveBeenCalledWith({ timeout: TIMEOUT }); - expect(result).toStrictEqual(response); + expect(result).toStrictEqual(response.body); }); }); 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.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts index d2113dce9548f..dab1eaeed27ce 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts @@ -271,7 +271,7 @@ describe('get_data_telemetry', () => { function mockEsClient( indicesMappings: string[] = [], // an array of `indices` to get mappings from. { isECS = false, dataStreamDataset = '', dataStreamType = '', shipper = '' } = {}, - indexStats: any = {} + indexStats = {} ) { const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; // @ts-expect-error 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_kibana.ts b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts index 566c942890150..3f1966901544a 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts @@ -8,7 +8,7 @@ import { omit } from 'lodash'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { ISavedObjectsRepository, KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; import { StatsCollectionContext } from 'src/plugins/telemetry_collection_manager/server'; import { ElasticsearchClient } from 'src/core/server'; @@ -27,7 +27,7 @@ export interface KibanaUsageStats { }; }; - [plugin: string]: any; + [plugin: string]: Record; } export function handleKibanaStats( @@ -73,7 +73,7 @@ export function handleKibanaStats( export async function getKibana( usageCollection: UsageCollectionSetup, asInternalUser: ElasticsearchClient, - soClient: SavedObjectsClientContract | ISavedObjectsRepository, + soClient: SavedObjectsClientContract, kibanaRequest: KibanaRequest | undefined // intentionally `| undefined` to enforce providing the parameter ): Promise { const usage = await usageCollection.bulkFetch(asInternalUser, soClient, kibanaRequest); diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts index edf8dbb30809b..7fd6ca4080d6a 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts @@ -20,17 +20,18 @@ import { StatsCollectionConfig } from '../../../telemetry_collection_manager/ser function mockUsageCollection(kibanaUsage = {}) { const usageCollection = usageCollectionPluginMock.createSetupContract(); usageCollection.bulkFetch = jest.fn().mockResolvedValue(kibanaUsage); - usageCollection.toObject = jest.fn().mockImplementation((data: any) => data); + usageCollection.toObject = jest.fn().mockImplementation((data) => data); return usageCollection; } // set up successful call mocks for info, cluster stats, nodes usage and data telemetry -function mockGetLocalStats(clusterInfo: any, clusterStats: any) { +function mockGetLocalStats( + clusterInfo: ClusterInfo, + clusterStats: ClusterStats +) { const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; esClient.info.mockResolvedValue( // @ts-expect-error we only care about the response body - { - body: { ...clusterInfo }, - } + { body: { ...clusterInfo } } ); esClient.cluster.stats // @ts-expect-error we only care about the response body @@ -70,8 +71,8 @@ function mockGetLocalStats(clusterInfo: any, clusterStats: any) { } function mockStatsCollectionConfig( - clusterInfo: any, - clusterStats: any, + clusterInfo: unknown, + clusterStats: unknown, kibana: {} ): StatsCollectionConfig { return { @@ -113,13 +114,13 @@ describe('get_local_stats', () => { }, }, ]; - const clusterStats = { + const clusterStats = ({ _nodes: { failed: 123 }, cluster_name: 'real-cool', indices: { totally: 456 }, nodes: { yup: 'abc' }, random: 123, - }; + } as unknown) as estypes.ClusterStatsResponse; const kibana = { kibana: { 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 67f9ebb8ff3e4..7fdcb50b704af 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts @@ -26,10 +26,10 @@ import { getDataTelemetry, DATA_TELEMETRY_ID, DataTelemetryPayload } from './get * @param {Object} clusterStats Cluster stats (GET /_cluster/stats) * @param {Object} kibana The Kibana Usage stats */ -export function handleLocalStats( +export function handleLocalStats( // eslint-disable-next-line @typescript-eslint/naming-convention { cluster_name, cluster_uuid, version }: estypes.RootNodeInfoResponse, - { _nodes, cluster_name: clusterName, ...clusterStats }: any, + { _nodes, cluster_name: clusterName, ...clusterStats }: ClusterStats, kibana: KibanaUsageStats | undefined, dataTelemetry: DataTelemetryPayload | undefined, context: StatsCollectionContext @@ -48,13 +48,17 @@ export function handleLocalStats( }; } +/** + * The payload structure as composed by the OSS telemetry collection mechanism. + */ export type TelemetryLocalStats = ReturnType; /** * 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 e46d4be540734..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 NodeAggregation { - [key: string]: number; -} - -// we set aggregations as an optional type because it was only added in v7.8.0 -export interface NodeObj { - node_id?: string; - timestamp: number | string; - since: number; - rest_actions: { - [key: string]: number; - }; - aggregations?: { - [key: string]: NodeAggregation; - }; -} - -export interface NodesFeatureUsageResponse { - cluster_name: string; - nodes: { - [key: string]: NodeObj; - }; +/** + * 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: NodeObj[] | 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 NodeObj), + ...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 151e89a11a192..1126cbd1aa189 100644 --- a/src/plugins/telemetry/server/telemetry_collection/index.ts +++ b/src/plugins/telemetry/server/telemetry_collection/index.ts @@ -7,8 +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 } from './get_nodes_usage'; export { getClusterUuids } from './get_cluster_stats'; export { registerCollection } from './register_collection'; diff --git a/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts b/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts index fbead0125fe09..5ea8211739a13 100644 --- a/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts +++ b/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts @@ -8,6 +8,7 @@ import { getTelemetrySavedObject } from './get_telemetry_saved_object'; import { SavedObjectsErrorHelpers } from '../../../../core/server'; +import { savedObjectsClientMock } from '../../../../core/server/mocks'; describe('getTelemetrySavedObject', () => { it('returns null when saved object not found', async () => { @@ -51,7 +52,7 @@ interface CallGetTelemetrySavedObjectParams { savedObjectNotFound: boolean; savedObjectForbidden: boolean; savedObjectOtherError: boolean; - result?: any; + result?: unknown; } const DefaultParams = { @@ -68,26 +69,22 @@ function getCallGetTelemetrySavedObjectParams( async function callGetTelemetrySavedObject(params: CallGetTelemetrySavedObjectParams) { const savedObjectsClient = getMockSavedObjectsClient(params); - return await getTelemetrySavedObject(savedObjectsClient as any); + return await getTelemetrySavedObject(savedObjectsClient); } const SavedObjectForbiddenMessage = 'savedObjectForbidden'; const SavedObjectOtherErrorMessage = 'savedObjectOtherError'; function getMockSavedObjectsClient(params: CallGetTelemetrySavedObjectParams) { - return { - async get(type: string, id: string) { - if (params.savedObjectNotFound) throw SavedObjectsErrorHelpers.createGenericNotFoundError(); - if (params.savedObjectForbidden) - throw SavedObjectsErrorHelpers.decorateForbiddenError( - new Error(SavedObjectForbiddenMessage) - ); - if (params.savedObjectOtherError) - throw SavedObjectsErrorHelpers.decorateGeneralError( - new Error(SavedObjectOtherErrorMessage) - ); - - return { attributes: { enabled: null } }; - }, - }; + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockImplementation(async (type, id) => { + if (params.savedObjectNotFound) throw SavedObjectsErrorHelpers.createGenericNotFoundError(); + if (params.savedObjectForbidden) + throw SavedObjectsErrorHelpers.decorateForbiddenError(new Error(SavedObjectForbiddenMessage)); + if (params.savedObjectOtherError) + throw SavedObjectsErrorHelpers.decorateGeneralError(new Error(SavedObjectOtherErrorMessage)); + + return { id, type, attributes: { enabled: null }, references: [] }; + }); + return savedObjectsClient; } diff --git a/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts index a2c24627f6fd7..1b80a2c29b362 100644 --- a/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts +++ b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts @@ -13,12 +13,12 @@ export function getKID(useProdKey = false): string { return useProdKey ? 'kibana1' : 'kibana_dev1'; } -export async function encryptTelemetry( - payload: any, +export async function encryptTelemetry( + payload: Payload | Payload[], { useProdKey = false } = {} ): Promise { const kid = getKID(useProdKey); const encryptor = await createRequestEncryptor(telemetryJWKS); - const clusters = [].concat(payload); - return Promise.all(clusters.map((cluster: any) => encryptor.encrypt(kid, cluster))); + const clusters = ([] as Payload[]).concat(payload); + return Promise.all(clusters.map((cluster) => encryptor.encrypt(kid, cluster))); } diff --git a/src/plugins/telemetry_collection_manager/server/plugin.ts b/src/plugins/telemetry_collection_manager/server/plugin.ts index 692d91b963d9d..0efdde5eeafd6 100644 --- a/src/plugins/telemetry_collection_manager/server/plugin.ts +++ b/src/plugins/telemetry_collection_manager/server/plugin.ts @@ -7,7 +7,7 @@ */ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { +import type { PluginInitializerContext, CoreSetup, CoreStart, @@ -19,7 +19,7 @@ import { SavedObjectsClientContract, } from 'src/core/server'; -import { +import type { TelemetryCollectionManagerPluginSetup, TelemetryCollectionManagerPluginStart, BasicStatsPayload, @@ -29,6 +29,8 @@ import { StatsCollectionConfig, UsageStatsPayload, StatsCollectionContext, + UnencryptedStatsGetterConfig, + EncryptedStatsGetterConfig, } from './types'; import { encryptTelemetry } from './encryption'; import { TelemetrySavedObjectsClient } from './telemetry_saved_objects_client'; @@ -40,7 +42,7 @@ interface TelemetryCollectionPluginsDepsSetup { export class TelemetryCollectionManagerPlugin implements Plugin { private readonly logger: Logger; - private collectionStrategy: CollectionStrategy | undefined; + private collectionStrategy: CollectionStrategy | undefined; private usageGetterMethodPriority = -1; private usageCollection?: UsageCollectionSetup; private elasticsearchClient?: IClusterClient; @@ -215,6 +217,8 @@ export class TelemetryCollectionManagerPlugin })); }; + private async getStats(config: UnencryptedStatsGetterConfig): Promise; + private async getStats(config: EncryptedStatsGetterConfig): Promise; private async getStats(config: StatsGetterConfig) { if (!this.usageCollection) { return []; diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index 896b1671328a9..e0ba7e7527af7 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -95,13 +95,14 @@ exports[`TelemetryManagementSectionComponent renders as expected 1`] = ` size="s" />

@@ -156,12 +157,26 @@ exports[`TelemetryManagementSectionComponent renders as expected 1`] = `

, "displayName": "Provide usage statistics", + "isCustom": true, + "isOverridden": false, "name": "telemetry:enabled", + "requiresPageReload": false, "type": "boolean", "value": true, } } - toasts={null} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } /> @@ -170,6 +185,7 @@ exports[`TelemetryManagementSectionComponent renders as expected 1`] = ` exports[`TelemetryManagementSectionComponent renders null because allowChangingOptInStatus is false 1`] = ` Promise; + fetchExample: () => Promise; onClose: () => void; } interface State { isLoading: boolean; hasPrivilegeToRead: boolean; - data: any[] | null; + data: unknown[] | null; } /** diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx index 7e7e255edea8c..019dedd793fa2 100644 --- a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx @@ -12,9 +12,11 @@ import TelemetryManagementSection from './telemetry_management_section'; import { TelemetryService } from '../../../telemetry/public/services'; import { coreMock } from '../../../../core/public/mocks'; import { render } from '@testing-library/react'; +import type { DocLinksStart } from 'src/core/public'; describe('TelemetryManagementSectionComponent', () => { const coreStart = coreMock.createStart(); + const docLinks = {} as DocLinksStart['links']; const coreSetup = coreMock.createSetup(); it('renders as expected', () => { @@ -45,6 +47,7 @@ describe('TelemetryManagementSectionComponent', () => { enableSaving={true} isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ) ).toMatchSnapshot(); @@ -78,6 +81,7 @@ describe('TelemetryManagementSectionComponent', () => { enableSaving={true} isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ); @@ -93,6 +97,7 @@ describe('TelemetryManagementSectionComponent', () => { enableSaving={true} toasts={coreStart.notifications.toasts} isSecurityExampleEnabled={isSecurityExampleEnabled} + docLinks={docLinks} /> ); @@ -130,6 +135,7 @@ describe('TelemetryManagementSectionComponent', () => { isSecurityExampleEnabled={isSecurityExampleEnabled} enableSaving={true} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ); try { @@ -177,6 +183,7 @@ describe('TelemetryManagementSectionComponent', () => { enableSaving={true} isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ); try { @@ -215,6 +222,7 @@ describe('TelemetryManagementSectionComponent', () => { enableSaving={true} isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ); try { @@ -254,6 +262,7 @@ describe('TelemetryManagementSectionComponent', () => { isSecurityExampleEnabled={isSecurityExampleEnabled} enableSaving={true} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ); try { @@ -293,6 +302,7 @@ describe('TelemetryManagementSectionComponent', () => { isSecurityExampleEnabled={isSecurityExampleEnabled} enableSaving={true} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ); @@ -332,6 +342,7 @@ describe('TelemetryManagementSectionComponent', () => { enableSaving={true} isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ); try { @@ -339,11 +350,12 @@ describe('TelemetryManagementSectionComponent', () => { await expect( toggleOptInComponent.prop('handleChange')() ).resolves.toBe(true); - expect((component.state() as any).enabled).toBe(true); + // TODO: Fix `mountWithIntl` types in @kbn/test/jest to make testing easier + expect((component.state() as { enabled: boolean }).enabled).toBe(true); await expect( toggleOptInComponent.prop('handleChange')() ).resolves.toBe(true); - expect((component.state() as any).enabled).toBe(false); + expect((component.state() as { enabled: boolean }).enabled).toBe(false); telemetryService.setOptIn = jest.fn().mockRejectedValue(Error('test-error')); await expect( toggleOptInComponent.prop('handleChange')() @@ -381,6 +393,7 @@ describe('TelemetryManagementSectionComponent', () => { enableSaving={true} toasts={coreStart.notifications.toasts} isSecurityExampleEnabled={isSecurityExampleEnabled} + docLinks={docLinks} /> ).html() ).toMatchSnapshot(); diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx index 5a9f3922c6caf..e9ddc4cf82dfc 100644 --- a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx @@ -20,12 +20,12 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; +import type { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; +import type { DocLinksStart, ToastsStart } from 'src/core/public'; import { PRIVACY_STATEMENT_URL } from '../../../telemetry/common/constants'; import { OptInExampleFlyout } from './opt_in_example_flyout'; import { OptInSecurityExampleFlyout } from './opt_in_security_example_flyout'; import { LazyField } from '../../../advanced_settings/public'; -import { ToastsStart } from '../../../../core/public'; import { TrackApplicationView } from '../../../usage_collection/public'; type TelemetryService = TelemetryPluginSetup['telemetryService']; @@ -40,6 +40,7 @@ interface Props { enableSaving: boolean; query?: { text: string }; toasts: ToastsStart; + docLinks: DocLinksStart['links']; } interface State { @@ -130,24 +131,26 @@ export class TelemetryManagementSection extends Component { {this.maybeGetAppliesSettingMessage()} diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx index cc38b1ec74b37..91881dffa52d7 100644 --- a/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx @@ -8,10 +8,12 @@ import React, { lazy, Suspense } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; -import { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; -// It should be this but the types are way too vague in the AdvancedSettings plugin `Record` -// type Props = Omit; -type Props = any; +import type { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; +import type TelemetryManagementSection from './telemetry_management_section'; +export type TelemetryManagementSectionWrapperProps = Omit< + TelemetryManagementSection['props'], + 'telemetryService' | 'showAppliesSettingMessage' | 'isSecurityExampleEnabled' +>; const TelemetryManagementSectionComponent = lazy(() => import('./telemetry_management_section')); @@ -19,7 +21,7 @@ export function telemetryManagementSectionWrapper( telemetryService: TelemetryPluginSetup['telemetryService'], shouldShowSecuritySolutionUsageExample: () => boolean ) { - const TelemetryManagementSectionWrapper = (props: Props) => ( + const TelemetryManagementSectionWrapper = (props: TelemetryManagementSectionWrapperProps) => ( }> ); }, diff --git a/src/plugins/usage_collection/server/collector/collector.ts b/src/plugins/usage_collection/server/collector/collector.ts index 90e873388d22e..22c91ac0c038d 100644 --- a/src/plugins/usage_collection/server/collector/collector.ts +++ b/src/plugins/usage_collection/server/collector/collector.ts @@ -141,11 +141,11 @@ export type CollectorOptions< }); export class Collector { - public readonly extendFetchContext: CollectorOptionsFetchExtendedContext; - public readonly type: CollectorOptions['type']; - public readonly init?: CollectorOptions['init']; - public readonly fetch: CollectorFetchMethod; - public readonly isReady: CollectorOptions['isReady']; + public readonly extendFetchContext: CollectorOptionsFetchExtendedContext; + public readonly type: CollectorOptions['type']; + public readonly init?: CollectorOptions['init']; + public readonly fetch: CollectorFetchMethod; + public readonly isReady: CollectorOptions['isReady']; /** * @private Constructor of a Collector. It should be called via the CollectorSet factory methods: `makeStatsCollector` and `makeUsageCollector` * @param log {@link Logger} @@ -160,7 +160,9 @@ export class Collector { isReady, extendFetchContext = {}, ...options - }: CollectorOptions + }: // Any does not affect here, but needs to be set so it doesn't affect anything else down the line + // eslint-disable-next-line @typescript-eslint/no-explicit-any + CollectorOptions ) { if (type === undefined) { throw new Error('Collector must be instantiated with a options.type string property'); diff --git a/src/plugins/usage_collection/server/collector/collector_set.test.ts b/src/plugins/usage_collection/server/collector/collector_set.test.ts index 0ef9a27cf094c..5a617e2316dda 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.test.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.test.ts @@ -13,7 +13,7 @@ import { UsageCollector } from './usage_collector'; import { elasticsearchServiceMock, loggingSystemMock, - savedObjectsRepositoryMock, + savedObjectsClientMock, } from '../../../../core/server/mocks'; const logger = loggingSystemMock.createLogger(); @@ -34,7 +34,7 @@ describe('CollectorSet', () => { loggerSpies.warn.mockRestore(); }); const mockEsClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - const mockSoClient = savedObjectsRepositoryMock.create(); + const mockSoClient = savedObjectsClientMock.create(); const req = void 0; // No need to instantiate any KibanaRequest in these tests it('should throw an error if non-Collector type of object is registered', () => { @@ -43,8 +43,9 @@ describe('CollectorSet', () => { collectors.registerCollector({ type: 'type_collector_test', init, + // @ts-expect-error we are intentionally sending it wrong. fetch, - } as any); // We are intentionally sending it wrong. + }); }; expect(registerPojo).toThrowError( @@ -71,13 +72,14 @@ describe('CollectorSet', () => { }); it('should log debug status of fetching from the collector', async () => { - mockEsClient.get.mockResolvedValue({ passTest: 1000 } as any); + // @ts-expect-error we are just mocking the output of any call + mockEsClient.ping.mockResolvedValue({ passTest: 1000 }); const collectors = new CollectorSet({ logger }); collectors.registerCollector( new Collector(logger, { type: 'MY_TEST_COLLECTOR', - fetch: (collectorFetchContext: any) => { - return collectorFetchContext.esClient.get(); + fetch: (collectorFetchContext) => { + return collectorFetchContext.esClient.ping(); }, isReady: () => true, }) @@ -122,7 +124,8 @@ describe('CollectorSet', () => { new Collector(logger, { type: 'MY_TEST_COLLECTOR', fetch: () => ({ test: 1 }), - isReady: true as any, + // @ts-expect-error we are intentionally sending it wrong + isReady: true, }) ); @@ -138,10 +141,11 @@ describe('CollectorSet', () => { it('should not break if isReady is not provided', async () => { const collectors = new CollectorSet({ logger }); collectors.registerCollector( + // @ts-expect-error we are intentionally sending it wrong. new Collector(logger, { type: 'MY_TEST_COLLECTOR', fetch: () => ({ test: 1 }), - } as any) + }) ); const result = await collectors.bulkFetch(mockEsClient, mockSoClient, req); diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index 4de5691eaaa70..d42eb6644bbbe 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -7,16 +7,17 @@ */ import { snakeCase } from 'lodash'; -import { +import type { Logger, ElasticsearchClient, - ISavedObjectsRepository, SavedObjectsClientContract, KibanaRequest, } from 'src/core/server'; import { Collector, CollectorOptions } from './collector'; import { UsageCollector, UsageCollectorOptions } from './usage_collector'; +// Needed for the general array containing all the collectors. We don't really care about their types here +// eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyCollector = Collector; interface CollectorSetConfig { @@ -144,7 +145,7 @@ export class CollectorSet { public bulkFetch = async ( esClient: ElasticsearchClient, - soClient: SavedObjectsClientContract | ISavedObjectsRepository, + soClient: SavedObjectsClientContract, kibanaRequest: KibanaRequest | undefined, // intentionally `| undefined` to enforce providing the parameter collectors: Map = this.collectors ) => { @@ -183,7 +184,7 @@ export class CollectorSet { public bulkFetchUsage = async ( esClient: ElasticsearchClient, - savedObjectsClient: SavedObjectsClientContract | ISavedObjectsRepository, + savedObjectsClient: SavedObjectsClientContract, kibanaRequest: KibanaRequest | undefined // intentionally `| undefined` to enforce providing the parameter ) => { const usageCollectors = this.getFilteredCollectorSet((c) => c instanceof UsageCollector); diff --git a/src/plugins/usage_collection/server/collector/usage_collector.ts b/src/plugins/usage_collection/server/collector/usage_collector.ts index 1509b10654f49..3af3a7bb65f84 100644 --- a/src/plugins/usage_collection/server/collector/usage_collector.ts +++ b/src/plugins/usage_collection/server/collector/usage_collector.ts @@ -23,6 +23,8 @@ export class UsageCollector exte > { constructor( log: Logger, + // Needed because it doesn't affect on anything here but being explicit creates a lot of pain down the line + // eslint-disable-next-line @typescript-eslint/no-explicit-any collectorOptions: UsageCollectorOptions ) { super(log, collectorOptions); diff --git a/src/plugins/usage_collection/server/report/store_report.test.ts b/src/plugins/usage_collection/server/report/store_report.test.ts index 08fdec4ae804f..789e01020bb2e 100644 --- a/src/plugins/usage_collection/server/report/store_report.test.ts +++ b/src/plugins/usage_collection/server/report/store_report.test.ts @@ -118,7 +118,7 @@ describe('store_report', () => { expect(storeApplicationUsageMock).toHaveBeenCalledTimes(1); expect(storeApplicationUsageMock).toHaveBeenCalledWith( repository, - Object.values(report.application_usage as Record), + Object.values(report.application_usage!), expect.any(Date) ); }); diff --git a/src/plugins/usage_collection/server/routes/stats/stats.ts b/src/plugins/usage_collection/server/routes/stats/stats.ts index 416a69dd9a8f9..6cae56afa281b 100644 --- a/src/plugins/usage_collection/server/routes/stats/stats.ts +++ b/src/plugins/usage_collection/server/routes/stats/stats.ts @@ -15,7 +15,6 @@ import { first } from 'rxjs/operators'; import { ElasticsearchClient, IRouter, - ISavedObjectsRepository, KibanaRequest, MetricsServiceSetup, SavedObjectsClientContract, @@ -30,6 +29,12 @@ const STATS_NOT_READY_MESSAGE = i18n.translate('usageCollection.stats.notReadyMe const SNAPSHOT_REGEX = /-snapshot/i; +interface UsageObject { + kibana?: UsageObject; + xpack?: UsageObject; + [key: string]: unknown | UsageObject; +} + export function registerStatsRoute({ router, config, @@ -55,9 +60,9 @@ export function registerStatsRoute({ }) { const getUsage = async ( esClient: ElasticsearchClient, - savedObjectsClient: SavedObjectsClientContract | ISavedObjectsRepository, + savedObjectsClient: SavedObjectsClientContract, kibanaRequest: KibanaRequest - ): Promise => { + ): Promise => { const usage = await collectorSet.bulkFetchUsage(esClient, savedObjectsClient, kibanaRequest); return collectorSet.toObject(usage); }; @@ -104,7 +109,7 @@ export function registerStatsRoute({ const usagePromise = shouldGetUsage ? getUsage(asCurrentUser, savedObjectsClient, req) - : Promise.resolve({}); + : Promise.resolve({}); const [usage, clusterUuid] = await Promise.all([ usagePromise, getClusterUuid(asCurrentUser), @@ -138,7 +143,7 @@ export function registerStatsRoute({ } return accum; - }, {} as any); + }, {} as UsageObject); extended = { usage: modifiedUsage, diff --git a/src/plugins/vis_default_editor/public/components/agg_add.tsx b/src/plugins/vis_default_editor/public/components/agg_add.tsx index 37ef08ec640f0..bbe235082e13e 100644 --- a/src/plugins/vis_default_editor/public/components/agg_add.tsx +++ b/src/plugins/vis_default_editor/public/components/agg_add.tsx @@ -52,7 +52,7 @@ function DefaultEditorAggAdd({ const addButton = ( setIsPopoverOpen(!isPopoverOpen)} @@ -88,7 +88,7 @@ function DefaultEditorAggAdd({ repositionOnScroll={true} closePopover={() => setIsPopoverOpen(false)} > - + {(groupName !== AggGroupNames.Buckets || !stats.count) && ( 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/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.scss b/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.scss new file mode 100644 index 0000000000000..bd27116735dbe --- /dev/null +++ b/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.scss @@ -0,0 +1,3 @@ +.aggBasedDialog__card { + background-color: $euiColorEmptyShade; +} diff --git a/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.tsx b/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.tsx index 56973cc05db2f..c0f55ce21cefd 100644 --- a/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.tsx +++ b/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.tsx @@ -28,6 +28,7 @@ import { memoizeLast } from '../../legacy/memoize'; import type { BaseVisType, TypesStart } from '../../vis_types'; import { VisGroups } from '../../vis_types'; import { DialogNavigation } from '../dialog_navigation'; +import './agg_based_selection.scss'; interface VisTypeListEntry { type: BaseVisType; @@ -137,6 +138,7 @@ class AggBasedSelection extends React.Component} + className="aggBasedDialog__card" /> ); diff --git a/src/plugins/visualize/public/application/components/visualize_listing.scss b/src/plugins/visualize/public/application/components/visualize_listing.scss index c3b0df67e317d..840ebf89c129f 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.scss +++ b/src/plugins/visualize/public/application/components/visualize_listing.scss @@ -21,7 +21,12 @@ } .visListingCallout { - max-width: 1000px; + @include kbnThemeStyle('v7') { + max-width: 1000px; + } + @include kbnThemeStyle('v8') { + max-width: 1200px; + } width: 100%; margin-left: auto; diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index 8fb3884a5b37b..9bf3045bd0138 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -240,6 +240,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'discoverApp', title: 'OneRecord', + hiddenType: false, editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { @@ -259,6 +260,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'dashboardApp', title: 'Dashboard', + hiddenType: false, editUrl: '/management/kibana/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', inAppUrl: { @@ -278,6 +280,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', + hiddenType: false, editUrl: '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { @@ -289,6 +292,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[1].meta).to.eql({ icon: 'visualizeApp', title: 'Visualization', + hiddenType: false, editUrl: '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { @@ -308,6 +312,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'indexPatternApp', title: 'saved_objects*', + hiddenType: false, editUrl: '/management/kibana/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts index fee525067719f..17e562d221d72 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -27,6 +27,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: schema.string(), }), namespaceType: schema.string(), + hiddenType: schema.boolean(), }), }); const invalidRelationSchema = schema.object({ @@ -89,6 +90,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'management.kibana.indexPatterns', }, namespaceType: 'single', + hiddenType: false, }, }, { @@ -105,6 +107,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, }, }, ]); @@ -132,6 +135,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'management.kibana.indexPatterns', }, namespaceType: 'single', + hiddenType: false, }, relationship: 'child', }, @@ -148,6 +152,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, }, relationship: 'parent', }, @@ -192,6 +197,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, }, }, { @@ -208,6 +214,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, }, }, ]); @@ -232,6 +239,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, }, relationship: 'child', }, @@ -248,6 +256,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, }, relationship: 'child', }, @@ -292,6 +301,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'discover.show', }, namespaceType: 'single', + hiddenType: false, }, }, { @@ -308,6 +318,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'dashboard.show', }, namespaceType: 'single', + hiddenType: false, }, }, ]); @@ -334,6 +345,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'discover.show', }, namespaceType: 'single', + hiddenType: false, }, relationship: 'child', }, @@ -378,6 +390,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'discover.show', }, namespaceType: 'single', + hiddenType: false, }, }, { @@ -394,6 +407,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, }, }, ]); @@ -420,6 +434,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'discover.show', }, namespaceType: 'single', + hiddenType: false, }, relationship: 'parent', }, @@ -466,6 +481,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, title: 'Visualization', }, relationship: 'child', diff --git a/test/common/config.js b/test/common/config.js index b44f2de5042eb..84848347f94cd 100644 --- a/test/common/config.js +++ b/test/common/config.js @@ -21,7 +21,7 @@ export default function () { servers, esTestCluster: { - serverArgs: ['xpack.security.enabled=false', 'geoip.downloader.enabled=false'], + serverArgs: ['xpack.security.enabled=false'], }, kbnTestServer: { diff --git a/test/common/services/security/user.ts b/test/common/services/security/user.ts index 3bd31bb5ed186..d6813105ecbf6 100644 --- a/test/common/services/security/user.ts +++ b/test/common/services/security/user.ts @@ -33,7 +33,7 @@ export class User { public async delete(username: string) { this.log.debug(`deleting user ${username}`); - const { data, status, statusText } = await await this.kbnClient.request({ + const { data, status, statusText } = await this.kbnClient.request({ path: `/internal/security/users/${username}`, method: 'DELETE', }); @@ -44,4 +44,32 @@ export class User { } this.log.debug(`deleted user ${username}`); } + + public async disable(username: string) { + this.log.debug(`disabling user ${username}`); + const { data, status, statusText } = await this.kbnClient.request({ + path: `/internal/security/users/${encodeURIComponent(username)}/_disable`, + method: 'POST', + }); + if (status !== 204) { + throw new Error( + `Expected status code of 204, received ${status} ${statusText}: ${util.inspect(data)}` + ); + } + this.log.debug(`disabled user ${username}`); + } + + public async enable(username: string) { + this.log.debug(`enabling user ${username}`); + const { data, status, statusText } = await this.kbnClient.request({ + path: `/internal/security/users/${encodeURIComponent(username)}/_enable`, + method: 'POST', + }); + if (status !== 204) { + throw new Error( + `Expected status code of 204, received ${status} ${statusText}: ${util.inspect(data)}` + ); + } + this.log.debug(`enabled user ${username}`); + } } diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/hidden_types/data.json b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_types/data.json new file mode 100644 index 0000000000000..057373579c100 --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_types/data.json @@ -0,0 +1,88 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-actions-export-hidden:obj_1", + "source": { + "test-actions-export-hidden": { + "title": "hidden object 1" + }, + "type": "test-actions-export-hidden", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-actions-export-hidden:obj_2", + "source": { + "test-actions-export-hidden": { + "title": "hidden object 2" + }, + "type": "test-actions-export-hidden", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "visualization:75c3e060-1e7c-11e9-8488-65449e65d0ed", + "source": { + "visualization": { + "title": "A Pie", + "visState": "{\"title\":\"A Pie\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"dimensions\":{\"metric\":{\"accessor\":0,\"format\":{\"id\":\"number\"},\"params\":{},\"aggType\":\"count\"}}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"geo.src\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\"}}]}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + } + }, + "type": "visualization", + "updated_at": "2019-01-22T19:32:31.206Z" + }, + "references" : [ + { + "name" : "kibanaSavedObjectMeta.searchSourceJSON.index", + "type" : "index-pattern", + "id" : "logstash-*" + } + ] + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "dashboard:i-exist", + "source": { + "dashboard": { + "title": "A Dashboard", + "hits": 0, + "description": "", + "panelsJSON": "[{\"gridData\":{\"w\":24,\"h\":15,\"x\":0,\"y\":0,\"i\":\"1\"},\"version\":\"7.0.0\",\"panelIndex\":\"1\",\"type\":\"visualization\",\"id\":\"75c3e060-1e7c-11e9-8488-65449e65d0ed\",\"embeddableConfig\":{}}]", + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "version": 1, + "timeRestore": false, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + } + }, + "type": "dashboard", + "updated_at": "2019-01-22T19:32:47.232Z" + } + } +} diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/hidden_types/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_types/mappings.json new file mode 100644 index 0000000000000..a862731c13f7a --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_types/mappings.json @@ -0,0 +1,504 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "dynamic": true, + "properties": { + "test-actions-export-hidden": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-transform": { + "properties": { + "title": { "type": "text" }, + "enabled": { "type": "boolean" } + } + }, + "test-export-add": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-add-dep": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-transform-error": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-invalid-transform": { + "properties": { + "title": { "type": "text" } + } + }, + "apm-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "go": { + "type": "long", + "null_value": 0 + }, + "java": { + "type": "long", + "null_value": 0 + }, + "js-base": { + "type": "long", + "null_value": 0 + }, + "nodejs": { + "type": "long", + "null_value": 0 + }, + "python": { + "type": "long", + "null_value": 0 + }, + "ruby": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "id": { + "type": "text", + "index": false + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "telemetry:optIn": { + "type": "boolean" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape", + "tree": "quadtree" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "spaceId": { + "type": "keyword" + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + } + } + } + } +} diff --git a/test/functional/page_objects/management/saved_objects_page.ts b/test/functional/page_objects/management/saved_objects_page.ts index c28d351aa77fb..fc4de6ed7f82f 100644 --- a/test/functional/page_objects/management/saved_objects_page.ts +++ b/test/functional/page_objects/management/saved_objects_page.ts @@ -294,10 +294,12 @@ export function SavedObjectsPageProvider({ getService, getPageObjects }: FtrProv return await testSubjects.isEnabled('savedObjectsManagementDelete'); } - async clickDelete() { + async clickDelete({ confirmDelete = true }: { confirmDelete?: boolean } = {}) { await testSubjects.click('savedObjectsManagementDelete'); - await testSubjects.click('confirmModalConfirmButton'); - await this.waitTableIsLoaded(); + if (confirmDelete) { + await testSubjects.click('confirmModalConfirmButton'); + await this.waitTableIsLoaded(); + } } async getImportWarnings() { diff --git a/test/plugin_functional/plugins/saved_object_export_transforms/server/plugin.ts b/test/plugin_functional/plugins/saved_object_export_transforms/server/plugin.ts index daaf6426bdddc..408ac03dd946b 100644 --- a/test/plugin_functional/plugins/saved_object_export_transforms/server/plugin.ts +++ b/test/plugin_functional/plugins/saved_object_export_transforms/server/plugin.ts @@ -90,8 +90,6 @@ export class SavedObjectExportTransformsPlugin implements Plugin { }, }); - ///////////// - ///////////// // example of a SO type that will throw an object-transform-error savedObjects.registerType({ name: 'test-export-transform-error', @@ -134,8 +132,29 @@ export class SavedObjectExportTransformsPlugin implements Plugin { }, }, }); + + // example of a SO type that is exportable while being hidden + savedObjects.registerType({ + name: 'test-actions-export-hidden', + hidden: true, + namespaceType: 'single', + mappings: { + properties: { + title: { type: 'text' }, + enabled: { + type: 'boolean', + }, + }, + }, + management: { + defaultSearchField: 'title', + importableAndExportable: true, + getTitle: (obj) => obj.attributes.title, + }, + }); } public start() {} + public stop() {} } diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts index 00ba74a988cf4..ba4835cdab089 100644 --- a/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts @@ -15,6 +15,5 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) { loadTestFile(require.resolve('./resolve_import_errors')); loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./delete')); - loadTestFile(require.resolve('./interface/saved_objects_management')); }); } diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts deleted file mode 100644 index dfd0b9dd07476..0000000000000 --- a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts +++ /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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import path from 'path'; -import expect from '@kbn/expect'; -import { PluginFunctionalProviderContext } from '../../../services'; - -export default function ({ getPageObjects, getService }: PluginFunctionalProviderContext) { - const PageObjects = getPageObjects(['common', 'settings', 'header', 'savedObjects']); - const esArchiver = getService('esArchiver'); - const fixturePaths = { - hiddenImportable: path.join(__dirname, 'exports', '_import_hidden_importable.ndjson'), - hiddenNonImportable: path.join(__dirname, 'exports', '_import_hidden_non_importable.ndjson'), - }; - - describe('Saved objects management Interface', () => { - before(() => esArchiver.emptyKibanaIndex()); - beforeEach(async () => { - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); - }); - describe('importable/exportable hidden type', () => { - it('imports objects successfully', async () => { - await PageObjects.savedObjects.importFile(fixturePaths.hiddenImportable); - await PageObjects.savedObjects.checkImportSucceeded(); - }); - - it('shows test-hidden-importable-exportable in table', async () => { - await PageObjects.savedObjects.searchForObject('type:(test-hidden-importable-exportable)'); - const results = await PageObjects.savedObjects.getTableSummary(); - expect(results.length).to.be(1); - - const { title } = results[0]; - expect(title).to.be( - 'test-hidden-importable-exportable [id=ff3733a0-9fty-11e7-ahb3-3dcb94193fab]' - ); - }); - }); - - describe('non-importable/exportable hidden type', () => { - it('fails to import object', async () => { - await PageObjects.savedObjects.importFile(fixturePaths.hiddenNonImportable); - await PageObjects.savedObjects.checkImportSucceeded(); - - const errorsCount = await PageObjects.savedObjects.getImportErrorsCount(); - expect(errorsCount).to.be(1); - }); - }); - }); -} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_importable.ndjson b/test/plugin_functional/test_suites/saved_objects_management/exports/_import_hidden_importable.ndjson similarity index 100% rename from test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_importable.ndjson rename to test/plugin_functional/test_suites/saved_objects_management/exports/_import_hidden_importable.ndjson diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_non_importable.ndjson b/test/plugin_functional/test_suites/saved_objects_management/exports/_import_hidden_non_importable.ndjson similarity index 100% rename from test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_non_importable.ndjson rename to test/plugin_functional/test_suites/saved_objects_management/exports/_import_hidden_non_importable.ndjson diff --git a/test/plugin_functional/test_suites/saved_objects_management/hidden_types.ts b/test/plugin_functional/test_suites/saved_objects_management/hidden_types.ts new file mode 100644 index 0000000000000..464b7c6e7ced7 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_management/hidden_types.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import path from 'path'; +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +const fixturePaths = { + hiddenImportable: path.join(__dirname, 'exports', '_import_hidden_importable.ndjson'), + hiddenNonImportable: path.join(__dirname, 'exports', '_import_hidden_non_importable.ndjson'), +}; + +export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common', 'settings', 'header', 'savedObjects']); + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + + describe('saved objects management with hidden types', () => { + before(async () => { + await esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_types' + ); + }); + + after(async () => { + await esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_types' + ); + }); + + beforeEach(async () => { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaSavedObjects(); + }); + + describe('API calls', () => { + it('should flag the object as hidden in its meta', async () => { + await supertest + .get('/api/kibana/management/saved_objects/_find?type=test-actions-export-hidden') + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + expect( + resp.body.saved_objects.map((obj: any) => ({ + id: obj.id, + type: obj.type, + hidden: obj.meta.hiddenType, + })) + ).to.eql([ + { + id: 'obj_1', + type: 'test-actions-export-hidden', + hidden: true, + }, + { + id: 'obj_2', + type: 'test-actions-export-hidden', + hidden: true, + }, + ]); + }); + }); + }); + + describe('Delete modal', () => { + it('should display a warning then trying to delete hidden saved objects', async () => { + await PageObjects.savedObjects.clickCheckboxByTitle('A Pie'); + await PageObjects.savedObjects.clickCheckboxByTitle('A Dashboard'); + await PageObjects.savedObjects.clickCheckboxByTitle('hidden object 1'); + + await PageObjects.savedObjects.clickDelete({ confirmDelete: false }); + expect(await testSubjects.exists('cannotDeleteObjectsConfirmWarning')).to.eql(true); + }); + + it('should not delete the hidden objects when performing the operation', async () => { + await PageObjects.savedObjects.clickCheckboxByTitle('A Pie'); + await PageObjects.savedObjects.clickCheckboxByTitle('hidden object 1'); + + await PageObjects.savedObjects.clickDelete({ confirmDelete: true }); + + const objectNames = (await PageObjects.savedObjects.getTableSummary()).map( + (obj) => obj.title + ); + expect(objectNames.includes('hidden object 1')).to.eql(true); + expect(objectNames.includes('A Pie')).to.eql(false); + }); + }); + + describe('importing hidden types', () => { + describe('importable/exportable hidden type', () => { + it('imports objects successfully', async () => { + await PageObjects.savedObjects.importFile(fixturePaths.hiddenImportable); + await PageObjects.savedObjects.checkImportSucceeded(); + }); + + it('shows test-hidden-importable-exportable in table', async () => { + await PageObjects.savedObjects.searchForObject( + 'type:(test-hidden-importable-exportable)' + ); + const results = await PageObjects.savedObjects.getTableSummary(); + expect(results.length).to.be(1); + + const { title } = results[0]; + expect(title).to.be( + 'test-hidden-importable-exportable [id=ff3733a0-9fty-11e7-ahb3-3dcb94193fab]' + ); + }); + }); + + describe('non-importable/exportable hidden type', () => { + it('fails to import object', async () => { + await PageObjects.savedObjects.importFile(fixturePaths.hiddenNonImportable); + await PageObjects.savedObjects.checkImportSucceeded(); + + const errorsCount = await PageObjects.savedObjects.getImportErrorsCount(); + expect(errorsCount).to.be(1); + }); + }); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_management/index.ts b/test/plugin_functional/test_suites/saved_objects_management/index.ts index 9f2d28b582f78..edaa819e5ea58 100644 --- a/test/plugin_functional/test_suites/saved_objects_management/index.ts +++ b/test/plugin_functional/test_suites/saved_objects_management/index.ts @@ -15,5 +15,6 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) { loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./export_transform')); loadTestFile(require.resolve('./import_warnings')); + loadTestFile(require.resolve('./hidden_types')); }); } diff --git a/test/scripts/jenkins_xpack_build_plugins.sh b/test/scripts/jenkins_xpack_build_plugins.sh index 496964983cc6c..cb0b5ec1d56da 100755 --- a/test/scripts/jenkins_xpack_build_plugins.sh +++ b/test/scripts/jenkins_xpack_build_plugins.sh @@ -13,6 +13,7 @@ node scripts/build_kibana_platform_plugins \ --scan-dir "$XPACK_DIR/test/plugin_api_perf/plugins" \ --scan-dir "$XPACK_DIR/test/licensing_plugin/plugins" \ --scan-dir "$XPACK_DIR/test/usage_collection/plugins" \ + --scan-dir "$XPACK_DIR/test/security_functional/fixtures/common" \ --scan-dir "$KIBANA_DIR/examples" \ --scan-dir "$XPACK_DIR/examples" \ --workers 12 \ 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/vars/workers.groovy b/vars/workers.groovy index 1260f74f1bdf9..83d439934cbfa 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -71,7 +71,7 @@ def base(Map params, Closure closure) { if (config.scm) { // Try to clone from Github up to 8 times, waiting 15 secs between attempts retryWithDelay(8, 15) { - checkout scm + kibanaCheckout() } dir("kibana") { diff --git a/x-pack/package.json b/x-pack/package.json index c09db67483121..129c8d86adecc 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -27,9 +27,7 @@ "yarn": "^1.21.1" }, "devDependencies": { - "@kbn/dev-utils": "link:../packages/kbn-dev-utils", "@kbn/es": "link:../packages/kbn-es", - "@kbn/expect": "link:../packages/kbn-expect", "@kbn/plugin-helpers": "link:../packages/kbn-plugin-helpers", "@kbn/storybook": "link:../packages/kbn-storybook", "@kbn/test": "link:../packages/kbn-test" diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 9b22e31c05e8a..30108a0777819 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -92,6 +92,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -123,6 +124,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -162,6 +164,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -199,6 +202,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -250,6 +254,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -274,6 +279,7 @@ describe('create()', () => { isPreconfigured: false, name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); @@ -283,6 +289,7 @@ describe('create()', () => { Object { "actionTypeId": "my-action-type", "config": Object {}, + "isMissingSecrets": false, "name": "my name", "secrets": Object {}, }, @@ -347,6 +354,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: { a: true, b: true, @@ -373,6 +381,7 @@ describe('create()', () => { isPreconfigured: false, name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: { a: true, b: true, @@ -390,6 +399,7 @@ describe('create()', () => { "b": true, "c": true, }, + "isMissingSecrets": false, "name": "my name", "secrets": Object {}, }, @@ -449,6 +459,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -482,6 +493,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -518,6 +530,7 @@ describe('get()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -566,6 +579,7 @@ describe('get()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -628,6 +642,7 @@ describe('get()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -653,6 +668,7 @@ describe('get()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -821,6 +837,7 @@ describe('getAll()', () => { type: 'type', attributes: { name: 'test', + isMissingSecrets: false, config: { foo: 'bar', }, @@ -881,6 +898,7 @@ describe('getAll()', () => { type: 'type', attributes: { name: 'test', + isMissingSecrets: false, config: { foo: 'bar', }, @@ -932,6 +950,7 @@ describe('getAll()', () => { config: { foo: 'bar', }, + isMissingSecrets: false, referencedByCount: 6, }, { @@ -959,6 +978,7 @@ describe('getBulk()', () => { config: { foo: 'bar', }, + isMissingSecrets: false, }, references: [], }, @@ -1030,6 +1050,7 @@ describe('getBulk()', () => { config: { foo: 'bar', }, + isMissingSecrets: false, }, references: [], }, @@ -1088,6 +1109,7 @@ describe('getBulk()', () => { config: { foo: 'bar', }, + isMissingSecrets: false, }, references: [], }, @@ -1143,6 +1165,7 @@ describe('getBulk()', () => { foo: 'bar', }, id: '1', + isMissingSecrets: false, isPreconfigured: false, name: 'test', }, @@ -1231,6 +1254,7 @@ describe('update()', () => { type: 'action', attributes: { actionTypeId: 'my-action-type', + isMissingSecrets: false, }, references: [], }); @@ -1239,6 +1263,7 @@ describe('update()', () => { type: 'action', attributes: { actionTypeId: 'my-action-type', + isMissingSecrets: false, name: 'my name', config: {}, secrets: {}, @@ -1319,6 +1344,7 @@ describe('update()', () => { type: 'action', attributes: { actionTypeId: 'my-action-type', + isMissingSecrets: false, }, references: [], }); @@ -1327,6 +1353,7 @@ describe('update()', () => { type: 'action', attributes: { actionTypeId: 'my-action-type', + isMissingSecrets: false, name: 'my name', config: {}, secrets: {}, @@ -1345,6 +1372,7 @@ describe('update()', () => { id: 'my-action', isPreconfigured: false, actionTypeId: 'my-action-type', + isMissingSecrets: false, name: 'my name', config: {}, }); @@ -1355,6 +1383,7 @@ describe('update()', () => { Object { "actionTypeId": "my-action-type", "config": Object {}, + "isMissingSecrets": false, "name": "my name", "secrets": Object {}, }, @@ -1374,6 +1403,70 @@ describe('update()', () => { `); }); + test('updates an action with isMissingSecrets "true" (set true as the import result), to isMissingSecrets', async () => { + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + executor, + }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'action', + attributes: { + actionTypeId: 'my-action-type', + isMissingSecrets: true, + }, + references: [], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: 'my-action', + type: 'action', + attributes: { + actionTypeId: 'my-action-type', + isMissingSecrets: true, + name: 'my name', + config: {}, + secrets: {}, + }, + references: [], + }); + const result = await actionsClient.update({ + id: 'my-action', + action: { + name: 'my name', + config: {}, + secrets: {}, + }, + }); + expect(result).toEqual({ + id: 'my-action', + isPreconfigured: false, + actionTypeId: 'my-action-type', + isMissingSecrets: true, + name: 'my name', + config: {}, + }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "action", + Object { + "actionTypeId": "my-action-type", + "config": Object {}, + "isMissingSecrets": false, + "name": "my name", + "secrets": Object {}, + }, + Object { + "id": "my-action", + "overwrite": true, + "references": Array [], + }, + ] + `); + }); + test('validates config', async () => { actionTypeRegistry.register({ id: 'my-action-type', @@ -1428,6 +1521,7 @@ describe('update()', () => { type: 'action', attributes: { actionTypeId: 'my-action-type', + isMissingSecrets: true, name: 'my name', config: { a: true, @@ -1454,6 +1548,7 @@ describe('update()', () => { id: 'my-action', isPreconfigured: false, actionTypeId: 'my-action-type', + isMissingSecrets: true, name: 'my name', config: { a: true, @@ -1472,6 +1567,7 @@ describe('update()', () => { "b": true, "c": true, }, + "isMissingSecrets": false, "name": "my name", "secrets": Object {}, }, @@ -1507,6 +1603,7 @@ describe('update()', () => { type: 'action', attributes: { actionTypeId: 'my-action-type', + isMissingSecrets: false, name: 'my name', config: {}, secrets: {}, diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index 9f87de5f686cc..c655141415b54 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -155,6 +155,7 @@ export class ActionsClient { { actionTypeId, name, + isMissingSecrets: false, config: validatedActionTypeConfig as SavedObjectAttributes, secrets: validatedActionTypeSecrets as SavedObjectAttributes, }, @@ -164,6 +165,7 @@ export class ActionsClient { return { id: result.id, actionTypeId: result.attributes.actionTypeId, + isMissingSecrets: result.attributes.isMissingSecrets, name: result.attributes.name, config: result.attributes.config, isPreconfigured: false, @@ -228,6 +230,7 @@ export class ActionsClient { ...attributes, actionTypeId, name, + isMissingSecrets: false, config: validatedActionTypeConfig as SavedObjectAttributes, secrets: validatedActionTypeSecrets as SavedObjectAttributes, }, @@ -245,6 +248,7 @@ export class ActionsClient { return { id, actionTypeId: result.attributes.actionTypeId as string, + isMissingSecrets: result.attributes.isMissingSecrets as boolean, name: result.attributes.name as string, config: result.attributes.config as Record, isPreconfigured: false, @@ -299,6 +303,7 @@ export class ActionsClient { return { id, actionTypeId: result.attributes.actionTypeId, + isMissingSecrets: result.attributes.isMissingSecrets, name: result.attributes.name, config: result.attributes.config, isPreconfigured: false, diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index 76f6a62ce6597..fbd9a8cddbdcb 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -21,6 +21,7 @@ const createActionsConfigMock = () => { maxContentLength: 1000000, timeout: 360000, }), + getCustomHostSettings: jest.fn().mockReturnValue(undefined), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 70c8b0e8185d5..925e77ca85fb2 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -13,8 +13,14 @@ import { AllowedHosts, EnabledActionTypes, } from './actions_config'; +import { resolveCustomHosts } from './lib/custom_host_settings'; +import { Logger } from '../../../../src/core/server'; +import { loggingSystemMock } from '../../../../src/core/server/mocks'; + import moment from 'moment'; +const mockLogger = loggingSystemMock.create().get() as jest.Mocked; + const defaultActionsConfig: ActionsConfig = { enabled: false, allowedHosts: [], @@ -355,4 +361,79 @@ describe('getProxySettings', () => { const proxySettings = getActionsConfigurationUtilities(config).getProxySettings(); expect(proxySettings?.proxyOnlyHosts).toEqual(new Set(proxyOnlyHosts)); }); + + test('getCustomHostSettings() returns undefined when no matching config', () => { + const httpsUrl = 'https://elastic.co/foo/bar'; + const smtpUrl = 'smtp://elastic.co'; + let config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + }); + + let chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + + config = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [], + }); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + + config = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://www.elastic.co:443', + }, + ], + }); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + }); + + test('getCustomHostSettings() returns matching config', () => { + const httpsUrl = 'https://elastic.co/ignoring/paths/here'; + const smtpUrl = 'smtp://elastic.co:123'; + const config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://elastic.co', + tls: { + rejectUnauthorized: true, + }, + }, + { + url: 'smtp://elastic.co:123', + tls: { + rejectUnauthorized: false, + }, + smtp: { + ignoreTLS: true, + }, + }, + ], + }); + + let chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(config.customHostSettings![0]); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(config.customHostSettings![1]); + }); + + test('getCustomHostSettings() returns undefined when bad url is passed in', () => { + const badUrl = 'https://elastic.co/foo/bar'; + const config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + }); + + const chs = getActionsConfigurationUtilities(config).getCustomHostSettings(badUrl); + expect(chs).toEqual(undefined); + }); }); diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index 4c73cab76f9e8..b8cd5878a8972 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -11,7 +11,8 @@ import url from 'url'; import { curry } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; -import { ActionsConfig, AllowedHosts, EnabledActionTypes } from './config'; +import { ActionsConfig, AllowedHosts, EnabledActionTypes, CustomHostSettings } from './config'; +import { getCanonicalCustomHostUrl } from './lib/custom_host_settings'; import { ActionTypeDisabledError } from './lib'; import { ProxySettings, ResponseSettings } from './types'; @@ -32,6 +33,7 @@ export interface ActionsConfigurationUtilities { isRejectUnauthorizedCertificatesEnabled: () => boolean; getProxySettings: () => undefined | ProxySettings; getResponseSettings: () => ResponseSettings; + getCustomHostSettings: (targetUrl: string) => CustomHostSettings | undefined; } function allowListErrorMessage(field: AllowListingField, value: string) { @@ -107,6 +109,27 @@ function getResponseSettingsFromConfig(config: ActionsConfig): ResponseSettings }; } +function getCustomHostSettings( + config: ActionsConfig, + targetUrl: string +): CustomHostSettings | undefined { + const customHostSettings = config.customHostSettings; + if (!customHostSettings) { + return; + } + + let parsedUrl: URL | undefined; + try { + parsedUrl = new URL(targetUrl); + } catch (err) { + // presumably this bad URL is reported elsewhere + return; + } + + const canonicalUrl = getCanonicalCustomHostUrl(parsedUrl); + return customHostSettings.find((settings) => settings.url === canonicalUrl); +} + export function getActionsConfigurationUtilities( config: ActionsConfig ): ActionsConfigurationUtilities { @@ -119,6 +142,7 @@ export function getActionsConfigurationUtilities( isActionTypeEnabled, getProxySettings: () => getProxySettingsFromConfig(config), getResponseSettings: () => getResponseSettingsFromConfig(config), + // returns the global rejectUnauthorized setting isRejectUnauthorizedCertificatesEnabled: () => config.rejectUnauthorized, ensureUriAllowed(uri: string) { if (!isUriAllowed(uri)) { @@ -135,5 +159,6 @@ export function getActionsConfigurationUtilities( throw new ActionTypeDisabledError(disabledActionTypeErrorMessage(actionType), 'config'); } }, + getCustomHostSettings: (targetUrl: string) => getCustomHostSettings(config, targetUrl), }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 4596619c50940..5747b4bbb28f4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -282,6 +282,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -342,6 +343,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts new file mode 100644 index 0000000000000..80bf51e19c379 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts @@ -0,0 +1,277 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { readFileSync as fsReadFileSync } from 'fs'; +import { resolve as pathResolve, join as pathJoin } from 'path'; +import http from 'http'; +import https from 'https'; +import axios from 'axios'; +import { duration as momentDuration } from 'moment'; +import { schema } from '@kbn/config-schema'; + +import { request } from './axios_utils'; +import { ByteSizeValue } from '@kbn/config-schema'; +import { Logger } from '../../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { createReadySignal } from '../../../../event_log/server/lib/ready_signal'; +import { ActionsConfig } from '../../config'; +import { + ActionsConfigurationUtilities, + getActionsConfigurationUtilities, +} from '../../actions_config'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +const CERT_DIR = '../../../../../../../packages/kbn-dev-utils/certs'; + +const KIBANA_CRT_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.crt')); +const KIBANA_KEY_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.key')); +const CA_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'ca.crt')); + +const KIBANA_KEY = fsReadFileSync(KIBANA_KEY_FILE, 'utf8'); +const KIBANA_CRT = fsReadFileSync(KIBANA_CRT_FILE, 'utf8'); +const CA = fsReadFileSync(CA_FILE, 'utf8'); + +describe('axios connections', () => { + let testServer: http.Server | https.Server; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let savedAxiosDefaultsAdapter: any; + + beforeAll(() => { + // needed to prevent the dreaded Error: Cross origin http://localhost forbidden + // see: https://github.com/axios/axios/issues/1754#issuecomment-572778305 + savedAxiosDefaultsAdapter = axios.defaults.adapter; + axios.defaults.adapter = require('axios/lib/adapters/http'); + }); + + afterAll(() => { + axios.defaults.adapter = savedAxiosDefaultsAdapter; + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + testServer.close(); + }); + + describe('http', () => { + test('it works', async () => { + const { url, server } = await createServer(); + testServer = server; + + const configurationUtilities = getACUfromConfig(); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + }); + + describe('https', () => { + test('it fails with self-signed cert and no overrides', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig(); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it works with rejectUnauthorized false config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + rejectUnauthorized: false, + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with rejectUnauthorized custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { rejectUnauthorized: false } }], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with ca in custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: CA } }], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it fails with incorrect ca in custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: KIBANA_CRT } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it works with incorrect ca in custom host config but rejectUnauthorized false', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [ + { + url, + tls: { + certificateAuthoritiesData: CA, + rejectUnauthorized: false, + }, + }, + ], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with incorrect ca in custom host config but rejectUnauthorized config true', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + rejectUnauthorized: false, + customHostSettings: [ + { + url, + tls: { + certificateAuthoritiesData: CA, + }, + }, + ], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it fails with no matching custom host settings', async () => { + const { url, server } = await createServer(true); + const otherUrl = 'https://example.com'; + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url: otherUrl, tls: { rejectUnauthorized: false } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it fails cleanly with a garbage CA 1', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: 'garbage' } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it fails cleanly with a garbage CA 2', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const ca = '-----BEGIN CERTIFICATE-----\ngarbage\n-----END CERTIFICATE-----\n'; + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: ca } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + }); +}); + +interface CreateServerResult { + url: string; + server: http.Server | https.Server; +} + +async function createServer(useHttps: boolean = false): Promise { + let server: http.Server | https.Server; + const readySignal = createReadySignal(); + + if (!useHttps) { + server = http.createServer((req, res) => { + res.writeHead(200); + res.end('http: just testing that a connection could be made'); + }); + } else { + const httpsOptions = { + cert: KIBANA_CRT, + key: KIBANA_KEY, + }; + server = https.createServer(httpsOptions, (req, res) => { + res.writeHead(200); + res.end('https: just testing that a connection could be made'); + }); + } + + server.listen(() => { + const addressInfo = server.address(); + if (addressInfo == null || typeof addressInfo === 'string') { + server.close(); + throw new Error('error getting address of server, closing'); + } + + const url = localUrlFromPort(useHttps, addressInfo.port, 'localhost'); + readySignal.signal({ server, url }); + }); + + // let the node process stop if for some reason this server isn't closed + server.unref(); + + return readySignal.wait(); +} + +const BaseActionsConfig: ActionsConfig = { + enabled: true, + allowedHosts: ['*'], + enabledActionTypes: ['*'], + preconfiguredAlertHistoryEsIndex: false, + preconfigured: {}, + proxyUrl: undefined, + proxyHeaders: undefined, + proxyRejectUnauthorizedCertificates: true, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + rejectUnauthorized: true, + maxResponseContentLength: ByteSizeValue.parse('1mb'), + responseTimeout: momentDuration(1000 * 30), + customHostSettings: undefined, + cleanupFailedExecutionsTask: { + enabled: true, + cleanupInterval: schema.duration().validate('5m'), + idleInterval: schema.duration().validate('1h'), + pageSize: 100, + }, +}; + +function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { + return getActionsConfigurationUtilities({ + ...BaseActionsConfig, + ...config, + }); +} + +function localUrlFromPort(useHttps: boolean, port: number, host: string): string { + return `${useHttps ? 'https' : 'http'}://${host}:${port}`; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts index f6d1be9bffc6b..805c22806ce4c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts @@ -16,11 +16,16 @@ const logger = loggingSystemMock.create().get() as jest.Mocked; const targetHost = 'elastic.co'; const targetUrl = `https://${targetHost}/foo/bar/baz`; +const targetUrlCanonical = `https://${targetHost}:443`; const nonMatchingUrl = `https://${targetHost}m/foo/bar/baz`; describe('getCustomAgents', () => { const configurationUtilities = actionsConfigMock.create(); + beforeEach(() => { + jest.resetAllMocks(); + }); + test('get agents for valid proxy URL', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', @@ -106,4 +111,117 @@ describe('getCustomAgents', () => { expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); }); + + test('handles custom host settings', () => { + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + certificateAuthoritiesData: 'ca data here', + }, + }); + const { httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpsAgent?.options.ca).toBe('ca data here'); + expect(httpsAgent?.options.rejectUnauthorized).toBe(false); + }); + + test('handles custom host settings with proxy', () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + certificateAuthoritiesData: 'ca data here', + }, + }); + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + + expect(httpsAgent?.options.ca).toBe('ca data here'); + expect(httpsAgent?.options.rejectUnauthorized).toBe(false); + }); + + test('handles overriding global rejectUnauthorized false', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(false); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: true, + }, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); + expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeTruthy(); + }); + + test('handles overriding global rejectUnauthorized true', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(true); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + }, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); + expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeFalsy(); + }); + + test('handles overriding global rejectUnauthorized false with a proxy', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(false); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: true, + }, + }); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + // note: this setting doesn't come into play, it's for the connection to + // the proxy, not the target url + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeTruthy(); + }); + + test('handles overriding global rejectUnauthorized true with a proxy', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(true); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + }, + }); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + // note: this setting doesn't come into play, it's for the connection to + // the proxy, not the target url + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeFalsy(); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts index ff2d005f4d841..6ec926004e73e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts @@ -6,7 +6,7 @@ */ import { Agent as HttpAgent } from 'http'; -import { Agent as HttpsAgent } from 'https'; +import { Agent as HttpsAgent, AgentOptions } from 'https'; import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; @@ -22,7 +22,8 @@ export function getCustomAgents( logger: Logger, url: string ): GetCustomAgentsResponse { - const proxySettings = configurationUtilities.getProxySettings(); + // the default for rejectUnauthorized is the global setting, which can + // be overridden (below) with a custom host setting const defaultAgents = { httpAgent: undefined, httpsAgent: new HttpsAgent({ @@ -30,10 +31,39 @@ export function getCustomAgents( }), }; + // Get the current proxy settings, and custom host settings for this URL. + // If there are neither of these, return the default agents + const proxySettings = configurationUtilities.getProxySettings(); + const customHostSettings = configurationUtilities.getCustomHostSettings(url); + if (!proxySettings && !customHostSettings) { + return defaultAgents; + } + + // update the defaultAgents.httpsAgent if configured + const tlsSettings = customHostSettings?.tls; + let agentOptions: AgentOptions | undefined; + if (tlsSettings) { + logger.debug(`Creating customized connection settings for: ${url}`); + agentOptions = defaultAgents.httpsAgent.options; + + if (tlsSettings.certificateAuthoritiesData) { + agentOptions.ca = tlsSettings.certificateAuthoritiesData; + } + + // see: src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts + // This is where the global rejectUnauthorized is overridden by a custom host + if (tlsSettings.rejectUnauthorized !== undefined) { + agentOptions.rejectUnauthorized = tlsSettings.rejectUnauthorized; + } + } + + // if there weren't any proxy settings, return the currently calculated agents if (!proxySettings) { return defaultAgents; } + // there is a proxy in use, but it's possible we won't use it via custom host + // proxyOnlyHosts and proxyBypassHosts let targetUrl: URL; try { targetUrl = new URL(url); @@ -56,6 +86,7 @@ export function getCustomAgents( return defaultAgents; } } + logger.debug(`Creating proxy agents for proxy: ${proxySettings.proxyUrl}`); let proxyUrl: URL; try { @@ -65,6 +96,9 @@ export function getCustomAgents( return defaultAgents; } + // At this point, we are going to use a proxy, so we need new agents. + // We will though, copy over the calculated tls options from above, into + // the https agent. const httpAgent = new HttpProxyAgent(proxySettings.proxyUrl); const httpsAgent = (new HttpsProxyAgent({ host: proxyUrl.hostname, @@ -76,5 +110,12 @@ export function getCustomAgents( }) as unknown) as HttpsAgent; // vsCode wasn't convinced HttpsProxyAgent is an https.Agent, so we convinced it + if (agentOptions) { + httpsAgent.options = { + ...httpsAgent.options, + ...agentOptions, + }; + } + return { httpAgent, httpsAgent }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index 4b45c6d787cd6..cceeefde71dc2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -15,6 +15,7 @@ import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import nodemailer from 'nodemailer'; import { ProxySettings } from '../../types'; import { actionsConfigMock } from '../../actions_config.mock'; +import { CustomHostSettings } from '../../config'; const createTransportMock = nodemailer.createTransport as jest.Mock; const sendMailMockResult = { result: 'does not matter' }; @@ -356,16 +357,151 @@ describe('send_email module', () => { ] `); }); + + test('it handles custom host settings from config', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + undefined, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + }, + smtp: { + ignoreTLS: false, + requireTLS: true, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + + // note in the object below, the rejectUnauthenticated got set to false, + // given the implementation allowing that for no auth and !secure. + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "host": "example.com", + "port": 1025, + "requireTLS": true, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": false, + }, + }, + ] + `); + }); + + test('it allows custom host settings to override calculated values', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + undefined, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + rejectUnauthorized: true, + }, + smtp: { + ignoreTLS: true, + requireTLS: false, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + + // in this case, rejectUnauthorized is true, as the custom host settings + // overrode the calculated value of false + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "host": "example.com", + "ignoreTLS": true, + "port": 1025, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": true, + }, + }, + ] + `); + }); + + test('it handles custom host settings with a proxy', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + { + proxyUrl: 'https://proxy.com', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + rejectUnauthorized: true, + }, + smtp: { + requireTLS: true, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "headers": undefined, + "host": "example.com", + "port": 1025, + "proxy": "https://proxy.com", + "requireTLS": true, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": true, + }, + }, + ] + `); + }); }); function getSendEmailOptions( { content = {}, routing = {}, transport = {} } = {}, - proxySettings?: ProxySettings + proxySettings?: ProxySettings, + customHostSettings?: CustomHostSettings ) { const configurationUtilities = actionsConfigMock.create(); if (proxySettings) { configurationUtilities.getProxySettings.mockReturnValue(proxySettings); } + if (customHostSettings) { + configurationUtilities.getCustomHostSettings.mockReturnValue(customHostSettings); + } return { content: { ...content, @@ -392,12 +528,16 @@ function getSendEmailOptions( function getSendEmailOptionsNoAuth( { content = {}, routing = {}, transport = {} } = {}, - proxySettings?: ProxySettings + proxySettings?: ProxySettings, + customHostSettings?: CustomHostSettings ) { const configurationUtilities = actionsConfigMock.create(); if (proxySettings) { configurationUtilities.getProxySettings.mockReturnValue(proxySettings); } + if (customHostSettings) { + configurationUtilities.getCustomHostSettings.mockReturnValue(customHostSettings); + } return { content: { ...content, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index c0a254967b4fe..005e73b1fc2f7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -11,6 +11,7 @@ import { default as MarkdownIt } from 'markdown-it'; import { Logger } from '../../../../../../src/core/server'; import { ActionsConfigurationUtilities } from '../../actions_config'; +import { CustomHostSettings } from '../../config'; // an email "service" which doesn't actually send, just returns what it would send export const JSON_TRANSPORT_SERVICE = '__json'; @@ -52,7 +53,10 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom const { from, to, cc, bcc } = routing; const { subject, message } = content; - const transportConfig: Record = {}; + // The transport options do not seem to be exposed as a type, and we reference + // some deep properties, so need to use any here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const transportConfig: Record = {}; const proxySettings = configurationUtilities.getProxySettings(); const rejectUnauthorized = configurationUtilities.isRejectUnauthorizedCertificatesEnabled(); @@ -73,6 +77,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom useProxy = false; } } + let customHostSettings: CustomHostSettings | undefined; if (service === JSON_TRANSPORT_SERVICE) { transportConfig.jsonTransport = true; @@ -83,6 +88,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom transportConfig.host = host; transportConfig.port = port; transportConfig.secure = !!secure; + customHostSettings = configurationUtilities.getCustomHostSettings(`smtp://${host}:${port}`); if (proxySettings && useProxy) { transportConfig.tls = { @@ -99,6 +105,33 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom } else { transportConfig.tls = { rejectUnauthorized }; } + + // finally, allow customHostSettings to override some of the settings + // see: https://nodemailer.com/smtp/ + if (customHostSettings) { + const tlsConfig: Record = {}; + const tlsSettings = customHostSettings.tls; + const smtpSettings = customHostSettings.smtp; + + if (tlsSettings?.certificateAuthoritiesData) { + tlsConfig.ca = tlsSettings?.certificateAuthoritiesData; + } + if (tlsSettings?.rejectUnauthorized !== undefined) { + tlsConfig.rejectUnauthorized = tlsSettings?.rejectUnauthorized; + } + + if (!transportConfig.tls) { + transportConfig.tls = tlsConfig; + } else { + transportConfig.tls = { ...transportConfig.tls, ...tlsConfig }; + } + + if (smtpSettings?.ignoreTLS) { + transportConfig.ignoreTLS = true; + } else if (smtpSettings?.requireTLS) { + transportConfig.requireTLS = true; + } + } } const nodemailerTransport = nodemailer.createTransport(transportConfig); diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts index 8a185d353de02..95088fa5f7965 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts @@ -167,6 +167,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -230,6 +231,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index d3f059eede615..00e56303dbe22 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -290,6 +290,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -382,6 +383,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index 092b5d2cce587..4c4fd143369e1 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -164,6 +164,19 @@ describe('config validation', () => { ] `); }); + + // Most of the customHostSettings tests are in ./lib/custom_host_settings.test.ts + // but this one seemed more relevant for this test suite, since url is the one + // required property. + test('validates customHostSettings contains a URL', () => { + const config: Record = { + customHostSettings: [{}], + }; + + expect(() => configSchema.validate(config)).toThrowErrorMatchingInlineSnapshot( + `"[customHostSettings.0.url]: expected value of type [string] but got [undefined]"` + ); + }); }); // object creator that ensures we can create a property named __proto__ on an diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index 7225c54d57596..0dc1aed68f4d0 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -23,6 +23,30 @@ const preconfiguredActionSchema = schema.object({ secrets: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), }); +const customHostSettingsSchema = schema.object({ + url: schema.string({ minLength: 1 }), + smtp: schema.maybe( + schema.object({ + ignoreTLS: schema.maybe(schema.boolean()), + requireTLS: schema.maybe(schema.boolean()), + }) + ), + tls: schema.maybe( + schema.object({ + rejectUnauthorized: schema.maybe(schema.boolean()), + certificateAuthoritiesFiles: schema.maybe( + schema.oneOf([ + schema.string({ minLength: 1 }), + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + ]) + ), + certificateAuthoritiesData: schema.maybe(schema.string({ minLength: 1 })), + }) + ), +}); + +export type CustomHostSettings = TypeOf; + export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), allowedHosts: schema.arrayOf( @@ -50,6 +74,7 @@ export const configSchema = schema.object({ rejectUnauthorized: schema.boolean({ defaultValue: true }), maxResponseContentLength: schema.byteSize({ defaultValue: '1mb' }), responseTimeout: schema.duration({ defaultValue: '60s' }), + customHostSettings: schema.maybe(schema.arrayOf(customHostSettingsSchema)), cleanupFailedExecutionsTask: schema.object({ enabled: schema.boolean({ defaultValue: true }), cleanupInterval: schema.duration({ defaultValue: '5m' }), diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts new file mode 100644 index 0000000000000..ad07ea21d7917 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts @@ -0,0 +1,504 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { readFileSync as fsReadFileSync } from 'fs'; +import { resolve as pathResolve, join as pathJoin } from 'path'; +import { schema, ByteSizeValue } from '@kbn/config-schema'; +import moment from 'moment'; + +import { ActionsConfig } from '../config'; +import { Logger } from '../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; + +import { resolveCustomHosts, getCanonicalCustomHostUrl } from './custom_host_settings'; + +const CA_DIR = '../../../../../../packages/kbn-dev-utils/certs'; +const CA_FILE1 = pathResolve(__filename, pathJoin(CA_DIR, 'ca.crt')); +const CA_CONTENTS1 = fsReadFileSync(CA_FILE1, 'utf8'); +const CA_FILE2 = pathResolve(__filename, pathJoin(CA_DIR, 'kibana.crt')); +const CA_CONTENTS2 = fsReadFileSync(CA_FILE2, 'utf8'); + +let mockLogger: Logger = loggingSystemMock.create().get(); + +function warningLogs() { + const calls = loggingSystemMock.collect(mockLogger).warn; + return calls.map((call) => `${call[0]}`); +} + +describe('custom_host_settings', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockLogger = loggingSystemMock.create().get(); + }); + + describe('getCanonicalCustomHostUrl()', () => { + test('minimal urls', () => { + expect(getCanonicalCustomHostUrl(new URL('http://elastic.com'))).toBe( + 'http://elastic.com:80' + ); + expect(getCanonicalCustomHostUrl(new URL('https://elastic.co'))).toBe( + 'https://elastic.co:443' + ); + expect(getCanonicalCustomHostUrl(new URL('smtp://mail.elastic.co'))).toBe( + 'smtp://mail.elastic.co:25' + ); + expect(warningLogs()).toEqual([]); + }); + + test('maximal urls', () => { + expect( + getCanonicalCustomHostUrl(new URL('http://user1:pass1@elastic.co:81/foo?bar#car')) + ).toBe('http://elastic.co:81'); + expect( + getCanonicalCustomHostUrl(new URL('https://user1:pass1@elastic.co:82/foo?bar#car')) + ).toBe('https://elastic.co:82'); + expect( + getCanonicalCustomHostUrl(new URL('smtp://user1:pass1@mail.elastic.co:83/foo?bar#car')) + ).toBe('smtp://mail.elastic.co:83'); + expect(warningLogs()).toEqual([]); + }); + }); + + describe('resolveCustomHosts()', () => { + const defaultActionsConfig: ActionsConfig = { + enabled: true, + allowedHosts: [], + enabledActionTypes: [], + preconfiguredAlertHistoryEsIndex: false, + preconfigured: {}, + proxyRejectUnauthorizedCertificates: true, + rejectUnauthorized: true, + maxResponseContentLength: new ByteSizeValue(1000000), + responseTimeout: moment.duration(60000), + cleanupFailedExecutionsTask: { + enabled: true, + cleanupInterval: schema.duration().validate('5m'), + idleInterval: schema.duration().validate('1h'), + pageSize: 100, + }, + }; + + test('ensure it copies over the config parts that it does not touch', () => { + const config: ActionsConfig = { ...defaultActionsConfig }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles undefined customHostSettings', () => { + const config: ActionsConfig = { ...defaultActionsConfig, customHostSettings: undefined }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles empty object customHostSettings', () => { + const config: ActionsConfig = { ...defaultActionsConfig, customHostSettings: [] }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles multiple valid settings', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://elastic.co:443', + tls: { + certificateAuthoritiesData: 'xyz', + rejectUnauthorized: false, + }, + }, + { + url: 'smtp://mail.elastic.com:25', + tls: { + certificateAuthoritiesData: 'abc', + rejectUnauthorized: true, + }, + smtp: { + ignoreTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles bad url', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'this! is! not! a! url!', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { ...config, customHostSettings: [] }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, invalid URL \\"this! is! not! a! url!\\", ignoring; error: Invalid URL: this! is! not! a! url!", + ] + `); + }); + + test('handles bad port', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:0', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { ...config, customHostSettings: [] }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, unable to determine port for URL \\"https://almost.purrfect.com:0\\", ignoring", + ] + `); + }); + + test('handles auth info', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://kitty:cat@almost.purrfect.com', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://kitty:cat@almost.purrfect.com\\" contains authentication information which will be ignored, but should be removed from the configuration", + ] + `); + }); + + test('handles hash', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com#important', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com#important\\" contains hash information which will be ignored", + ] + `); + }); + + test('handles path', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/about', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com/about\\" contains path information which will be ignored", + ] + `); + }); + + test('handles / path same as no path, since we have no choice', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles unsupported URL protocols', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'http://almost.purrfect.com/', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, unsupported protocol used in URL \\"http://almost.purrfect.com/\\", ignoring", + ] + `); + }); + + test('handles smtp options for non-smtp urls', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + smtp: { + ignoreTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com/\\" contains smtp properties but does not use smtp; ignoring smtp properties", + ] + `); + }); + + test('handles ca files not found', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: 'this-file-does-not-exist', + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + tls: { + certificateAuthoritiesFiles: 'this-file-does-not-exist', + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "error reading file \\"this-file-does-not-exist\\" specified in xpack.actions.customHosts, ignoring: ENOENT: no such file or directory, open 'this-file-does-not-exist'", + ] + `); + }); + + test('handles a single ca file', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: CA_FILE1, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe(CA_CONTENTS1); + expect(warningLogs()).toEqual([]); + }); + + test('handles multiple ca files', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: [CA_FILE1, CA_FILE2], + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe( + `${CA_CONTENTS1}\n${CA_CONTENTS2}` + ); + expect(warningLogs()).toEqual([]); + }); + + test('handles ca files and ca data', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: [CA_FILE2], + certificateAuthoritiesData: CA_CONTENTS1, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe( + `${CA_CONTENTS1}\n${CA_CONTENTS2}` + ); + expect(warningLogs()).toEqual([]); + }); + + test('handles smtp ignoreTLS and requireTLS both used', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'smtp://almost.purrfect.com/', + smtp: { + ignoreTLS: true, + requireTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'smtp://almost.purrfect.com:25', + smtp: { + ignoreTLS: false, + requireTLS: true, + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"smtp://almost.purrfect.com/\\" cannot have both requireTLS and ignoreTLS set to true; using requireTLS: true and ignoreTLS: false", + ] + `); + }); + + test('handles duplicate URLs', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + rejectUnauthorized: true, + }, + }, + { + url: 'https://almost.purrfect.com:443', + tls: { + rejectUnauthorized: false, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + tls: { + rejectUnauthorized: true, + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, multiple URLs match the canonical url \\"https://almost.purrfect.com:443\\"; only the first will be used", + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.ts new file mode 100644 index 0000000000000..bfc8dad48aab6 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.ts @@ -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 { readFileSync } from 'fs'; +import { cloneDeep } from 'lodash'; +import { Logger } from '../../../../../src/core/server'; +import { ActionsConfig, CustomHostSettings } from '../config'; + +type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; + +type ActionsConfigWriteable = DeepWriteable; +type CustomHostSettingsWriteable = DeepWriteable; + +export function getCanonicalCustomHostUrl(url: URL): string { + const port = getActualPort(url.protocol, url.port); + + return `${url.protocol}//${url.hostname}:${port}`; +} + +const ErrorPrefix = 'In configuration xpack.actions.customHosts,'; +const ValidProtocols = new Set(['https:', 'smtp:']); +const ProtocolsForSmtp = new Set(['smtp:']); + +// converts the custom host data in config, for ease of use, and to perform +// validation we can't do in config-schema, since the cloud validation can't +// do these sorts of validations +export function resolveCustomHosts(logger: Logger, config: ActionsConfig): ActionsConfig { + const result: ActionsConfigWriteable = cloneDeep(config); + + if (!result.customHostSettings) { + return result as ActionsConfig; + } + + const savedSettings: CustomHostSettingsWriteable[] = []; + + for (const customHostSetting of result.customHostSettings) { + const originalUrl = customHostSetting.url; + let parsedUrl: URL | undefined; + try { + parsedUrl = new URL(originalUrl); + } catch (err) { + logger.warn(`${ErrorPrefix} invalid URL "${originalUrl}", ignoring; error: ${err.message}`); + continue; + } + + customHostSetting.url = getCanonicalCustomHostUrl(parsedUrl); + + if (!ValidProtocols.has(parsedUrl.protocol)) { + logger.warn(`${ErrorPrefix} unsupported protocol used in URL "${originalUrl}", ignoring`); + continue; + } + + const port = getActualPort(parsedUrl.protocol, parsedUrl.port); + if (!port) { + logger.warn(`${ErrorPrefix} unable to determine port for URL "${originalUrl}", ignoring`); + continue; + } + + if (parsedUrl.username || parsedUrl.password) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains authentication information which will be ignored, but should be removed from the configuration` + ); + } + + if (parsedUrl.hash) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains hash information which will be ignored` + ); + } + + if (parsedUrl.pathname && parsedUrl.pathname !== '/') { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains path information which will be ignored` + ); + } + + if (!ProtocolsForSmtp.has(parsedUrl.protocol) && customHostSetting.smtp) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains smtp properties but does not use smtp; ignoring smtp properties` + ); + delete customHostSetting.smtp; + } + + // read the specified ca files, add their content to certificateAuthoritiesData + if (customHostSetting.tls) { + let files = customHostSetting.tls?.certificateAuthoritiesFiles || []; + if (typeof files === 'string') { + files = [files]; + } + for (const file of files) { + const contents = getFileContents(logger, file); + if (contents) { + appendToCertificateAuthoritiesData(customHostSetting, contents); + } + } + } + + const customSmtpSettings = customHostSetting.smtp; + if (customSmtpSettings) { + if (customSmtpSettings.requireTLS && customSmtpSettings.ignoreTLS) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" cannot have both requireTLS and ignoreTLS set to true; using requireTLS: true and ignoreTLS: false` + ); + customSmtpSettings.requireTLS = true; + customSmtpSettings.ignoreTLS = false; + } + } + + savedSettings.push(customHostSetting); + } + + // check to see if there are any dups on the url + const existingUrls = new Set(); + for (const customHostSetting of savedSettings) { + const url = customHostSetting.url; + if (existingUrls.has(url)) { + logger.warn( + `${ErrorPrefix} multiple URLs match the canonical url "${url}"; only the first will be used` + ); + // mark this entry to be able to delete it after processing them all + customHostSetting.url = ''; + } + existingUrls.add(url); + } + + // remove the settings we want to skip + result.customHostSettings = savedSettings.filter((setting) => setting.url !== ''); + + return result as ActionsConfig; +} + +function appendToCertificateAuthoritiesData(customHost: CustomHostSettingsWriteable, cert: string) { + const tls = customHost.tls; + if (tls) { + if (!tls.certificateAuthoritiesData) { + tls.certificateAuthoritiesData = cert; + } else { + tls.certificateAuthoritiesData += '\n' + cert; + } + } +} + +function getFileContents(logger: Logger, fileName: string): string | undefined { + try { + return readFileSync(fileName, 'utf8'); + } catch (err) { + logger.warn( + `error reading file "${fileName}" specified in xpack.actions.customHosts, ignoring: ${err.message}` + ); + return; + } +} + +// 0 isn't a valid port, so result can be checked as falsy +function getActualPort(protocol: string, port: string): number { + if (port !== '') { + const portNumber = parseInt(port, 10); + if (isNaN(portNumber)) { + return 0; + } + return portNumber; + } + + // from https://nodejs.org/dist/latest-v14.x/docs/api/url.html#url_url_port + if (protocol === 'http:') return 80; + if (protocol === 'https:') return 443; + if (protocol === 'smtp:') return 25; + return 0; +} diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 106e41259e692..2036ed6c7d343 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -35,6 +35,7 @@ import { } from './cleanup_failed_executions'; import { ActionsConfig, getValidatedConfig } from './config'; +import { resolveCustomHosts } from './lib/custom_host_settings'; import { ActionsClient } from './actions_client'; import { ActionTypeRegistry } from './action_type_registry'; import { createExecutionEnqueuerFunction } from './create_execute_function'; @@ -157,7 +158,10 @@ export class ActionsPlugin implements Plugin()); + this.actionsConfig = getValidatedConfig( + this.logger, + resolveCustomHosts(this.logger, initContext.config.get()) + ); this.telemetryLogger = initContext.logger.get('usage'); this.preconfiguredActions = []; this.kibanaIndexConfig = initContext.config.legacy.get(); diff --git a/x-pack/plugins/actions/server/routes/create.test.ts b/x-pack/plugins/actions/server/routes/create.test.ts index e5d8e6f5861f3..51a55309b52ae 100644 --- a/x-pack/plugins/actions/server/routes/create.test.ts +++ b/x-pack/plugins/actions/server/routes/create.test.ts @@ -39,12 +39,14 @@ describe('createActionRoute', () => { actionTypeId: 'abc', config: { foo: true }, isPreconfigured: false, + isMissingSecrets: false, }; const createApiResult = { - ...omit(createResult, ['actionTypeId', 'isPreconfigured']), + ...omit(createResult, ['actionTypeId', 'isPreconfigured', 'isMissingSecrets']), connector_type_id: createResult.actionTypeId, is_preconfigured: createResult.isPreconfigured, + is_missing_secrets: createResult.isMissingSecrets, }; const actionsClient = actionsClientMock.create(); @@ -99,6 +101,7 @@ describe('createActionRoute', () => { id: '1', name: 'My name', actionTypeId: 'abc', + isMissingSecrets: false, config: { foo: true }, isPreconfigured: false, }); @@ -138,6 +141,7 @@ describe('createActionRoute', () => { name: 'My name', actionTypeId: 'abc', config: { foo: true }, + isMissingSecrets: false, isPreconfigured: false, }); diff --git a/x-pack/plugins/actions/server/routes/create.ts b/x-pack/plugins/actions/server/routes/create.ts index c05f2180bd62b..0c243b6a4eaa9 100644 --- a/x-pack/plugins/actions/server/routes/create.ts +++ b/x-pack/plugins/actions/server/routes/create.ts @@ -29,11 +29,13 @@ const rewriteBodyReq: RewriteRequestCase = ({ const rewriteBodyRes: RewriteResponseCase = ({ actionTypeId, isPreconfigured, + isMissingSecrets, ...res }) => ({ ...res, connector_type_id: actionTypeId, is_preconfigured: isPreconfigured, + is_missing_secrets: isMissingSecrets, }); export const createActionRoute = ( diff --git a/x-pack/plugins/actions/server/routes/get.test.ts b/x-pack/plugins/actions/server/routes/get.test.ts index 6a42f3b27370e..1107ec243bc01 100644 --- a/x-pack/plugins/actions/server/routes/get.test.ts +++ b/x-pack/plugins/actions/server/routes/get.test.ts @@ -38,6 +38,7 @@ describe('getActionRoute', () => { name: 'action name', config: {}, isPreconfigured: false, + isMissingSecrets: false, }; const actionsClient = actionsClientMock.create(); @@ -57,6 +58,7 @@ describe('getActionRoute', () => { "config": Object {}, "connector_type_id": "2", "id": "1", + "is_missing_secrets": false, "is_preconfigured": false, "name": "action name", }, @@ -73,6 +75,7 @@ describe('getActionRoute', () => { name: 'action name', config: {}, is_preconfigured: false, + is_missing_secrets: false, }, }); }); diff --git a/x-pack/plugins/actions/server/routes/get.ts b/x-pack/plugins/actions/server/routes/get.ts index 59766fc133ba6..3f4a67c3bfbcd 100644 --- a/x-pack/plugins/actions/server/routes/get.ts +++ b/x-pack/plugins/actions/server/routes/get.ts @@ -19,11 +19,13 @@ const paramSchema = schema.object({ const rewriteBodyRes: RewriteResponseCase = ({ actionTypeId, isPreconfigured, + isMissingSecrets, ...res }) => ({ ...res, connector_type_id: actionTypeId, is_preconfigured: isPreconfigured, + is_missing_secrets: isMissingSecrets, }); export const getActionRoute = ( diff --git a/x-pack/plugins/actions/server/routes/get_all.ts b/x-pack/plugins/actions/server/routes/get_all.ts index 831722fd36eed..2d3a2727e9663 100644 --- a/x-pack/plugins/actions/server/routes/get_all.ts +++ b/x-pack/plugins/actions/server/routes/get_all.ts @@ -12,12 +12,15 @@ import { ActionsRequestHandlerContext, FindActionResult } from '../types'; import { verifyAccessAndContext } from './verify_access_and_context'; const rewriteBodyRes: RewriteResponseCase = (results) => { - return results.map(({ actionTypeId, isPreconfigured, referencedByCount, ...res }) => ({ - ...res, - connector_type_id: actionTypeId, - is_preconfigured: isPreconfigured, - referenced_by_count: referencedByCount, - })); + return results.map( + ({ actionTypeId, isPreconfigured, referencedByCount, isMissingSecrets, ...res }) => ({ + ...res, + connector_type_id: actionTypeId, + is_preconfigured: isPreconfigured, + referenced_by_count: referencedByCount, + is_missing_secrets: isMissingSecrets, + }) + ); }; export const getAllActionRoute = ( diff --git a/x-pack/plugins/actions/server/routes/update.ts b/x-pack/plugins/actions/server/routes/update.ts index d1758717e80f9..276ce80751726 100644 --- a/x-pack/plugins/actions/server/routes/update.ts +++ b/x-pack/plugins/actions/server/routes/update.ts @@ -25,11 +25,13 @@ const bodySchema = schema.object({ const rewriteBodyRes: RewriteResponseCase = ({ actionTypeId, isPreconfigured, + isMissingSecrets, ...res }) => ({ ...res, connector_type_id: actionTypeId, is_preconfigured: isPreconfigured, + is_missing_secrets: isMissingSecrets, }); export const updateActionRoute = ( diff --git a/x-pack/plugins/actions/server/saved_objects/get_import_result_message.test.ts b/x-pack/plugins/actions/server/saved_objects/get_import_result_message.test.ts new file mode 100644 index 0000000000000..b5a5ab75b9248 --- /dev/null +++ b/x-pack/plugins/actions/server/saved_objects/get_import_result_message.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { SavedObject } from 'kibana/server'; +import { RawAction } from '../types'; +import { getImportResultMessage } from './get_import_result_message'; + +describe('getImportResultMessage', () => { + it('Return message with total imported connectors and the proper secrets need to update ', async () => { + const savedObjectConnectors = [ + { + type: 'action', + id: 'ed02cb70-a6ef-11eb-bd58-6b2eae02c6ef', + attributes: { + actionTypeId: '.server-log', + config: {}, + isMissingSecrets: false, + name: 'test', + }, + references: [], + migrationVersion: { action: '7.14.0' }, + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-27T04:10:33.043Z', + version: 'WzcxLDFd', + namespaces: ['default'], + }, + { + type: 'action', + id: 'e8aa94e0-a6ef-11eb-bd58-6b2eae02c6ef', + attributes: { + actionTypeId: '.email', + config: [Object], + isMissingSecrets: true, + name: 'test', + }, + references: [], + migrationVersion: { action: '7.14.0' }, + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-27T04:10:33.043Z', + version: 'WzcyLDFd', + namespaces: ['default'], + }, + ]; + const message = getImportResultMessage( + (savedObjectConnectors as unknown) as Array> + ); + expect(message).toBe('1 connector has secrets that require updates.'); + }); +}); diff --git a/x-pack/plugins/actions/server/saved_objects/get_import_result_message.ts b/x-pack/plugins/actions/server/saved_objects/get_import_result_message.ts new file mode 100644 index 0000000000000..3b88a750c7430 --- /dev/null +++ b/x-pack/plugins/actions/server/saved_objects/get_import_result_message.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { SavedObject } from 'kibana/server'; +import { RawAction } from '../types'; + +export function getImportResultMessage(connectors: Array>) { + const connectorsWithSecrets = connectors.filter( + (connector) => connector.attributes.isMissingSecrets + ); + return i18n.translate('xpack.actions.savedObjects.onImportText', { + defaultMessage: + '{connectorsWithSecretsLength} {connectorsWithSecretsLength, plural, one {connector has} other {connectors have}} secrets that require updates.', + values: { + connectorsWithSecretsLength: connectorsWithSecrets.length, + }, + }); +} + +export const GO_TO_CONNECTORS_BUTTON_LABLE = 'Go to connectors'; diff --git a/x-pack/plugins/actions/server/saved_objects/index.ts b/x-pack/plugins/actions/server/saved_objects/index.ts index c8626660de2d9..3c6a78a6f0866 100644 --- a/x-pack/plugins/actions/server/saved_objects/index.ts +++ b/x-pack/plugins/actions/server/saved_objects/index.ts @@ -5,10 +5,12 @@ * 2.0. */ -import { SavedObjectsServiceSetup } from 'kibana/server'; +import { SavedObject, SavedObjectsServiceSetup } from 'kibana/server'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; import mappings from './mappings.json'; import { getMigrations } from './migrations'; +import { RawAction } from '../types'; +import { getImportResultMessage, GO_TO_CONNECTORS_BUTTON_LABLE } from './get_import_result_message'; export const ACTION_SAVED_OBJECT_TYPE = 'action'; export const ALERT_SAVED_OBJECT_TYPE = 'alert'; @@ -24,6 +26,25 @@ export function setupSavedObjects( namespaceType: 'single', mappings: mappings.action, migrations: getMigrations(encryptedSavedObjects), + management: { + defaultSearchField: 'name', + importableAndExportable: true, + getTitle(obj) { + return `Connector: [${obj.attributes.name}]`; + }, + onImport(connectors) { + return { + warnings: [ + { + type: 'action_required', + message: getImportResultMessage(connectors as Array>), + actionPath: '/app/management/insightsAndAlerting/triggersActions/connectors', + buttonLabel: GO_TO_CONNECTORS_BUTTON_LABLE, + }, + ], + }; + }, + }, }); // Encrypted attributes diff --git a/x-pack/plugins/actions/server/saved_objects/mappings.json b/x-pack/plugins/actions/server/saved_objects/mappings.json index ef6a0c9919920..c598b96ba2451 100644 --- a/x-pack/plugins/actions/server/saved_objects/mappings.json +++ b/x-pack/plugins/actions/server/saved_objects/mappings.json @@ -12,6 +12,9 @@ "actionTypeId": { "type": "keyword" }, + "isMissingSecrets": { + "type": "boolean" + }, "config": { "enabled": false, "type": "object" diff --git a/x-pack/plugins/actions/server/saved_objects/migrations.test.ts b/x-pack/plugins/actions/server/saved_objects/migrations.test.ts index a75735e514c10..4c30925e61894 100644 --- a/x-pack/plugins/actions/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/actions/server/saved_objects/migrations.test.ts @@ -43,7 +43,7 @@ describe('7.10.0', () => { test('rename cases configuration object', () => { const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0']; - const action = getMockData({}); + const action = getCasesMockData({}); const migratedAction = migration710(action, context); expect(migratedAction.attributes.config).toEqual({ incidentConfiguration: { mapping: [] }, @@ -112,10 +112,32 @@ describe('7.11.0', () => { }); }); +describe('7.14.0', () => { + beforeEach(() => { + jest.resetAllMocks(); + encryptedSavedObjectsSetup.createMigration.mockImplementation( + (shouldMigrateWhenPredicate, migration) => migration + ); + }); + + test('add isMissingSecrets property for actions', () => { + const migration714 = getMigrations(encryptedSavedObjectsSetup)['7.14.0']; + const action = getMockData({ isMissingSecrets: undefined }); + const migratedAction = migration714(action, context); + expect(migratedAction).toEqual({ + ...action, + attributes: { + ...action.attributes, + isMissingSecrets: false, + }, + }); + }); +}); + function getMockDataForWebhook( overwrites: Record = {}, hasUserAndPassword: boolean -): SavedObjectUnsanitizedDoc { +): SavedObjectUnsanitizedDoc> { const secrets = hasUserAndPassword ? { user: 'test', password: '123' } : { user: '', password: '' }; @@ -134,7 +156,7 @@ function getMockDataForWebhook( function getMockDataForEmail( overwrites: Record = {} -): SavedObjectUnsanitizedDoc { +): SavedObjectUnsanitizedDoc> { return { attributes: { name: 'abc', @@ -148,9 +170,9 @@ function getMockDataForEmail( }; } -function getMockData( +function getCasesMockData( overwrites: Record = {} -): SavedObjectUnsanitizedDoc { +): SavedObjectUnsanitizedDoc> { return { attributes: { name: 'abc', @@ -163,3 +185,19 @@ function getMockData( type: 'action', }; } + +function getMockData( + overwrites: Record = {} +): SavedObjectUnsanitizedDoc> { + return { + attributes: { + name: 'abc', + actionTypeId: '123', + config: {}, + secrets: {}, + ...overwrites, + }, + id: uuid.v4(), + type: 'action', + }; +} diff --git a/x-pack/plugins/actions/server/saved_objects/migrations.ts b/x-pack/plugins/actions/server/saved_objects/migrations.ts index 9bd54330f5d05..17932b6b90f97 100644 --- a/x-pack/plugins/actions/server/saved_objects/migrations.ts +++ b/x-pack/plugins/actions/server/saved_objects/migrations.ts @@ -41,9 +41,15 @@ export function getMigrations( pipeMigrations(removeCasesFieldMappings, addHasAuthConfigurationObject) ); + const migrationActionsFourteen = encryptedSavedObjects.createMigration( + (doc): doc is SavedObjectUnsanitizedDoc => true, + pipeMigrations(addisMissingSecretsField) + ); + return { '7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'), + '7.14.0': executeMigrationWithErrorHandling(migrationActionsFourteen, '7.14.0'), }; } @@ -127,6 +133,18 @@ const addHasAuthConfigurationObject = ( }; }; +const addisMissingSecretsField = ( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + isMissingSecrets: false, + }, + }; +}; + function pipeMigrations(...migrations: ActionMigration[]): ActionMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index d6f99a766ed34..ea22e90dfed40 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -63,6 +63,7 @@ export interface ActionResult { + before(() => { + cy.loginAsSuperUser(); + }); it('Redirects to service page with rangeFrom and rangeTo added to the URL', () => { - const endDate = new Date(Cypress.env('END_DATE')); - cy.clock(endDate); - cy.visit('/app/apm'); cy.url().should( diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/support/commands.js b/x-pack/plugins/apm/ftr_e2e/cypress/support/commands.js deleted file mode 100644 index bc32b9a0320e0..0000000000000 --- a/x-pack/plugins/apm/ftr_e2e/cypress/support/commands.js +++ /dev/null @@ -1,32 +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. - */ - -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add("login", (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/support/commands.ts b/x-pack/plugins/apm/ftr_e2e/cypress/support/commands.ts new file mode 100644 index 0000000000000..d19826418cb52 --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/support/commands.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. + */ + +Cypress.Commands.add('loginAsReadOnlyUser', () => { + cy.loginAs({ username: 'apm_read_user', password: 'changeme' }); +}); + +Cypress.Commands.add('loginAsSuperUser', () => { + cy.loginAs({ username: 'elastic', password: 'changeme' }); +}); + +Cypress.Commands.add( + 'loginAs', + ({ username, password }: { username: string; password: string }) => { + cy.log(`Logging in as ${username}`); + const kibanaUrl = Cypress.env('KIBANA_URL'); + cy.request({ + method: 'POST', + url: `${kibanaUrl}/internal/security/login`, + body: { + providerType: 'basic', + providerName: 'basic', + currentURL: `${kibanaUrl}/login`, + params: { username, password }, + }, + headers: { + 'kbn-xsrf': 'e2e_test', + }, + }); + } +); diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/support/index.js b/x-pack/plugins/apm/ftr_e2e/cypress/support/index.js deleted file mode 100644 index 0e9350b188de5..0000000000000 --- a/x-pack/plugins/apm/ftr_e2e/cypress/support/index.js +++ /dev/null @@ -1,27 +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. - */ - -// *********************************************************** -// This example support/index.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import './commands'; - -// Alternatively you can use CommonJS syntax: -// require('./commands') diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/translations.ts b/x-pack/plugins/apm/ftr_e2e/cypress/support/index.ts similarity index 87% rename from x-pack/plugins/security_solution/public/cases/components/add_comment/translations.ts rename to x-pack/plugins/apm/ftr_e2e/cypress/support/index.ts index d94a4a8607d1e..3dbe36647a851 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/translations.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/support/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from '../../translations'; +import './commands'; diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/support/types.d.ts b/x-pack/plugins/apm/ftr_e2e/cypress/support/types.d.ts new file mode 100644 index 0000000000000..ad50f3820b82a --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/support/types.d.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. + */ + +declare namespace Cypress { + interface Chainable { + loginAsReadOnlyUser(): void; + loginAsSuperUser(): void; + loginAs(params: { username: string; password: string }): void; + } +} diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts b/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts new file mode 100644 index 0000000000000..69473e8689627 --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.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. + */ + +export const esArchiverLoad = (folder: string) => { + cy.exec( + `node ../../../../scripts/es_archiver load ${folder} --dir ./cypress/fixtures/es_archiver --config ../../../test/functional/config.js --es-url ${Cypress.env( + 'ELASTICSEARCH_URL' + )} --kibana-url ${Cypress.config().baseUrl}` + ); +}; + +export const esArchiverUnload = (folder: string) => { + cy.exec( + `node ../../../../scripts/es_archiver unload ${folder} --dir ./cypress/fixtures/es_archiver --config ../../../test/functional/config.js --es-url ${Cypress.env( + 'ELASTICSEARCH_URL' + )} --kibana-url ${Cypress.config().baseUrl}` + ); +}; + +export const esArchiverResetKibana = () => { + cy.exec( + `node ../../../../scripts/es_archiver empty-kibana-index --config ../../../test/functional/config.js --es-url ${Cypress.env( + 'ELASTICSEARCH_URL' + )} --kibana-url ${Cypress.config().baseUrl}`, + { failOnNonZeroExit: false } + ); +}; diff --git a/x-pack/plugins/apm/ftr_e2e/cypress_start.ts b/x-pack/plugins/apm/ftr_e2e/cypress_start.ts index 97396cd6a0282..0a13caa1a665b 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress_start.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress_start.ts @@ -7,6 +7,7 @@ import Url from 'url'; import cypress from 'cypress'; +import childProcess from 'child_process'; import { FtrProviderContext } from './ftr_provider_context'; import archives_metadata from './cypress/fixtures/es_archiver/archives_metadata'; @@ -23,18 +24,32 @@ async function cypressStart( cypressExecution: typeof cypress.run | typeof cypress.open ) { const config = getService('config'); - const esArchiver = getService('esArchiver'); const archiveName = 'apm_8.0.0'; - // Load apm data on ES - await esArchiver.load(archiveName); const { start, end } = archives_metadata[archiveName]; + const kibanaUrl = Url.format({ + protocol: config.get('servers.kibana.protocol'), + hostname: config.get('servers.kibana.hostname'), + port: config.get('servers.kibana.port'), + }); + + // Creates APM users + childProcess.execSync( + `node ../scripts/setup-kibana-security.js --role-suffix e2e_tests --username ${config.get( + 'servers.elasticsearch.username' + )} --password ${config.get( + 'servers.elasticsearch.password' + )} --kibana-url ${kibanaUrl}` + ); + await cypressExecution({ - config: { baseUrl: Url.format(config.get('servers.kibana')) }, + config: { baseUrl: kibanaUrl }, env: { START_DATE: start, END_DATE: end, + ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), + KIBANA_URL: kibanaUrl, }, }); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx index b2e8ca5fda805..a8435beca1e4a 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx @@ -88,10 +88,11 @@ export function PageLoadDistribution() { const exploratoryViewLink = createExploratoryViewUrl( { [`${serviceName}-page-views`]: { + dataType: 'ux', reportType: 'pld', time: { from: rangeFrom!, to: rangeTo! }, reportDefinitions: { - 'service.name': serviceName?.[0] as string, + 'service.name': serviceName as string[], }, ...(breakdown ? { breakdown: breakdown.fieldName } : {}), }, @@ -99,6 +100,8 @@ export function PageLoadDistribution() { http?.basePath.get() ); + const showAnalyzeButton = false; + return (
@@ -118,18 +121,20 @@ export function PageLoadDistribution() { dataTestSubj={'pldBreakdownFilter'} /> - - - - - + {showAnalyzeButton && ( + + + + + + )} @@ -89,18 +92,20 @@ export function PageViewsTrend() { dataTestSubj={'pvBreakdownFilter'} /> - - - - - + {showAnalyzeButton && ( + + + + + + )} diff --git a/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx b/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx index dc071fe93bbbd..9353c37b90728 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx +++ b/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx @@ -10,7 +10,6 @@ import React, { FormEvent, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import styled from 'styled-components'; import { useApmServiceContext } from '../../context/apm_service/use_apm_service_context'; -import { useUrlParams } from '../../context/url_params_context/use_url_params'; import * as urlHelpers from './Links/url_helpers'; // The default transaction type (for non-RUM services) is "request". Set the @@ -21,11 +20,8 @@ const EuiSelectWithWidth = styled(EuiSelect)` `; export function TransactionTypeSelect() { - const { transactionTypes } = useApmServiceContext(); + const { transactionTypes, transactionType } = useApmServiceContext(); const history = useHistory(); - const { - urlParams: { transactionType }, - } = useUrlParams(); const handleChange = useCallback( (event: FormEvent) => { 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/canvas/canvas_plugin_src/functions/server/esdocs.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts index 992bceaa3390c..e77717d689f95 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts @@ -5,7 +5,7 @@ * 2.0. */ -import squel from 'squel'; +import squel from 'safe-squel'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; /* eslint-disable */ import { queryEsSQL } from '../../../server/lib/query_es_sql'; 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/enterprise_search/public/applications/workplace_search/views/overview_mvp/index.ts b/x-pack/plugins/cases/common/ui/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/common/ui/index.ts index 69c843fe3821e..6cc0ccaa93a6d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/index.ts +++ b/x-pack/plugins/cases/common/ui/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { Overview } from './overview'; +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/cases/public/common/mock/index.ts b/x-pack/plugins/cases/public/common/mock/index.ts new file mode 100644 index 0000000000000..add4c1c206dd4 --- /dev/null +++ b/x-pack/plugins/cases/public/common/mock/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 './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/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 82f04d82ea2f8..ebc270e96767e 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -17,7 +17,6 @@ import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginStart } from '../../../../src/plugins/share/public'; -import { EnhancedSearchInterceptor } from './search/search_interceptor'; import { registerSearchSessionsMgmt } from './search/sessions_mgmt'; import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; import { createConnectedSearchSessionIndicator } from './search'; @@ -39,7 +38,6 @@ export type DataEnhancedStart = ReturnType<DataEnhancedPlugin['start']>; export class DataEnhancedPlugin implements Plugin<void, void, DataEnhancedSetupDependencies, DataEnhancedStartDependencies> { - private enhancedSearchInterceptor!: EnhancedSearchInterceptor; private config!: ConfigSchema; private readonly storage = new Storage(window.localStorage); private usageCollector?: SearchUsageCollector; @@ -50,22 +48,6 @@ export class DataEnhancedPlugin core: CoreSetup<DataEnhancedStartDependencies>, { bfetch, data, management }: DataEnhancedSetupDependencies ) { - this.enhancedSearchInterceptor = new EnhancedSearchInterceptor({ - bfetch, - toasts: core.notifications.toasts, - http: core.http, - uiSettings: core.uiSettings, - startServices: core.getStartServices(), - usageCollector: data.search.usageCollector, - session: data.search.session, - }); - - data.__enhance({ - search: { - searchInterceptor: this.enhancedSearchInterceptor, - }, - }); - this.config = this.initializerContext.config.get<ConfigSchema>(); if (this.config.search.sessions.enabled) { const sessionsConfig = this.config.search.sessions; @@ -96,7 +78,5 @@ export class DataEnhancedPlugin } } - public stop() { - this.enhancedSearchInterceptor.stop(); - } + public stop() {} } diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts deleted file mode 100644 index 0e511c545f3e2..0000000000000 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts +++ /dev/null @@ -1,1111 +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 type { MockedKeys } from '@kbn/utility-types/jest'; -import { coreMock } from '../../../../../src/core/public/mocks'; -import { EnhancedSearchInterceptor } from './search_interceptor'; -import { CoreSetup, CoreStart } from 'kibana/public'; -import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; -import { AbortError } from '../../../../../src/plugins/kibana_utils/public'; -import { - ISessionService, - SearchTimeoutError, - SearchSessionState, - PainlessError, - DataPublicPluginSetup, -} from 'src/plugins/data/public'; -import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; -import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks'; -import { BehaviorSubject } from 'rxjs'; -import * as xpackResourceNotFoundException from '../../common/search/test_data/search_phase_execution_exception.json'; - -const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); - -const timeTravel = async (msToRun = 0) => { - await flushPromises(); - jest.advanceTimersByTime(msToRun); - return flushPromises(); -}; - -const next = jest.fn(); -const error = jest.fn(); -const complete = jest.fn(); - -let searchInterceptor: EnhancedSearchInterceptor; -let mockCoreSetup: MockedKeys<CoreSetup>; -let mockCoreStart: MockedKeys<CoreStart>; -let fetchMock: jest.Mock<any>; - -jest.useFakeTimers(); - -jest.mock('./utils', () => ({ - createRequestHash: jest.fn().mockImplementation((input) => { - return Promise.resolve(JSON.stringify(input)); - }), -})); - -function mockFetchImplementation(responses: any[]) { - let i = 0; - fetchMock.mockImplementation((r) => { - if (!r.request.id) i = 0; - const { time = 0, value = {}, isError = false } = responses[i++]; - value.meta = { - size: 10, - }; - return new Promise((resolve, reject) => - setTimeout(() => { - return (isError ? reject : resolve)(value); - }, time) - ); - }); -} - -describe('EnhancedSearchInterceptor', () => { - let sessionService: jest.Mocked<ISessionService>; - let sessionState$: BehaviorSubject<SearchSessionState>; - let dataPluginMockSetup: DataPublicPluginSetup; - - beforeEach(() => { - mockCoreSetup = coreMock.createSetup(); - mockCoreStart = coreMock.createStart(); - sessionState$ = new BehaviorSubject<SearchSessionState>(SearchSessionState.None); - dataPluginMockSetup = dataPluginMock.createSetupContract(); - const dataPluginMockStart = dataPluginMock.createStartContract(); - sessionService = { - ...(dataPluginMockStart.search.session as jest.Mocked<ISessionService>), - state$: sessionState$, - }; - fetchMock = jest.fn(); - - mockCoreSetup.uiSettings.get.mockImplementation((name: string) => { - switch (name) { - case UI_SETTINGS.SEARCH_TIMEOUT: - return 1000; - default: - return; - } - }); - - next.mockClear(); - error.mockClear(); - complete.mockClear(); - jest.clearAllTimers(); - - const mockPromise = new Promise((resolve) => { - resolve([ - { - application: mockCoreStart.application, - }, - ]); - }); - - const bfetchMock = bfetchPluginMock.createSetupContract(); - bfetchMock.batchedFunction.mockReturnValue(fetchMock); - - searchInterceptor = new EnhancedSearchInterceptor({ - bfetch: bfetchMock, - toasts: mockCoreSetup.notifications.toasts, - startServices: mockPromise as any, - http: mockCoreSetup.http, - uiSettings: mockCoreSetup.uiSettings, - usageCollector: dataPluginMockSetup.search.usageCollector, - session: sessionService, - }); - }); - - describe('errors', () => { - test('Should throw Painless error on server error with OSS format', async () => { - const mockResponse: any = { - statusCode: 400, - message: 'search_phase_execution_exception', - attributes: xpackResourceNotFoundException.error, - }; - fetchMock.mockRejectedValueOnce(mockResponse); - const response = searchInterceptor.search({ - params: {}, - }); - await expect(response.toPromise()).rejects.toThrow(PainlessError); - }); - - test('Renders a PainlessError', async () => { - searchInterceptor.showError( - new PainlessError({ - statusCode: 400, - message: 'search_phase_execution_exception', - attributes: xpackResourceNotFoundException.error, - }) - ); - expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); - expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); - }); - }); - - describe('search', () => { - test('should resolve immediately if first call returns full result', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: false, - isRunning: false, - id: 1, - rawResponse: { - took: 1, - }, - }, - }, - ]; - mockFetchImplementation(responses); - - const response = searchInterceptor.search({}); - response.subscribe({ next, error, complete }); - - await timeTravel(10); - - expect(next).toHaveBeenCalled(); - expect(next.mock.calls[0][0]).toStrictEqual(responses[0].value); - expect(complete).toHaveBeenCalled(); - expect(error).not.toHaveBeenCalled(); - }); - - test('should make secondary request if first call returns partial result', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - rawResponse: { - took: 1, - }, - }, - }, - { - time: 20, - value: { - isPartial: false, - isRunning: false, - id: 1, - rawResponse: { - took: 1, - }, - }, - }, - ]; - - mockFetchImplementation(responses); - - const response = searchInterceptor.search({}, { pollInterval: 0 }); - response.subscribe({ next, error, complete }); - - await timeTravel(10); - - expect(next).toHaveBeenCalled(); - expect(next.mock.calls[0][0]).toStrictEqual(responses[0].value); - expect(complete).not.toHaveBeenCalled(); - expect(error).not.toHaveBeenCalled(); - - await timeTravel(20); - - expect(next).toHaveBeenCalledTimes(2); - expect(next.mock.calls[1][0]).toStrictEqual(responses[1].value); - expect(complete).toHaveBeenCalled(); - expect(error).not.toHaveBeenCalled(); - }); - - test('should abort if request is partial and not running (ES graceful error)', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: false, - id: 1, - }, - }, - ]; - mockFetchImplementation(responses); - - const response = searchInterceptor.search({}); - response.subscribe({ next, error }); - - await timeTravel(10); - - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(Error); - }); - - test('should abort on user abort', async () => { - const responses = [ - { - time: 500, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]; - mockFetchImplementation(responses); - - const abortController = new AbortController(); - abortController.abort(); - - const response = searchInterceptor.search({}, { abortSignal: abortController.signal }); - response.subscribe({ next, error }); - - await timeTravel(500); - - expect(next).not.toHaveBeenCalled(); - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); - }); - - test('should DELETE a running async search on abort', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - }, - }, - { - time: 300, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]; - mockFetchImplementation(responses); - - const abortController = new AbortController(); - setTimeout(() => abortController.abort(), 250); - - const response = searchInterceptor.search( - {}, - { abortSignal: abortController.signal, pollInterval: 0 } - ); - response.subscribe({ next, error }); - - await timeTravel(10); - - expect(next).toHaveBeenCalled(); - expect(error).not.toHaveBeenCalled(); - - await timeTravel(240); - - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); - }); - - test('should not DELETE a running async search on async timeout prior to first response', async () => { - const responses = [ - { - time: 2000, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]; - mockFetchImplementation(responses); - - const response = searchInterceptor.search({}, { pollInterval: 0 }); - response.subscribe({ next, error }); - - await timeTravel(1000); - - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(SearchTimeoutError); - expect(fetchMock).toHaveBeenCalled(); - expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); - }); - - test('should DELETE a running async search on async timeout after first response', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - }, - }, - { - time: 2000, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]; - mockFetchImplementation(responses); - - const response = searchInterceptor.search({}, { pollInterval: 0 }); - response.subscribe({ next, error }); - - await timeTravel(10); - - expect(next).toHaveBeenCalled(); - expect(error).not.toHaveBeenCalled(); - expect(fetchMock).toHaveBeenCalled(); - expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); - - // Long enough to reach the timeout but not long enough to reach the next response - await timeTravel(1000); - - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(SearchTimeoutError); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); - }); - - test('should DELETE a running async search on async timeout on error from fetch', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - }, - }, - { - time: 10, - value: { - statusCode: 500, - message: 'oh no', - id: 1, - }, - isError: true, - }, - ]; - mockFetchImplementation(responses); - - const response = searchInterceptor.search({}, { pollInterval: 0 }); - response.subscribe({ next, error }); - - await timeTravel(10); - - expect(next).toHaveBeenCalled(); - expect(error).not.toHaveBeenCalled(); - expect(fetchMock).toHaveBeenCalled(); - expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); - - // Long enough to reach the timeout but not long enough to reach the next response - await timeTravel(10); - - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(Error); - expect((error.mock.calls[0][0] as Error).message).toBe('oh no'); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); - }); - - test('should NOT DELETE a running SAVED async search on abort', async () => { - const sessionId = 'sessionId'; - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - }, - }, - { - time: 300, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]; - mockFetchImplementation(responses); - - const abortController = new AbortController(); - setTimeout(() => abortController.abort(), 250); - - const response = searchInterceptor.search( - {}, - { abortSignal: abortController.signal, pollInterval: 0, sessionId } - ); - response.subscribe({ next, error }); - - await timeTravel(10); - - expect(next).toHaveBeenCalled(); - expect(error).not.toHaveBeenCalled(); - - sessionState$.next(SearchSessionState.BackgroundLoading); - - await timeTravel(240); - - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); - }); - }); - - describe('session tracking', () => { - beforeEach(() => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - }, - }, - { - time: 300, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]; - - mockFetchImplementation(responses); - }); - - test('should track searches', async () => { - const sessionId = 'sessionId'; - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const response = searchInterceptor.search({}, { pollInterval: 0, sessionId }); - response.subscribe({ next, error }); - await timeTravel(10); - expect(sessionService.trackSearch).toBeCalledTimes(1); - expect(untrack).not.toBeCalled(); - await timeTravel(300); - expect(sessionService.trackSearch).toBeCalledTimes(1); - expect(untrack).toBeCalledTimes(1); - }); - - test('session service should be able to cancel search', async () => { - const sessionId = 'sessionId'; - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const response = searchInterceptor.search({}, { pollInterval: 0, sessionId }); - response.subscribe({ next, error }); - await timeTravel(10); - expect(sessionService.trackSearch).toBeCalledTimes(1); - - const abort = sessionService.trackSearch.mock.calls[0][0].abort; - expect(abort).toBeInstanceOf(Function); - - abort(); - - await timeTravel(10); - - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); - }); - - test("don't track non current session searches", async () => { - const sessionId = 'sessionId'; - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const response1 = searchInterceptor.search( - {}, - { pollInterval: 0, sessionId: 'something different' } - ); - response1.subscribe({ next, error }); - - const response2 = searchInterceptor.search({}, { pollInterval: 0, sessionId: undefined }); - response2.subscribe({ next, error }); - - await timeTravel(10); - expect(sessionService.trackSearch).toBeCalledTimes(0); - }); - - test("don't track if no current session", async () => { - sessionService.getSessionId.mockImplementation(() => undefined); - sessionService.isCurrentSession.mockImplementation((_sessionId) => false); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const response1 = searchInterceptor.search( - {}, - { pollInterval: 0, sessionId: 'something different' } - ); - response1.subscribe({ next, error }); - - const response2 = searchInterceptor.search({}, { pollInterval: 0, sessionId: undefined }); - response2.subscribe({ next, error }); - - await timeTravel(10); - expect(sessionService.trackSearch).toBeCalledTimes(0); - }); - }); - - describe('session client caching', () => { - const sessionId = 'sessionId'; - const basicReq = { - params: { - test: 1, - }, - }; - - const basicCompleteResponse = [ - { - time: 10, - value: { - isPartial: false, - isRunning: false, - id: 1, - rawResponse: { - took: 1, - }, - }, - }, - ]; - - const partialCompleteResponse = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - rawResponse: { - took: 1, - }, - }, - }, - { - time: 20, - value: { - isPartial: false, - isRunning: false, - id: 1, - rawResponse: { - took: 1, - }, - }, - }, - ]; - - beforeEach(() => { - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - }); - - test('should be disabled if there is no session', async () => { - mockFetchImplementation(basicCompleteResponse); - - searchInterceptor.search(basicReq, {}).subscribe({ next, error, complete }); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor.search(basicReq, {}).subscribe({ next, error, complete }); - expect(fetchMock).toBeCalledTimes(2); - }); - - test('should fetch different requests in a single session', async () => { - mockFetchImplementation(basicCompleteResponse); - - const req2 = { - params: { - test: 2, - }, - }; - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(2); - }); - - test('should fetch the same request for two different sessions', async () => { - mockFetchImplementation(basicCompleteResponse); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor - .search(basicReq, { sessionId: 'anotherSession' }) - .subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(2); - }); - - test('should track searches that come from cache', async () => { - mockFetchImplementation(partialCompleteResponse); - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const req = { - params: { - test: 200, - }, - }; - - const response = searchInterceptor.search(req, { pollInterval: 1, sessionId }); - const response2 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); - response.subscribe({ next, error, complete }); - response2.subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - expect(sessionService.trackSearch).toBeCalledTimes(2); - expect(untrack).not.toBeCalled(); - await timeTravel(300); - // Should be called only 2 times (once per partial response) - expect(fetchMock).toBeCalledTimes(2); - expect(sessionService.trackSearch).toBeCalledTimes(2); - expect(untrack).toBeCalledTimes(2); - - expect(next).toBeCalledTimes(4); - expect(error).toBeCalledTimes(0); - expect(complete).toBeCalledTimes(2); - }); - - test('should cache partial responses', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - }, - }, - ]; - - mockFetchImplementation(responses); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - }); - - test('should not cache error responses', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: false, - id: 1, - }, - }, - ]; - - mockFetchImplementation(responses); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(2); - }); - - test('should deliver error to all replays', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: false, - id: 1, - }, - }, - ]; - - mockFetchImplementation(responses); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - expect(error).toBeCalledTimes(2); - expect(error.mock.calls[0][0].message).toEqual('Received partial response'); - expect(error.mock.calls[1][0].message).toEqual('Received partial response'); - }); - - test('should ignore anything outside params when hashing', async () => { - mockFetchImplementation(basicCompleteResponse); - - const req = { - something: 123, - params: { - test: 1, - }, - }; - - const req2 = { - something: 321, - params: { - test: 1, - }, - }; - - searchInterceptor.search(req, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - }); - - test('should ignore preference when hashing', async () => { - mockFetchImplementation(basicCompleteResponse); - - const req = { - params: { - test: 1, - preference: 123, - }, - }; - - const req2 = { - params: { - test: 1, - preference: 321, - }, - }; - - searchInterceptor.search(req, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - }); - - test('should return from cache for identical requests in the same session', async () => { - mockFetchImplementation(basicCompleteResponse); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - }); - - test('aborting a search that didnt get any response should retrigger search', async () => { - mockFetchImplementation(basicCompleteResponse); - - const abortController = new AbortController(); - - // Start a search request - searchInterceptor - .search(basicReq, { sessionId, abortSignal: abortController.signal }) - .subscribe({ next, error, complete }); - - // Abort the search request before it started - abortController.abort(); - - // Time travel to make sure nothing appens - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(0); - expect(next).toBeCalledTimes(0); - expect(error).toBeCalledTimes(1); - expect(complete).toBeCalledTimes(0); - - const error2 = jest.fn(); - const next2 = jest.fn(); - const complete2 = jest.fn(); - - // Search for the same thing again - searchInterceptor - .search(basicReq, { sessionId }) - .subscribe({ next: next2, error: error2, complete: complete2 }); - - // Should search again - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - expect(next2).toBeCalledTimes(1); - expect(error2).toBeCalledTimes(0); - expect(complete2).toBeCalledTimes(1); - }); - - test('aborting a running first search shouldnt clear cache', async () => { - mockFetchImplementation(partialCompleteResponse); - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const req = { - params: { - test: 200, - }, - }; - - const abortController = new AbortController(); - - const response = searchInterceptor.search(req, { - pollInterval: 1, - sessionId, - abortSignal: abortController.signal, - }); - response.subscribe({ next, error, complete }); - await timeTravel(10); - - expect(fetchMock).toBeCalledTimes(1); - expect(next).toBeCalledTimes(1); - expect(error).toBeCalledTimes(0); - expect(complete).toBeCalledTimes(0); - expect(sessionService.trackSearch).toBeCalledTimes(1); - expect(untrack).not.toBeCalled(); - - const next2 = jest.fn(); - const error2 = jest.fn(); - const complete2 = jest.fn(); - const response2 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); - response2.subscribe({ next: next2, error: error2, complete: complete2 }); - await timeTravel(0); - - abortController.abort(); - - await timeTravel(300); - // Both searches should be tracked and untracked - expect(sessionService.trackSearch).toBeCalledTimes(2); - expect(untrack).toBeCalledTimes(2); - - // First search should error - expect(next).toBeCalledTimes(1); - expect(error).toBeCalledTimes(1); - expect(complete).toBeCalledTimes(0); - - // Second search should complete - expect(next2).toBeCalledTimes(2); - expect(error2).toBeCalledTimes(0); - expect(complete2).toBeCalledTimes(1); - - // Should be called only 2 times (once per partial response) - expect(fetchMock).toBeCalledTimes(2); - }); - - test('aborting a running second search shouldnt clear cache', async () => { - mockFetchImplementation(partialCompleteResponse); - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const req = { - params: { - test: 200, - }, - }; - - const abortController = new AbortController(); - - const response = searchInterceptor.search(req, { pollInterval: 1, sessionId }); - response.subscribe({ next, error, complete }); - await timeTravel(10); - - expect(fetchMock).toBeCalledTimes(1); - expect(next).toBeCalledTimes(1); - expect(error).toBeCalledTimes(0); - expect(complete).toBeCalledTimes(0); - expect(sessionService.trackSearch).toBeCalledTimes(1); - expect(untrack).not.toBeCalled(); - - const next2 = jest.fn(); - const error2 = jest.fn(); - const complete2 = jest.fn(); - const response2 = searchInterceptor.search(req, { - pollInterval: 0, - sessionId, - abortSignal: abortController.signal, - }); - response2.subscribe({ next: next2, error: error2, complete: complete2 }); - await timeTravel(0); - - abortController.abort(); - - await timeTravel(300); - expect(sessionService.trackSearch).toBeCalledTimes(2); - expect(untrack).toBeCalledTimes(2); - - expect(next).toBeCalledTimes(2); - expect(error).toBeCalledTimes(0); - expect(complete).toBeCalledTimes(1); - - expect(next2).toBeCalledTimes(1); - expect(error2).toBeCalledTimes(1); - expect(complete2).toBeCalledTimes(0); - - // Should be called only 2 times (once per partial response) - expect(fetchMock).toBeCalledTimes(2); - }); - - test('aborting both requests should cancel underlaying search only once', async () => { - mockFetchImplementation(partialCompleteResponse); - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - sessionService.trackSearch.mockImplementation(() => jest.fn()); - - const req = { - params: { - test: 200, - }, - }; - - const abortController = new AbortController(); - - const response = searchInterceptor.search(req, { - pollInterval: 1, - sessionId, - abortSignal: abortController.signal, - }); - response.subscribe({ next, error, complete }); - - const response2 = searchInterceptor.search(req, { - pollInterval: 1, - sessionId, - abortSignal: abortController.signal, - }); - response2.subscribe({ next, error, complete }); - await timeTravel(10); - - abortController.abort(); - - await timeTravel(300); - - expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); - }); - - test('aborting both searches should stop searching and clear cache', async () => { - mockFetchImplementation(partialCompleteResponse); - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const req = { - params: { - test: 200, - }, - }; - - const abortController = new AbortController(); - - const response = searchInterceptor.search(req, { - pollInterval: 1, - sessionId, - abortSignal: abortController.signal, - }); - response.subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - const response2 = searchInterceptor.search(req, { - pollInterval: 1, - sessionId, - abortSignal: abortController.signal, - }); - response2.subscribe({ next, error, complete }); - await timeTravel(0); - expect(fetchMock).toBeCalledTimes(1); - - abortController.abort(); - - await timeTravel(300); - - expect(next).toBeCalledTimes(2); - expect(error).toBeCalledTimes(2); - expect(complete).toBeCalledTimes(0); - expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); - expect(error.mock.calls[1][0]).toBeInstanceOf(AbortError); - - // Should be called only 1 times (one partial response) - expect(fetchMock).toBeCalledTimes(1); - - // Clear mock and research - fetchMock.mockReset(); - mockFetchImplementation(partialCompleteResponse); - // Run the search again to see that we don't hit the cache - const response3 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); - response3.subscribe({ next, error, complete }); - - await timeTravel(10); - await timeTravel(10); - await timeTravel(300); - - // Should be called 2 times (two partial response) - expect(fetchMock).toBeCalledTimes(2); - expect(complete).toBeCalledTimes(1); - }); - - test('aborting a completed search shouldnt effect cache', async () => { - mockFetchImplementation(basicCompleteResponse); - - const abortController = new AbortController(); - - // Start a search request - searchInterceptor - .search(basicReq, { sessionId, abortSignal: abortController.signal }) - .subscribe({ next, error, complete }); - - // Get a final response - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - // Abort the search request - abortController.abort(); - - // Search for the same thing again - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - - // Get the response from cache - expect(fetchMock).toBeCalledTimes(1); - }); - }); -}); diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts deleted file mode 100644 index ed91b7b598488..0000000000000 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts +++ /dev/null @@ -1,225 +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 { once } from 'lodash'; -import { throwError, Subscription, from, of, fromEvent, EMPTY } from 'rxjs'; -import { - tap, - finalize, - catchError, - filter, - take, - skip, - switchMap, - shareReplay, - map, - takeUntil, -} from 'rxjs/operators'; -import { - TimeoutErrorMode, - SearchInterceptor, - SearchInterceptorDeps, - UI_SETTINGS, - IKibanaSearchRequest, - SearchSessionState, -} from '../../../../../src/plugins/data/public'; -import { - ENHANCED_ES_SEARCH_STRATEGY, - IAsyncSearchOptions, - pollSearch, -} from '../../../../../src/plugins/data/common'; -import { createRequestHash } from './utils'; -import { SearchResponseCache } from './search_response_cache'; -import { AbortError } from '../../../../../src/plugins/kibana_utils/public'; -import { SearchAbortController } from './search_abort_controller'; - -const MAX_CACHE_ITEMS = 50; -const MAX_CACHE_SIZE_MB = 10; -export class EnhancedSearchInterceptor extends SearchInterceptor { - private uiSettingsSub: Subscription; - private searchTimeout: number; - private readonly responseCache: SearchResponseCache = new SearchResponseCache( - MAX_CACHE_ITEMS, - MAX_CACHE_SIZE_MB - ); - - /** - * @internal - */ - constructor(deps: SearchInterceptorDeps) { - super(deps); - this.searchTimeout = deps.uiSettings.get(UI_SETTINGS.SEARCH_TIMEOUT); - - this.uiSettingsSub = deps.uiSettings - .get$(UI_SETTINGS.SEARCH_TIMEOUT) - .subscribe((timeout: number) => { - this.searchTimeout = timeout; - }); - } - - public stop() { - this.responseCache.clear(); - this.uiSettingsSub.unsubscribe(); - } - - protected getTimeoutMode() { - return this.application.capabilities.advancedSettings?.save - ? TimeoutErrorMode.CHANGE - : TimeoutErrorMode.CONTACT; - } - - private createRequestHash$(request: IKibanaSearchRequest, options: IAsyncSearchOptions) { - const { sessionId, isRestore } = options; - // Preference is used to ensure all queries go to the same set of shards and it doesn't need to be hashed - // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-shard-routing.html#shard-and-node-preference - const { preference, ...params } = request.params || {}; - const hashOptions = { - ...params, - sessionId, - isRestore, - }; - - return from(sessionId ? createRequestHash(hashOptions) : of(undefined)); - } - - /** - * @internal - * Creates a new pollSearch that share replays its results - */ - private runSearch$( - { id, ...request }: IKibanaSearchRequest, - options: IAsyncSearchOptions, - searchAbortController: SearchAbortController - ) { - const search = () => this.runSearch({ id, ...request }, options); - const { sessionId, strategy } = options; - - // track if this search's session will be send to background - // if yes, then we don't need to cancel this search when it is aborted - let isSavedToBackground = false; - const savedToBackgroundSub = - this.deps.session.isCurrentSession(sessionId) && - this.deps.session.state$ - .pipe( - skip(1), // ignore any state, we are only interested in transition x -> BackgroundLoading - filter( - (state) => - this.deps.session.isCurrentSession(sessionId) && - state === SearchSessionState.BackgroundLoading - ), - take(1) - ) - .subscribe(() => { - isSavedToBackground = true; - }); - - const cancel = once(() => { - if (id && !isSavedToBackground) this.deps.http.delete(`/internal/search/${strategy}/${id}`); - }); - - return pollSearch(search, cancel, { - ...options, - abortSignal: searchAbortController.getSignal(), - }).pipe( - tap((response) => (id = response.id)), - catchError((e: Error) => { - cancel(); - return throwError(e); - }), - finalize(() => { - searchAbortController.cleanup(); - if (savedToBackgroundSub) { - savedToBackgroundSub.unsubscribe(); - } - }), - // This observable is cached in the responseCache. - // Using shareReplay makes sure that future subscribers will get the final response - - shareReplay(1) - ); - } - - /** - * @internal - * Creates a new search observable and a corresponding search abort controller - * If requestHash is defined, tries to return them first from cache. - */ - private getSearchResponse$( - request: IKibanaSearchRequest, - options: IAsyncSearchOptions, - requestHash?: string - ) { - const cached = requestHash ? this.responseCache.get(requestHash) : undefined; - - const searchAbortController = - cached?.searchAbortController || new SearchAbortController(this.searchTimeout); - - // Create a new abort signal if one was not passed. This fake signal will never be aborted, - // So the underlaying search will not be aborted, even if the other consumers abort. - searchAbortController.addAbortSignal(options.abortSignal ?? new AbortController().signal); - const response$ = cached?.response$ || this.runSearch$(request, options, searchAbortController); - - if (requestHash && !this.responseCache.has(requestHash)) { - this.responseCache.set(requestHash, { - response$, - searchAbortController, - }); - } - - return { - response$, - searchAbortController, - }; - } - - public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { - const searchOptions = { - strategy: ENHANCED_ES_SEARCH_STRATEGY, - ...options, - }; - const { sessionId, abortSignal } = searchOptions; - - return this.createRequestHash$(request, searchOptions).pipe( - switchMap((requestHash) => { - const { searchAbortController, response$ } = this.getSearchResponse$( - request, - searchOptions, - requestHash - ); - - this.pendingCount$.next(this.pendingCount$.getValue() + 1); - const untrackSearch = this.deps.session.isCurrentSession(sessionId) - ? this.deps.session.trackSearch({ abort: () => searchAbortController.abort() }) - : undefined; - - // Abort the replay if the abortSignal is aborted. - // The underlaying search will not abort unless searchAbortController fires. - const aborted$ = (abortSignal ? fromEvent(abortSignal, 'abort') : EMPTY).pipe( - map(() => { - throw new AbortError(); - }) - ); - - return response$.pipe( - takeUntil(aborted$), - catchError((e) => { - return throwError( - this.handleSearchError(e, searchOptions, searchAbortController.isTimeout()) - ); - }), - finalize(() => { - this.pendingCount$.next(this.pendingCount$.getValue() - 1); - if (untrackSearch && this.deps.session.isCurrentSession(sessionId)) { - // untrack if this search still belongs to current session - untrackSearch(); - } - }) - ); - }) - ); - } -} diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts index 29e6eddde65aa..a1ce3709e51a7 100644 --- a/x-pack/plugins/data_enhanced/server/plugin.ts +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -8,7 +8,6 @@ import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; import { registerSessionRoutes } from './routes'; import { searchSessionSavedObjectType } from './saved_objects'; -import { getUiSettings } from './ui_settings'; import type { DataEnhancedRequestHandlerContext, DataEnhancedSetupDependencies as SetupDependencies, @@ -30,7 +29,6 @@ export class EnhancedDataServerPlugin } public setup(core: CoreSetup<StartDependencies>, deps: SetupDependencies) { - core.uiSettings.register(getUiSettings()); core.savedObjects.registerType(searchSessionSavedObjectType); this.sessionService = new SearchSessionService(this.logger, this.config, deps.security); diff --git a/x-pack/plugins/data_enhanced/server/ui_settings.ts b/x-pack/plugins/data_enhanced/server/ui_settings.ts deleted file mode 100644 index fff7b798d783f..0000000000000 --- a/x-pack/plugins/data_enhanced/server/ui_settings.ts +++ /dev/null @@ -1,29 +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'; -import { schema } from '@kbn/config-schema'; -import { UiSettingsParams } from 'kibana/server'; -import { UI_SETTINGS } from '../../../../src/plugins/data/server'; - -export function getUiSettings(): Record<string, UiSettingsParams<unknown>> { - return { - [UI_SETTINGS.SEARCH_TIMEOUT]: { - name: i18n.translate('xpack.data.advancedSettings.searchTimeout', { - defaultMessage: 'Search Timeout', - }), - value: 600000, - description: i18n.translate('xpack.data.advancedSettings.searchTimeoutDesc', { - defaultMessage: - 'Change the maximum timeout for a search session or set to 0 to disable the timeout and allow queries to run to completion.', - }), - type: 'number', - category: ['search'], - schema: schema.number(), - }, - }; -} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index 9565408f7f47c..ba9173e54ec08 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -19,6 +19,7 @@ import { Loading } from '../../../shared/loading'; import { AnalyticsRouter } from '../analytics'; import { ApiLogs } from '../api_logs'; import { CurationsRouter } from '../curations'; +import { Documents, DocumentDetail } from '../documents'; import { EngineOverview } from '../engine_overview'; import { RelevanceTuning } from '../relevance_tuning'; import { ResultSettings } from '../result_settings'; @@ -103,6 +104,14 @@ describe('EngineRouter', () => { expect(wrapper.find(AnalyticsRouter)).toHaveLength(1); }); + it('renders a documents view', () => { + setMockValues({ ...values, myRole: { canViewEngineDocuments: true } }); + const wrapper = shallow(<EngineRouter />); + + expect(wrapper.find(Documents)).toHaveLength(1); + expect(wrapper.find(DocumentDetail)).toHaveLength(1); + }); + it('renders a synonyms view', () => { setMockValues({ ...values, myRole: { canManageEngineSynonyms: true } }); const wrapper = shallow(<EngineRouter />); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 80d1096237345..65769446b10db 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -17,7 +17,6 @@ import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chro import { Loading } from '../../../shared/loading'; import { AppLogic } from '../../app_logic'; -// TODO: Uncomment and add more routes as we migrate them import { ENGINES_PATH, ENGINE_ANALYTICS_PATH, @@ -50,7 +49,7 @@ export const EngineRouter: React.FC = () => { const { myRole: { canViewEngineAnalytics, - // canViewEngineDocuments, + canViewEngineDocuments, // canViewEngineSchema, // canViewEngineCrawler, canViewMetaEngineSourceEngines, @@ -93,12 +92,16 @@ export const EngineRouter: React.FC = () => { <AnalyticsRouter /> </Route> )} - <Route path={ENGINE_DOCUMENT_DETAIL_PATH}> - <DocumentDetail /> - </Route> - <Route path={ENGINE_DOCUMENTS_PATH}> - <Documents /> - </Route> + {canViewEngineDocuments && ( + <Route path={ENGINE_DOCUMENT_DETAIL_PATH}> + <DocumentDetail /> + </Route> + )} + {canViewEngineDocuments && ( + <Route path={ENGINE_DOCUMENTS_PATH}> + <Documents /> + </Route> + )} {canManageEngineCurations && ( <Route path={ENGINE_CURATIONS_PATH}> <CurationsRouter /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index d26838335d8f6..2a7f256398381 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -173,7 +173,7 @@ describe('AppSearchNav', () => { setMockValues({ myRole: { canViewSettings: true } }); const wrapper = shallow(<AppSearchNav />); - expect(wrapper.find(SideNavLink).last().prop('to')).toEqual('/settings/account'); + expect(wrapper.find(SideNavLink).last().prop('to')).toEqual('/settings'); }); it('renders the Credentials link', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index a04707ad48338..727312801c610 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -12,7 +12,7 @@ export const DOCS_PREFIX = docLinks.appSearchBase; export const ROOT_PATH = '/'; export const SETUP_GUIDE_PATH = '/setup_guide'; export const LIBRARY_PATH = '/library'; -export const SETTINGS_PATH = '/settings/account'; +export const SETTINGS_PATH = '/settings'; export const CREDENTIALS_PATH = '/credentials'; export const ROLE_MAPPINGS_PATH = '/role_mappings'; @@ -21,9 +21,7 @@ export const ROLE_MAPPING_NEW_PATH = `${ROLE_MAPPINGS_PATH}/new`; export const ENGINES_PATH = '/engines'; export const ENGINE_CREATION_PATH = '/engine_creation'; - export const ENGINE_PATH = `${ENGINES_PATH}/:engineName`; -export const SAMPLE_ENGINE_PATH = `${ENGINES_PATH}/national-parks-demo`; export const ENGINE_ANALYTICS_PATH = `${ENGINE_PATH}/analytics`; export const ENGINE_ANALYTICS_TOP_QUERIES_PATH = `${ENGINE_ANALYTICS_PATH}/top_queries`; @@ -48,11 +46,11 @@ export const META_ENGINE_SOURCE_ENGINES_PATH = `${ENGINE_PATH}/engines`; export const ENGINE_RELEVANCE_TUNING_PATH = `${ENGINE_PATH}/relevance_tuning`; export const ENGINE_SYNONYMS_PATH = `${ENGINE_PATH}/synonyms`; -export const ENGINE_RESULT_SETTINGS_PATH = `${ENGINE_PATH}/result-settings`; +export const ENGINE_RESULT_SETTINGS_PATH = `${ENGINE_PATH}/result_settings`; export const ENGINE_CURATIONS_PATH = `${ENGINE_PATH}/curations`; export const ENGINE_CURATIONS_NEW_PATH = `${ENGINE_CURATIONS_PATH}/new`; export const ENGINE_CURATION_PATH = `${ENGINE_CURATIONS_PATH}/:curationId`; -export const ENGINE_SEARCH_UI_PATH = `${ENGINE_PATH}/reference_application/new`; +export const ENGINE_SEARCH_UI_PATH = `${ENGINE_PATH}/search_ui`; export const ENGINE_API_LOGS_PATH = `${ENGINE_PATH}/api_logs`; 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/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index cdad8e07a88be..5d4b3cbe1af22 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -227,7 +227,7 @@ export const staticSourceData = [ } ), connectStepDescription: connectStepDescription.files, - objTypes: [SOURCE_OBJ_TYPES.ALL_FILES], + objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.ALL_FILES], features: { basicOrgContext: [ FeatureIds.SyncFrequency, @@ -383,7 +383,11 @@ export const staticSourceData = [ } ), connectStepDescription: connectStepDescription.files, - objTypes: [SOURCE_OBJ_TYPES.G_SUITE_FILES, SOURCE_OBJ_TYPES.ALL_STORED_FILES], + objTypes: [ + SOURCE_OBJ_TYPES.FOLDERS, + SOURCE_OBJ_TYPES.G_SUITE_FILES, + SOURCE_OBJ_TYPES.ALL_STORED_FILES, + ], features: { basicOrgContext: [ FeatureIds.SyncFrequency, @@ -510,7 +514,7 @@ export const staticSourceData = [ } ), connectStepDescription: connectStepDescription.files, - objTypes: [SOURCE_OBJ_TYPES.ALL_FILES], + objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.ALL_FILES], features: { basicOrgContext: [ FeatureIds.SyncFrequency, @@ -676,7 +680,7 @@ export const staticSourceData = [ } ), connectStepDescription: connectStepDescription.files, - objTypes: [SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES], + objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES], features: { basicOrgContext: [ FeatureIds.SyncFrequency, 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/file_upload/public/api/index.ts b/x-pack/plugins/file_upload/public/api/index.ts index 86b2d37967daa..c2520547ddad9 100644 --- a/x-pack/plugins/file_upload/public/api/index.ts +++ b/x-pack/plugins/file_upload/public/api/index.ts @@ -92,13 +92,17 @@ export async function checkIndexExists( ): Promise<boolean> { const body = JSON.stringify({ index }); const fileUploadModules = await lazyLoadModules(); - const { exists } = await fileUploadModules.getHttp().fetch<{ exists: boolean }>({ - path: `/internal/file_upload/index_exists`, - method: 'POST', - body, - query: params, - }); - return exists; + try { + const { exists } = await fileUploadModules.getHttp().fetch<{ exists: boolean }>({ + path: `/internal/file_upload/index_exists`, + method: 'POST', + body, + query: params, + }); + return exists; + } catch (error) { + return false; + } } export async function getTimeFieldRange(index: string, query: unknown, timeFieldName?: string) { diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx index 65866243a3e47..ddb0e7d9b2b22 100644 --- a/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { GeoJsonFilePicker, OnFileSelectParameters } from './geojson_file_picker'; import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; import { IndexNameForm } from './index_name_form'; -import { validateIndexName } from '../../util/indexing_service'; +import { validateIndexName } from '../../validate_index_name'; const GEO_FIELD_TYPE_OPTIONS = [ { @@ -32,6 +32,8 @@ interface Props { onFileSelect: (onFileSelectParameters: OnFileSelectParameters) => void; onGeoFieldTypeSelect: (geoFieldType: ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE) => void; onIndexNameChange: (name: string, error?: string) => void; + onIndexNameValidationStart: () => void; + onIndexNameValidationEnd: () => void; } interface State { @@ -40,11 +42,20 @@ interface State { } export class GeoJsonUploadForm extends Component<Props, State> { + private _isMounted = false; state: State = { hasFile: false, isPointsOnly: false, }; + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + _onFileSelect = async (onFileSelectParameters: OnFileSelectParameters) => { this.setState({ hasFile: true, @@ -53,7 +64,12 @@ export class GeoJsonUploadForm extends Component<Props, State> { this.props.onFileSelect(onFileSelectParameters); + this.props.onIndexNameValidationStart(); const indexNameError = await validateIndexName(onFileSelectParameters.indexName); + if (!this._isMounted) { + return; + } + this.props.onIndexNameValidationEnd(); this.props.onIndexNameChange(onFileSelectParameters.indexName, indexNameError); const geoFieldType = @@ -107,6 +123,8 @@ export class GeoJsonUploadForm extends Component<Props, State> { indexName={this.props.indexName} indexNameError={this.props.indexNameError} onIndexNameChange={this.props.onIndexNameChange} + onIndexNameValidationStart={this.props.onIndexNameValidationStart} + onIndexNameValidationEnd={this.props.onIndexNameValidationEnd} /> ) : null} </EuiForm> diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx index a6e83cfa6f3ab..0a70111e76b23 100644 --- a/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx @@ -5,23 +5,46 @@ * 2.0. */ +import _ from 'lodash'; import React, { ChangeEvent, Component } from 'react'; import { EuiFormRow, EuiFieldText, EuiCallOut, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { validateIndexName } from '../../util/indexing_service'; +import { validateIndexName } from '../../validate_index_name'; export interface Props { indexName: string; indexNameError?: string; onIndexNameChange: (name: string, error?: string) => void; + onIndexNameValidationStart: () => void; + onIndexNameValidationEnd: () => void; } export class IndexNameForm extends Component<Props> { - _onIndexNameChange = async (event: ChangeEvent<HTMLInputElement>) => { + private _isMounted = false; + + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + + _onIndexNameChange = (event: ChangeEvent<HTMLInputElement>) => { const indexName = event.target.value; + this.props.onIndexNameChange(indexName); + this._validateIndexName(indexName); + this.props.onIndexNameValidationStart(); + }; + + _validateIndexName = _.debounce(async (indexName: string) => { const indexNameError = await validateIndexName(indexName); + if (!this._isMounted || indexName !== this.props.indexName) { + return; + } + this.props.onIndexNameValidationEnd(); this.props.onIndexNameChange(indexName, indexNameError); - }; + }, 500); render() { const errors = [...(this.props.indexNameError ? [this.props.indexNameError] : [])]; diff --git a/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx b/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx index 5863b18d0cea0..28e99e7ffb18b 100644 --- a/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx +++ b/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx @@ -274,7 +274,11 @@ export class JsonUploadAndParse extends Component<FileUploadComponentProps, Stat }); const isReadyToImport = !!name && error === undefined; - this.props.onIndexReady(isReadyToImport); + if (isReadyToImport) { + this.props.enableImportBtn(); + } else { + this.props.disableImportBtn(); + } }; render() { @@ -309,6 +313,8 @@ export class JsonUploadAndParse extends Component<FileUploadComponentProps, Stat onFileSelect={this._onFileSelect} onGeoFieldTypeSelect={this._onGeoFieldTypeSelect} onIndexNameChange={this._onIndexNameChange} + onIndexNameValidationStart={this.props.disableImportBtn} + onIndexNameValidationEnd={this.props.enableImportBtn} /> ); } diff --git a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts index c2bc36e3cc450..b0f1b98a9ae72 100644 --- a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts @@ -24,7 +24,8 @@ export interface FileUploadComponentProps { isIndexingTriggered: boolean; onFileSelect: (geojsonFile: FeatureCollection, name: string, previewCoverage: number) => void; onFileClear: () => void; - onIndexReady: (indexReady: boolean) => void; + enableImportBtn: () => void; + disableImportBtn: () => void; onUploadComplete: (results: FileUploadGeoResults) => void; onUploadError: () => void; } diff --git a/x-pack/plugins/file_upload/public/util/indexing_service.ts b/x-pack/plugins/file_upload/public/util/indexing_service.ts deleted file mode 100644 index 4dcff3dbe7f0e..0000000000000 --- a/x-pack/plugins/file_upload/public/util/indexing_service.ts +++ /dev/null @@ -1,73 +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 _ from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { getIndexPatternService, getHttp } from '../kibana_services'; - -export const getExistingIndexNames = _.debounce( - async () => { - let indexes; - try { - indexes = await getHttp().fetch({ - path: `/api/index_management/indices`, - method: 'GET', - }); - } catch (e) { - // Log to console. Further diagnostics can be made in network request - // eslint-disable-next-line no-console - console.error(e); - } - return indexes ? indexes.map(({ name }: { name: string }) => name) : []; - }, - 10000, - { leading: true } -); - -export function checkIndexPatternValid(name: string) { - const byteLength = encodeURI(name).split(/%(?:u[0-9A-F]{2})?[0-9A-F]{2}|./).length - 1; - const reg = new RegExp('[\\\\/*?"<>|\\s,#]+'); - const indexPatternInvalid = - byteLength > 255 || // name can't be greater than 255 bytes - name !== name.toLowerCase() || // name should be lowercase - name === '.' || - name === '..' || // name can't be . or .. - name.match(/^[-_+]/) !== null || // name can't start with these chars - name.match(reg) !== null; // name can't contain these chars - return !indexPatternInvalid; -} - -export const validateIndexName = async (indexName: string) => { - if (!checkIndexPatternValid(indexName)) { - return i18n.translate( - 'xpack.fileUpload.util.indexingService.indexNameContainsIllegalCharactersErrorMessage', - { - defaultMessage: 'Index name contains illegal characters.', - } - ); - } - - const indexNames = await getExistingIndexNames(); - const indexPatternNames = await getIndexPatternService().getTitles(); - let indexNameError; - if (indexNames.includes(indexName)) { - indexNameError = i18n.translate( - 'xpack.fileUpload.util.indexingService.indexNameAlreadyExistsErrorMessage', - { - defaultMessage: 'Index name already exists.', - } - ); - } else if (indexPatternNames.includes(indexName)) { - indexNameError = i18n.translate( - 'xpack.fileUpload.util.indexingService.indexPatternAlreadyExistsErrorMessage', - { - defaultMessage: 'Index pattern already exists.', - } - ); - } - return indexNameError; -}; diff --git a/x-pack/plugins/file_upload/public/util/indexing_service.test.ts b/x-pack/plugins/file_upload/public/validate_index_name.test.ts similarity index 88% rename from x-pack/plugins/file_upload/public/util/indexing_service.test.ts rename to x-pack/plugins/file_upload/public/validate_index_name.test.ts index b8dfde9ccdc48..7422ced974e37 100644 --- a/x-pack/plugins/file_upload/public/util/indexing_service.test.ts +++ b/x-pack/plugins/file_upload/public/validate_index_name.test.ts @@ -5,12 +5,10 @@ * 2.0. */ -// Not all index pattern dependencies are avab. in jest context, -// prevent unrelated import errors by mocking kibana services -jest.mock('../kibana_services', () => {}); -import { checkIndexPatternValid } from './indexing_service'; +jest.mock('./kibana_services', () => {}); +import { checkIndexPatternValid } from './validate_index_name'; -describe('indexing_service', () => { +describe('checkIndexPatternValid', () => { const validNames = [ 'lowercaseletters', // Lowercase only '123', // Cannot include \, /, *, ?, ", <, >, |, " " (space character), , (comma), # diff --git a/x-pack/plugins/file_upload/public/validate_index_name.ts b/x-pack/plugins/file_upload/public/validate_index_name.ts new file mode 100644 index 0000000000000..cd190188b6a63 --- /dev/null +++ b/x-pack/plugins/file_upload/public/validate_index_name.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { getIndexPatternService } from './kibana_services'; +import { checkIndexExists } from './api'; + +export function checkIndexPatternValid(name: string) { + const byteLength = encodeURI(name).split(/%(?:u[0-9A-F]{2})?[0-9A-F]{2}|./).length - 1; + const reg = new RegExp('[\\\\/*?"<>|\\s,#]+'); + const indexPatternInvalid = + byteLength > 255 || // name can't be greater than 255 bytes + name !== name.toLowerCase() || // name should be lowercase + name === '.' || + name === '..' || // name can't be . or .. + name.match(/^[-_+]/) !== null || // name can't start with these chars + name.match(reg) !== null; // name can't contain these chars + return !indexPatternInvalid; +} + +export const validateIndexName = async (indexName: string) => { + if (!checkIndexPatternValid(indexName)) { + return i18n.translate('xpack.fileUpload.indexNameContainsIllegalCharactersErrorMessage', { + defaultMessage: 'Index name contains illegal characters.', + }); + } + + const indexPatternNames = await getIndexPatternService().getTitles(); + if (indexPatternNames.includes(indexName)) { + return i18n.translate('xpack.fileUpload.indexPatternAlreadyExistsErrorMessage', { + defaultMessage: 'Index pattern already exists.', + }); + } + + const indexExists = await checkIndexExists(indexName); + if (indexExists) { + return i18n.translate('xpack.fileUpload.indexNameAlreadyExistsErrorMessage', { + defaultMessage: 'Index name already exists.', + }); + } +}; diff --git a/x-pack/plugins/file_upload/server/routes.ts b/x-pack/plugins/file_upload/server/routes.ts index f2e796ec53ce0..8e6651ed891c6 100644 --- a/x-pack/plugins/file_upload/server/routes.ts +++ b/x-pack/plugins/file_upload/server/routes.ts @@ -195,7 +195,7 @@ export function fileUploadRoutes(coreSetup: CoreSetup<StartDeps, unknown>, logge body: schema.object({ index: schema.string() }), }, options: { - tags: ['access:fileUpload:analyzeFile'], + tags: ['access:fileUpload:import'], }, }, async (context, request, response) => { diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx index 0d4f067771be0..7a7e42b9d634f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx @@ -91,6 +91,27 @@ export const ManualInstructions: React.FunctionComponent<Props> = ({ }} /> </EuiText> + <EuiSpacer size="s" /> + <EuiText> + <FormattedMessage + id="xpack.fleet.enrollmentInstructions.troubleshootingText" + defaultMessage="If you are having trouble connecting, see our {link}." + values={{ + link: ( + <EuiLink + target="_blank" + external + href="https://www.elastic.co/guide/en/fleet/current/fleet-troubleshooting.html" + > + <FormattedMessage + id="xpack.fleet.enrollmentInstructions.troubleshootingLink" + defaultMessage="troubleshooting guide" + /> + </EuiLink> + ), + }} + /> + </EuiText> </> ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx index f3c353fd75dba..56f28ada004e2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx @@ -340,7 +340,7 @@ export const SettingFlyout: React.FunctionComponent<Props> = ({ onClose }) => { onClose={onConfirmModalClose} /> )} - <EuiFlyout onClose={onClose} size="l" maxWidth={640}> + <EuiFlyout onClose={onClose} size="m"> <EuiFlyoutHeader hasBorder aria-labelledby="IngestManagerSettingsFlyoutTitle"> <EuiTitle size="m"> <h2 id="IngestManagerSettingsFlyoutTitle"> diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx index 543819aca87a5..4ff5243483a3a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx @@ -7,7 +7,14 @@ import React from 'react'; import styled from 'styled-components'; -import { EuiTabs, EuiTab, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; +import { + EuiTabs, + EuiTab, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiPortal, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import type { Section } from '../sections'; @@ -58,11 +65,13 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({ return ( <> {modal === 'settings' && ( - <SettingFlyout - onClose={() => { - setModal(null); - }} - /> + <EuiPortal> + <SettingFlyout + onClose={() => { + setModal(null); + }} + /> + </EuiPortal> )} <Container> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index 88249f7f5d5ce..70cb6cddad5fa 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -481,10 +481,12 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { return ( <> {isEnrollmentFlyoutOpen ? ( - <AgentEnrollmentFlyout - agentPolicies={agentPolicies} - onClose={() => setIsEnrollmentFlyoutOpen(false)} - /> + <EuiPortal> + <AgentEnrollmentFlyout + agentPolicies={agentPolicies} + onClose={() => setIsEnrollmentFlyoutOpen(false)} + /> + </EuiPortal> ) : null} {agentToReassign && ( <EuiPortal> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx index a068426ecd23a..3be5d864e80c8 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx @@ -184,6 +184,27 @@ export const FleetServerCommandStep = ({ > <CommandCode>{installCommand}</CommandCode> </EuiCodeBlock> + <EuiSpacer size="s" /> + <EuiText> + <FormattedMessage + id="xpack.fleet.enrollmentInstructions.troubleshootingText" + defaultMessage="If you are having trouble connecting, see our {link}." + values={{ + link: ( + <EuiLink + target="_blank" + external + href="https://www.elastic.co/guide/en/fleet/current/fleet-troubleshooting.html" + > + <FormattedMessage + id="xpack.fleet.enrollmentInstructions.troubleshootingLink" + defaultMessage="troubleshooting guide" + /> + </EuiLink> + ), + }} + /> + </EuiText> </> ) : null, }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx index 8d639b48681e3..bcedb23b32d5d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx @@ -18,6 +18,7 @@ import { AgentPolicyPackageBadges } from '../agent_policy_package_badges'; type Props = { agentPolicies?: AgentPolicy[]; onAgentPolicyChange?: (key: string) => void; + excludeFleetServer?: boolean; } & ( | { withKeySelection: true; @@ -30,7 +31,7 @@ type Props = { export const EnrollmentStepAgentPolicy: React.FC<Props> = (props) => { const { notifications } = useStartServices(); - const { withKeySelection, agentPolicies, onAgentPolicyChange } = props; + const { withKeySelection, agentPolicies, onAgentPolicyChange, excludeFleetServer } = props; const onKeyChange = props.withKeySelection && props.onKeyChange; const [isAuthenticationSettingsOpen, setIsAuthenticationSettingsOpen] = useState(false); @@ -182,7 +183,10 @@ export const EnrollmentStepAgentPolicy: React.FC<Props> = (props) => { /> <EuiSpacer size="m" /> {selectedState.agentPolicyId && ( - <AgentPolicyPackageBadges agentPolicyId={selectedState.agentPolicyId} /> + <AgentPolicyPackageBadges + agentPolicyId={selectedState.agentPolicyId} + excludeFleetServer={excludeFleetServer} + /> )} {withKeySelection && onKeyChange && ( <> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx index 0ad1706e5273f..1aa88dcef4adc 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiFlyout, EuiFlyoutBody, @@ -37,9 +37,7 @@ interface Props { agentPolicies?: AgentPolicy[]; } -const MissingFleetServerHostCallout: React.FunctionComponent<{ onClose: () => void }> = ({ - onClose, -}) => { +const MissingFleetServerHostCallout: React.FunctionComponent = () => { const { setModal } = useUrlModal(); return ( <EuiCallOut @@ -70,7 +68,6 @@ const MissingFleetServerHostCallout: React.FunctionComponent<{ onClose: () => vo fill iconType="gear" onClick={() => { - onClose(); setModal('settings'); }} > @@ -89,11 +86,21 @@ export const AgentEnrollmentFlyout: React.FunctionComponent<Props> = ({ }) => { const [mode, setMode] = useState<'managed' | 'standalone'>('managed'); + const { modal } = useUrlModal(); + const [lastModal, setLastModal] = useState(modal); const settings = useGetSettings(); const fleetServerHosts = settings.data?.item?.fleet_server_hosts || []; + // Refresh settings when there is a modal/flyout change + useEffect(() => { + if (modal !== lastModal) { + settings.resendRequest(); + setLastModal(modal); + } + }, [modal, lastModal, settings]); + return ( - <EuiFlyout onClose={onClose} size="l" maxWidth={880}> + <EuiFlyout onClose={onClose} size="m"> <EuiFlyoutHeader hasBorder aria-labelledby="FleetAgentEnrollmentFlyoutTitle"> <EuiTitle size="m"> <h2 id="FleetAgentEnrollmentFlyoutTitle"> @@ -130,7 +137,7 @@ export const AgentEnrollmentFlyout: React.FunctionComponent<Props> = ({ <EuiFlyoutBody banner={ fleetServerHosts.length === 0 && mode === 'managed' ? ( - <MissingFleetServerHostCallout onClose={onClose} /> + <MissingFleetServerHostCallout /> ) : undefined } > diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/standalone_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/standalone_instructions.tsx index 7ccdfe05724f1..1d830b2c578b1 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/standalone_instructions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/standalone_instructions.tsx @@ -76,7 +76,7 @@ export const StandaloneInstructions = React.memo<Props>(({ agentPolicies }) => { const yaml = useMemo(() => fullAgentPolicyToYaml(fullAgentPolicy), [fullAgentPolicy]); const steps: EuiContainedStepProps[] = [ DownloadStep(), - AgentPolicySelectionStep({ agentPolicies, setSelectedPolicyId }), + AgentPolicySelectionStep({ agentPolicies, setSelectedPolicyId, excludeFleetServer: true }), { title: i18n.translate('xpack.fleet.agentEnrollment.stepConfigureAgentTitle', { defaultMessage: 'Configure the agent', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx index 08b1cbdb341d5..6a446e888a19f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx @@ -51,14 +51,19 @@ export const AgentPolicySelectionStep = ({ setSelectedAPIKeyId, setSelectedPolicyId, setIsFleetServerPolicySelected, + excludeFleetServer, }: { agentPolicies?: AgentPolicy[]; setSelectedAPIKeyId?: (key: string) => void; setSelectedPolicyId?: (policyId: string) => void; setIsFleetServerPolicySelected?: (selected: boolean) => void; + excludeFleetServer?: boolean; }) => { const regularAgentPolicies = Array.isArray(agentPolicies) - ? agentPolicies.filter((policy) => policy && !policy.is_managed) + ? agentPolicies.filter( + (policy) => + policy && !policy.is_managed && (!excludeFleetServer || !policy.is_default_fleet_server) + ) : []; const onAgentPolicyChange = useCallback( @@ -93,6 +98,7 @@ export const AgentPolicySelectionStep = ({ withKeySelection={setSelectedAPIKeyId ? true : false} onKeyChange={setSelectedAPIKeyId} onAgentPolicyChange={onAgentPolicyChange} + excludeFleetServer={excludeFleetServer} /> ), }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_policy_package_badges.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_policy_package_badges.tsx index cff0dc55515c4..89ac1b4f43b5f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_policy_package_badges.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_policy_package_badges.tsx @@ -6,8 +6,11 @@ */ import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiSpacer, EuiText, EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui'; +import { EuiSpacer, EuiText, EuiFlexGroup, EuiFlexItem, EuiBadge, EuiCallOut } from '@elastic/eui'; + +import { FLEET_SERVER_PACKAGE } from '../../../../../../common/constants'; import type { PackagePolicy, PackagePolicyPackage } from '../../../types'; import { useGetOneAgentPolicy } from '../../../hooks'; @@ -16,11 +19,13 @@ import { PackageIcon } from '../../../components/package_icon'; interface Props { agentPolicyId: string; hideTitle?: boolean; + excludeFleetServer?: boolean; } export const AgentPolicyPackageBadges: React.FunctionComponent<Props> = ({ agentPolicyId, hideTitle, + excludeFleetServer, }) => { const agentPolicyRequest = useGetOneAgentPolicy(agentPolicyId); const agentPolicy = agentPolicyRequest.data ? agentPolicyRequest.data.item : null; @@ -45,6 +50,19 @@ export const AgentPolicyPackageBadges: React.FunctionComponent<Props> = ({ return [...uniquePackages.values()]; }, [agentPolicy]); + const showFleetServerWarning = useMemo( + () => excludeFleetServer && packages?.some((pkg) => pkg.name === FLEET_SERVER_PACKAGE), + [packages, excludeFleetServer] + ); + + const collectedIntegrationsCount = useMemo( + () => + packages + ? packages.filter((pkg) => !excludeFleetServer || pkg.name !== FLEET_SERVER_PACKAGE).length + : 0, + [packages, excludeFleetServer] + ); + if (!agentPolicy || !packages) { return null; } @@ -58,8 +76,8 @@ export const AgentPolicyPackageBadges: React.FunctionComponent<Props> = ({ id="xpack.fleet.agentReassignPolicy.policyDescription" defaultMessage="The selected agent policy will collect data for {count, plural, one {{countValue} integration} other {{countValue} integrations}}:" values={{ - count: packages.length, - countValue: <b>{packages.length}</b>, + count: collectedIntegrationsCount, + countValue: <b>{collectedIntegrationsCount}</b>, }} /> </EuiText> @@ -68,7 +86,11 @@ export const AgentPolicyPackageBadges: React.FunctionComponent<Props> = ({ )} {packages.map((pkg, idx) => { return ( - <EuiBadge key={idx} color="hollow"> + <EuiBadge + key={idx} + color="hollow" + isDisabled={excludeFleetServer && pkg.name === FLEET_SERVER_PACKAGE} + > <EuiFlexGroup direction="row" gutterSize="xs" alignItems="center"> <EuiFlexItem grow={false}> <PackageIcon @@ -89,6 +111,22 @@ export const AgentPolicyPackageBadges: React.FunctionComponent<Props> = ({ </EuiBadge> ); })} + {showFleetServerWarning && ( + <> + <EuiSpacer size="s" /> + <EuiCallOut + size="s" + color="warning" + iconType="alert" + title={i18n.translate( + 'xpack.fleet.agentReassignPolicy.packageBadgeFleetServerWarning', + { + defaultMessage: 'Fleet Server will not be enabled in standalone mode.', + } + )} + /> + </> + )} </> ); }; diff --git a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts index aa36a3a7562bf..b408d0908e768 100644 --- a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts @@ -91,7 +91,7 @@ export const getListHandler: RequestHandler = async (context, request, response) allDashboards[pkgSavedObject.id] = dashboards; return allDashboards; }, {}); - const allDashboardSavedObjects = await context.core.savedObjects.client.bulkGet<{ + const allDashboardSavedObjectsResponse = await context.core.savedObjects.client.bulkGet<{ title?: string; }>( Object.values(dashboardIdsByPackageName).reduce<SavedObjectsBulkGetObject[]>( @@ -107,8 +107,19 @@ export const getListHandler: RequestHandler = async (context, request, response) [] ) ); + // Ignore dashboards not found + const allDashboardSavedObjects = allDashboardSavedObjectsResponse.saved_objects.filter((so) => { + if (so.error) { + if (so.error.statusCode === 404) { + return false; + } + throw so.error; + } + return true; + }); + const allDashboardSavedObjectsById = keyBy( - allDashboardSavedObjects.saved_objects, + allDashboardSavedObjects, (dashboardSavedObject) => dashboardSavedObject.id ); diff --git a/x-pack/plugins/fleet/server/services/settings.test.ts b/x-pack/plugins/fleet/server/services/settings.test.ts index a9f9600addc39..87b3e163c1bb3 100644 --- a/x-pack/plugins/fleet/server/services/settings.test.ts +++ b/x-pack/plugins/fleet/server/services/settings.test.ts @@ -17,7 +17,7 @@ describe('getCloudFleetServersHosts', () => { expect(getCloudFleetServersHosts()).toBeUndefined(); }); - it('should return fleet server hosts if cloud is correctly setup', () => { + it('should return fleet server hosts if cloud is correctly setup with default port == 443', () => { mockedAppContextService.getCloud.mockReturnValue({ cloudId: 'dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRjZWM2ZjI2MWE3NGJmMjRjZTMzYmI4ODExYjg0Mjk0ZiRjNmMyY2E2ZDA0MjI0OWFmMGNjN2Q3YTllOTYyNTc0Mw==', @@ -32,4 +32,20 @@ describe('getCloudFleetServersHosts', () => { ] `); }); + + it('should return fleet server hosts if cloud is correctly setup with a default port', () => { + mockedAppContextService.getCloud.mockReturnValue({ + cloudId: + 'test:dGVzdC5mcjo5MjQzJGRhM2I2YjNkYWY5ZDRjODE4ZjI4ZmEzNDdjMzgzODViJDgxMmY4NWMxZjNjZTQ2YTliYjgxZjFjMWIxMzRjNmRl', + isCloudEnabled: true, + deploymentId: 'deployment-id-1', + apm: {}, + }); + + expect(getCloudFleetServersHosts()).toMatchInlineSnapshot(` + Array [ + "https://deployment-id-1.fleet.test.fr:9243", + ] + `); + }); }); diff --git a/x-pack/plugins/fleet/server/services/settings.ts b/x-pack/plugins/fleet/server/services/settings.ts index 4ef9a3a95cbd0..2046e2571c926 100644 --- a/x-pack/plugins/fleet/server/services/settings.ts +++ b/x-pack/plugins/fleet/server/services/settings.ts @@ -84,6 +84,10 @@ export function getCloudFleetServersHosts() { } // Fleet Server url are formed like this `https://<deploymentId>.fleet.<host> - return [`https://${cloudSetup.deploymentId}.fleet.${res.host}`]; + return [ + `https://${cloudSetup.deploymentId}.fleet.${res.host}${ + res.defaultPort !== '443' ? `:${res.defaultPort}` : '' + }`, + ]; } } diff --git a/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx b/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx index 44f565f98cdb0..4bd9a01380c0e 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx @@ -7,7 +7,7 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFilterButton, EuiPopover, EuiFilterSelectItem } from '@elastic/eui'; +import { EuiFilterButton, EuiFilterGroup, EuiPopover, EuiFilterSelectItem } from '@elastic/eui'; interface Filter { name: string; @@ -65,26 +65,28 @@ export function FilterListButton<T extends string>({ onChange, filters }: Props< ); return ( - <EuiPopover - ownFocus - button={button} - isOpen={isPopoverOpen} - closePopover={closePopover} - panelPaddingSize="none" - data-test-subj="filterList" - > - <div className="euiFilterSelect__items"> - {Object.entries(filters).map(([filter, item], index) => ( - <EuiFilterSelectItem - checked={(item as Filter).checked} - key={index} - onClick={() => toggleFilter(filter as T)} - data-test-subj="filterItem" - > - {(item as Filter).name} - </EuiFilterSelectItem> - ))} - </div> - </EuiPopover> + <EuiFilterGroup> + <EuiPopover + ownFocus + button={button} + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + data-test-subj="filterList" + > + <div className="euiFilterSelect__items"> + {Object.entries(filters).map(([filter, item], index) => ( + <EuiFilterSelectItem + checked={(item as Filter).checked} + key={index} + onClick={() => toggleFilter(filter as T)} + data-test-subj="filterItem" + > + {(item as Filter).name} + </EuiFilterSelectItem> + ))} + </div> + </EuiPopover> + </EuiFilterGroup> ); } diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx index a481a3897789e..5023f9d5d5fd4 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx @@ -7,7 +7,7 @@ import React, { useMemo, useCallback, useEffect } from 'react'; import { noop } from 'lodash'; -import type { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; +import { DataPublicPluginStart, esQuery, Filter } from '../../../../../../src/plugins/data/public'; import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; import { LogEntryCursor } from '../../../common/log_entry'; @@ -19,6 +19,7 @@ import { ScrollableLogTextStreamView } from '../logging/log_text_stream'; import { LogColumnRenderConfiguration } from '../../utils/log_column_render_configuration'; import { JsonValue } from '../../../../../../src/plugins/kibana_utils/common'; import { Query } from '../../../../../../src/plugins/data/common'; +import { LogStreamErrorBoundary } from './log_stream_error_boundary'; interface LogStreamPluginDeps { data: DataPublicPluginStart; @@ -57,25 +58,39 @@ type LogColumnDefinition = | MessageColumnDefinition | FieldColumnDefinition; -export interface LogStreamProps { +export interface LogStreamProps extends LogStreamContentProps { + height?: string | number; +} + +interface LogStreamContentProps { sourceId?: string; startTimestamp: number; endTimestamp: number; query?: string | Query | BuiltEsQuery; + filters?: Filter[]; center?: LogEntryCursor; highlight?: string; - height?: string | number; columns?: LogColumnDefinition[]; } -export const LogStream: React.FC<LogStreamProps> = ({ +export const LogStream: React.FC<LogStreamProps> = ({ height = 400, ...contentProps }) => { + return ( + <LogStreamContainer style={{ height }}> + <LogStreamErrorBoundary resetOnChange={[contentProps.query]}> + <LogStreamContent {...contentProps} /> + </LogStreamErrorBoundary> + </LogStreamContainer> + ); +}; + +export const LogStreamContent: React.FC<LogStreamContentProps> = ({ sourceId = 'default', startTimestamp, endTimestamp, query, + filters, center, highlight, - height = '400px', columns, }) => { const customColumns = useMemo( @@ -99,12 +114,21 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re sourceConfiguration, loadSourceConfiguration, isLoadingSourceConfiguration, + derivedIndexPattern, } = useLogSource({ sourceId, fetch: services.http.fetch, indexPatternsService: services.data.indexPatterns, }); + const parsedQuery = useMemo<BuiltEsQuery | undefined>(() => { + if (typeof query === 'object' && 'bool' in query) { + return mergeBoolQueries(query, esQuery.buildEsQuery(derivedIndexPattern, [], filters ?? [])); + } else { + return esQuery.buildEsQuery(derivedIndexPattern, coerceToQueries(query), filters ?? []); + } + }, [derivedIndexPattern, filters, query]); + // Internal state const { entries, @@ -119,7 +143,7 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re sourceId, startTimestamp, endTimestamp, - query, + query: parsedQuery, center, columns: customColumns, }); @@ -138,8 +162,6 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re [entries] ); - const parsedHeight = typeof height === 'number' ? `${height}px` : height; - // Component lifetime useEffect(() => { loadSourceConfiguration(); @@ -170,37 +192,34 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re ); return ( - <LogStreamContent height={parsedHeight}> - <ScrollableLogTextStreamView - target={center ? center : entries.length ? entries[entries.length - 1].cursor : null} - columnConfigurations={columnConfigurations} - items={streamItems} - scale="medium" - wrap={true} - isReloading={isLoadingSourceConfiguration || isReloading} - isLoadingMore={isLoadingMore} - hasMoreBeforeStart={hasMoreBefore} - hasMoreAfterEnd={hasMoreAfter} - isStreaming={false} - jumpToTarget={noop} - reportVisibleInterval={handlePagination} - reloadItems={fetchEntries} - highlightedItem={highlight ?? null} - currentHighlightKey={null} - startDateExpression={''} - endDateExpression={''} - updateDateRange={noop} - startLiveStreaming={noop} - hideScrollbar={false} - /> - </LogStreamContent> + <ScrollableLogTextStreamView + target={center ? center : entries.length ? entries[entries.length - 1].cursor : null} + columnConfigurations={columnConfigurations} + items={streamItems} + scale="medium" + wrap={true} + isReloading={isLoadingSourceConfiguration || isReloading} + isLoadingMore={isLoadingMore} + hasMoreBeforeStart={hasMoreBefore} + hasMoreAfterEnd={hasMoreAfter} + isStreaming={false} + jumpToTarget={noop} + reportVisibleInterval={handlePagination} + reloadItems={fetchEntries} + highlightedItem={highlight ?? null} + currentHighlightKey={null} + startDateExpression={''} + endDateExpression={''} + updateDateRange={noop} + startLiveStreaming={noop} + hideScrollbar={false} + /> ); }; -const LogStreamContent = euiStyled.div<{ height: string }>` +const LogStreamContainer = euiStyled.div` display: flex; background-color: ${(props) => props.theme.eui.euiColorEmptyShade}; - height: ${(props) => props.height}; `; function convertLogColumnDefinitionToLogSourceColumnDefinition( @@ -227,6 +246,27 @@ function convertLogColumnDefinitionToLogSourceColumnDefinition( }); } +const mergeBoolQueries = (firstQuery: BuiltEsQuery, secondQuery: BuiltEsQuery): BuiltEsQuery => ({ + bool: { + must: [...firstQuery.bool.must, ...secondQuery.bool.must], + filter: [...firstQuery.bool.filter, ...secondQuery.bool.filter], + should: [...firstQuery.bool.should, ...secondQuery.bool.should], + must_not: [...firstQuery.bool.must_not, ...secondQuery.bool.must_not], + }, +}); + +const coerceToQueries = (value: undefined | string | Query): Query[] => { + if (value == null) { + return []; + } else if (typeof value === 'string') { + return [{ language: 'kuery', query: value }]; + } else if ('language' in value && 'query' in value) { + return [value]; + } + + return []; +}; + // Allow for lazy loading // eslint-disable-next-line import/no-default-export export default LogStream; diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx index 1d9edd7289236..e3fc4ca1de565 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx @@ -9,7 +9,7 @@ import { CoreStart } from 'kibana/public'; import React from 'react'; import ReactDOM from 'react-dom'; import { Subscription } from 'rxjs'; -import { Query, TimeRange, esQuery, Filter } from '../../../../../../src/plugins/data/public'; +import { Filter, Query, TimeRange } from '../../../../../../src/plugins/data/public'; import { Embeddable, EmbeddableInput, @@ -69,8 +69,6 @@ export class LogStreamEmbeddable extends Embeddable<LogStreamEmbeddableInput> { return; } - const parsedQuery = esQuery.buildEsQuery(undefined, this.input.query, this.input.filters); - const startTimestamp = datemathToEpochMillis(this.input.timeRange.from); const endTimestamp = datemathToEpochMillis(this.input.timeRange.to, 'up'); @@ -86,7 +84,8 @@ export class LogStreamEmbeddable extends Embeddable<LogStreamEmbeddableInput> { startTimestamp={startTimestamp} endTimestamp={endTimestamp} height="100%" - query={parsedQuery} + query={this.input.query} + filters={this.input.filters} /> </div> </EuiThemeProvider> diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream_error_boundary.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream_error_boundary.tsx new file mode 100644 index 0000000000000..c55e6d299127b --- /dev/null +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream_error_boundary.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 { EuiCodeBlock, EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { KQLSyntaxError } from '../../../../../../src/plugins/data/common'; +import { RenderErrorFunc, ResettableErrorBoundary } from '../resettable_error_boundary'; + +export const LogStreamErrorBoundary: React.FC<{ resetOnChange: any }> = ({ + children, + resetOnChange = null, +}) => { + return ( + <ResettableErrorBoundary + renderError={renderLogStreamErrorContent} + resetOnChange={resetOnChange} + > + {children} + </ResettableErrorBoundary> + ); +}; + +const LogStreamErrorContent: React.FC<{ + error: any; +}> = ({ error }) => { + if (error instanceof KQLSyntaxError) { + return ( + <EuiEmptyPrompt + title={ + <FormattedMessage + id="xpack.infra.logStream.kqlErrorTitle" + defaultMessage="Invalid KQL expression" + tagName="h2" + /> + } + body={<EuiCodeBlock className="eui-textLeft">{error.message}</EuiCodeBlock>} + /> + ); + } else { + return ( + <EuiEmptyPrompt + title={ + <FormattedMessage + id="xpack.infra.logStream.unknownErrorTitle" + defaultMessage="An error occurred" + tagName="h2" + /> + } + body={<EuiCodeBlock className="eui-textLeft">{error.message}</EuiCodeBlock>} + /> + ); + } +}; + +const renderLogStreamErrorContent: RenderErrorFunc = ({ latestError }) => ( + <LogStreamErrorContent error={latestError} /> +); diff --git a/x-pack/plugins/infra/public/components/resettable_error_boundary.tsx b/x-pack/plugins/infra/public/components/resettable_error_boundary.tsx new file mode 100644 index 0000000000000..6e9dc178a4d84 --- /dev/null +++ b/x-pack/plugins/infra/public/components/resettable_error_boundary.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import equal from 'fast-deep-equal'; +import React from 'react'; + +export interface RenderErrorFuncArgs { + latestError: any; + resetError: () => void; +} + +export type RenderErrorFunc = (renderErrorArgs: RenderErrorFuncArgs) => React.ReactNode; + +interface ResettableErrorBoundaryProps<ResetOnChange> { + renderError: RenderErrorFunc; + resetOnChange: ResetOnChange; +} + +interface ResettableErrorBoundaryState { + latestError: any; +} + +export class ResettableErrorBoundary<ResetOnChange> extends React.Component< + ResettableErrorBoundaryProps<ResetOnChange>, + ResettableErrorBoundaryState +> { + state: ResettableErrorBoundaryState = { + latestError: undefined, + }; + + componentDidUpdate({ + resetOnChange: prevResetOnChange, + }: ResettableErrorBoundaryProps<ResetOnChange>) { + const { resetOnChange } = this.props; + const { latestError } = this.state; + + if (latestError != null && !equal(resetOnChange, prevResetOnChange)) { + this.resetError(); + } + } + + static getDerivedStateFromError(error: any) { + return { + latestError: error, + }; + } + + render() { + const { children, renderError } = this.props; + const { latestError } = this.state; + + if (latestError != null) { + return renderError({ + latestError, + resetError: this.resetError, + }); + } + + return children; + } + + resetError = () => { + this.setState((previousState) => ({ + ...previousState, + latestError: undefined, + })); + }; +} diff --git a/x-pack/plugins/infra/public/containers/logs/log_filter/log_filter_state.ts b/x-pack/plugins/infra/public/containers/logs/log_filter/log_filter_state.ts index 5eece62c683e1..6a78d7c6f94bc 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_filter/log_filter_state.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_filter/log_filter_state.ts @@ -5,95 +5,100 @@ * 2.0. */ -import { useState, useMemo } from 'react'; import createContainer from 'constate'; -import { IIndexPattern } from 'src/plugins/data/public'; -import { esKuery } from '../../../../../../../src/plugins/data/public'; -import { convertKueryToElasticSearchQuery } from '../../../utils/kuery'; +import { useCallback, useState } from 'react'; +import { useDebounce } from 'react-use'; +import { esQuery, IIndexPattern, Query } from '../../../../../../../src/plugins/data/public'; -export interface KueryFilterQuery { - kind: 'kuery'; - expression: string; -} - -export interface SerializedFilterQuery { - query: KueryFilterQuery; - serializedQuery: string; -} +type ParsedQuery = ReturnType<typeof esQuery.buildEsQuery>; -interface LogFilterInternalStateParams { - filterQuery: SerializedFilterQuery | null; - filterQueryDraft: KueryFilterQuery | null; +interface ILogFilterState { + filterQuery: { + parsedQuery: ParsedQuery; + serializedQuery: string; + originalQuery: Query; + } | null; + filterQueryDraft: Query; + validationErrors: string[]; } -export const logFilterInitialState: LogFilterInternalStateParams = { +const initialLogFilterState: ILogFilterState = { filterQuery: null, - filterQueryDraft: null, + filterQueryDraft: { + language: 'kuery', + query: '', + }, + validationErrors: [], }; -export type LogFilterStateParams = Omit<LogFilterInternalStateParams, 'filterQuery'> & { - filterQuery: SerializedFilterQuery['serializedQuery'] | null; - filterQueryAsKuery: SerializedFilterQuery['query'] | null; - isFilterQueryDraftValid: boolean; -}; -export interface LogFilterCallbacks { - setLogFilterQueryDraft: (expression: string) => void; - applyLogFilterQuery: (expression: string) => void; -} +const validationDebounceTimeout = 1000; // milliseconds -export const useLogFilterState: (props: { - indexPattern: IIndexPattern; -}) => LogFilterStateParams & LogFilterCallbacks = ({ indexPattern }) => { - const [state, setState] = useState(logFilterInitialState); - const { filterQuery, filterQueryDraft } = state; +export const useLogFilterState = ({ indexPattern }: { indexPattern: IIndexPattern }) => { + const [logFilterState, setLogFilterState] = useState<ILogFilterState>(initialLogFilterState); - const setLogFilterQueryDraft = useMemo(() => { - const setDraft = (payload: KueryFilterQuery) => - setState((prevState) => ({ ...prevState, filterQueryDraft: payload })); - return (expression: string) => - setDraft({ - kind: 'kuery', - expression, - }); + const parseQuery = useCallback( + (filterQuery: Query) => esQuery.buildEsQuery(indexPattern, filterQuery, []), + [indexPattern] + ); + + const setLogFilterQueryDraft = useCallback((filterQueryDraft: Query) => { + setLogFilterState((previousLogFilterState) => ({ + ...previousLogFilterState, + filterQueryDraft, + validationErrors: [], + })); }, []); - const applyLogFilterQuery = useMemo(() => { - const applyQuery = (payload: SerializedFilterQuery) => - setState((prevState) => ({ - ...prevState, - filterQueryDraft: payload.query, - filterQuery: payload, - })); - return (expression: string) => - applyQuery({ - query: { - kind: 'kuery', - expression, - }, - serializedQuery: convertKueryToElasticSearchQuery(expression, indexPattern), + + const [, cancelPendingValidation] = useDebounce( + () => { + setLogFilterState((previousLogFilterState) => { + try { + parseQuery(logFilterState.filterQueryDraft); + return { + ...previousLogFilterState, + validationErrors: [], + }; + } catch (error) { + return { + ...previousLogFilterState, + validationErrors: [`${error}`], + }; + } }); - }, [indexPattern]); + }, + validationDebounceTimeout, + [logFilterState.filterQueryDraft, parseQuery] + ); - const isFilterQueryDraftValid = useMemo(() => { - if (filterQueryDraft?.kind === 'kuery') { + const applyLogFilterQuery = useCallback( + (filterQuery: Query) => { + cancelPendingValidation(); try { - esKuery.fromKueryExpression(filterQueryDraft.expression); - } catch (err) { - return false; + const parsedQuery = parseQuery(filterQuery); + setLogFilterState((previousLogFilterState) => ({ + ...previousLogFilterState, + filterQuery: { + parsedQuery, + serializedQuery: JSON.stringify(parsedQuery), + originalQuery: filterQuery, + }, + filterQueryDraft: filterQuery, + validationErrors: [], + })); + } catch (error) { + setLogFilterState((previousLogFilterState) => ({ + ...previousLogFilterState, + validationErrors: [`${error}`], + })); } - } - - return true; - }, [filterQueryDraft]); - - const serializedFilterQuery = useMemo(() => (filterQuery ? filterQuery.serializedQuery : null), [ - filterQuery, - ]); + }, + [cancelPendingValidation, parseQuery] + ); return { - ...state, - filterQueryAsKuery: state.filterQuery ? state.filterQuery.query : null, - filterQuery: serializedFilterQuery, - isFilterQueryDraftValid, + filterQuery: logFilterState.filterQuery, + filterQueryDraft: logFilterState.filterQueryDraft, + isFilterQueryDraftValid: logFilterState.validationErrors.length === 0, setLogFilterQueryDraft, applyLogFilterQuery, }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_filter/with_log_filter_url_state.tsx b/x-pack/plugins/infra/public/containers/logs/log_filter/with_log_filter_url_state.tsx index 6bc71fc880434..f085a2c7d275b 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_filter/with_log_filter_url_state.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_filter/with_log_filter_url_state.tsx @@ -5,43 +5,57 @@ * 2.0. */ +import * as rt from 'io-ts'; import React, { useContext } from 'react'; -import { LogFilterState, LogFilterStateParams } from './log_filter_state'; +import { Query } from '../../../../../../../src/plugins/data/public'; import { replaceStateKeyInQueryString, UrlStateContainer } from '../../../utils/url_state'; - -type LogFilterUrlState = LogFilterStateParams['filterQueryAsKuery']; +import { LogFilterState } from './log_filter_state'; export const WithLogFilterUrlState: React.FC = () => { - const { filterQueryAsKuery, applyLogFilterQuery } = useContext(LogFilterState.Context); + const { filterQuery, applyLogFilterQuery } = useContext(LogFilterState.Context); + return ( <UrlStateContainer - urlState={filterQueryAsKuery} + urlState={filterQuery?.originalQuery} urlStateKey="logFilter" mapToUrlState={mapToFilterQuery} onChange={(urlState) => { if (urlState) { - applyLogFilterQuery(urlState.expression); + applyLogFilterQuery(urlState); } }} onInitialize={(urlState) => { if (urlState) { - applyLogFilterQuery(urlState.expression); + applyLogFilterQuery(urlState); } }} /> ); }; -const mapToFilterQuery = (value: any): LogFilterUrlState | undefined => - value?.kind === 'kuery' && typeof value.expression === 'string' - ? { - kind: value.kind, - expression: value.expression, - } - : undefined; +const mapToFilterQuery = (value: any): Query | undefined => { + if (legacyFilterQueryUrlStateRT.is(value)) { + // migrate old url state + return { + language: value.kind, + query: value.expression, + }; + } else if (filterQueryUrlStateRT.is(value)) { + return value; + } else { + return undefined; + } +}; + +export const replaceLogFilterInQueryString = (query: Query) => + replaceStateKeyInQueryString<Query>('logFilter', query); + +const filterQueryUrlStateRT = rt.type({ + language: rt.string, + query: rt.string, +}); -export const replaceLogFilterInQueryString = (expression: string) => - replaceStateKeyInQueryString<LogFilterUrlState>('logFilter', { - kind: 'kuery', - expression, - }); +const legacyFilterQueryUrlStateRT = rt.type({ + kind: rt.literal('kuery'), + expression: rt.string, +}); diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts index cd68048e6c94f..021aa8f79fe59 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { useState, useCallback, useEffect, useMemo } from 'react'; import createContainer from 'constate'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import usePrevious from 'react-use/lib/usePrevious'; import useSetState from 'react-use/lib/useSetState'; -import { esKuery, esQuery, Query } from '../../../../../../../src/plugins/data/public'; +import { esQuery } from '../../../../../../../src/plugins/data/public'; import { LogEntry, LogEntryCursor } from '../../../../common/log_entry'; import { useSubscription } from '../../../utils/use_observable'; import { LogSourceConfigurationProperties } from '../log_source'; @@ -23,7 +23,7 @@ interface LogStreamProps { sourceId: string; startTimestamp: number; endTimestamp: number; - query?: string | Query | BuiltEsQuery; + query?: BuiltEsQuery; center?: LogEntryCursor; columns?: LogSourceConfigurationProperties['logColumns']; } @@ -77,27 +77,15 @@ export function useLogStream({ } }, [prevEndTimestamp, endTimestamp, setState]); - const parsedQuery = useMemo(() => { - if (!query) { - return undefined; - } else if (typeof query === 'string') { - return esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query)); - } else if ('language' in query) { - return getEsQueryFromQueryObject(query); - } else { - return query; - } - }, [query]); - const commonFetchArguments = useMemo( () => ({ sourceId, startTimestamp, endTimestamp, - query: parsedQuery, + query, columnOverrides: columns, }), - [columns, endTimestamp, parsedQuery, sourceId, startTimestamp] + [columns, endTimestamp, query, sourceId, startTimestamp] ); const { @@ -268,13 +256,4 @@ export function useLogStream({ }; } -function getEsQueryFromQueryObject(query: Query) { - switch (query.language) { - case 'kuery': - return esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query.query as string)); - case 'lucene': - return esQuery.luceneStringToDsl(query.query as string); - } -} - export const [LogStreamProvider, useLogStreamContext] = createContainer(useLogStream); diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts index cbd664729d5c2..9204c81816e83 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts @@ -7,12 +7,11 @@ import { useContext } from 'react'; import useThrottle from 'react-use/lib/useThrottle'; - import { RendererFunction } from '../../../utils/typed_react'; -import { LogSummaryBuckets, useLogSummary } from './log_summary'; import { LogFilterState } from '../log_filter'; import { LogPositionState } from '../log_position'; import { useLogSourceContext } from '../log_source'; +import { LogSummaryBuckets, useLogSummary } from './log_summary'; const FETCH_THROTTLE_INTERVAL = 3000; @@ -37,7 +36,7 @@ export const WithSummary = ({ sourceId, throttledStartTimestamp, throttledEndTimestamp, - filterQuery + filterQuery?.serializedQuery ?? null ); return children({ buckets, start, end }); diff --git a/x-pack/plugins/infra/public/pages/link_to/link_to_logs.test.tsx b/x-pack/plugins/infra/public/pages/link_to/link_to_logs.test.tsx index 0d57f8dad1e72..91f42509d493a 100644 --- a/x-pack/plugins/infra/public/pages/link_to/link_to_logs.test.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/link_to_logs.test.tsx @@ -66,7 +66,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'FILTER_FIELD:FILTER_VALUE',kind:kuery)"` + `"(language:kuery,query:'FILTER_FIELD:FILTER_VALUE')"` ); expect(searchParams.get('logPosition')).toMatchInlineSnapshot( `"(end:'2019-02-20T14:58:09.404Z',position:(tiebreaker:0,time:1550671089404),start:'2019-02-20T12:58:09.404Z',streamLive:!f)"` @@ -86,7 +86,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('OTHER_SOURCE'); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot(`"(expression:'',kind:kuery)"`); + expect(searchParams.get('logFilter')).toMatchInlineSnapshot(`"(language:kuery,query:'')"`); expect(searchParams.get('logPosition')).toEqual(null); }); }); @@ -106,7 +106,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'FILTER_FIELD:FILTER_VALUE',kind:kuery)"` + `"(language:kuery,query:'FILTER_FIELD:FILTER_VALUE')"` ); expect(searchParams.get('logPosition')).toMatchInlineSnapshot( `"(end:'2019-02-20T14:58:09.404Z',position:(tiebreaker:0,time:1550671089404),start:'2019-02-20T12:58:09.404Z',streamLive:!f)"` @@ -126,7 +126,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('OTHER_SOURCE'); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot(`"(expression:'',kind:kuery)"`); + expect(searchParams.get('logFilter')).toMatchInlineSnapshot(`"(language:kuery,query:'')"`); expect(searchParams.get('logPosition')).toEqual(null); }); }); @@ -146,7 +146,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'HOST_FIELD: HOST_NAME',kind:kuery)"` + `"(language:kuery,query:'HOST_FIELD: HOST_NAME')"` ); expect(searchParams.get('logPosition')).toEqual(null); }); @@ -167,7 +167,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'(HOST_FIELD: HOST_NAME) and (FILTER_FIELD:FILTER_VALUE)',kind:kuery)"` + `"(language:kuery,query:'(HOST_FIELD: HOST_NAME) and (FILTER_FIELD:FILTER_VALUE)')"` ); expect(searchParams.get('logPosition')).toMatchInlineSnapshot( `"(end:'2019-02-20T14:58:09.404Z',position:(tiebreaker:0,time:1550671089404),start:'2019-02-20T12:58:09.404Z',streamLive:!f)"` @@ -188,7 +188,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('OTHER_SOURCE'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'HOST_FIELD: HOST_NAME',kind:kuery)"` + `"(language:kuery,query:'HOST_FIELD: HOST_NAME')"` ); expect(searchParams.get('logPosition')).toEqual(null); }); @@ -223,7 +223,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'CONTAINER_FIELD: CONTAINER_ID',kind:kuery)"` + `"(language:kuery,query:'CONTAINER_FIELD: CONTAINER_ID')"` ); expect(searchParams.get('logPosition')).toEqual(null); }); @@ -244,7 +244,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'(CONTAINER_FIELD: CONTAINER_ID) and (FILTER_FIELD:FILTER_VALUE)',kind:kuery)"` + `"(language:kuery,query:'(CONTAINER_FIELD: CONTAINER_ID) and (FILTER_FIELD:FILTER_VALUE)')"` ); expect(searchParams.get('logPosition')).toMatchInlineSnapshot( `"(end:'2019-02-20T14:58:09.404Z',position:(tiebreaker:0,time:1550671089404),start:'2019-02-20T12:58:09.404Z',streamLive:!f)"` @@ -281,7 +281,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'POD_FIELD: POD_UID',kind:kuery)"` + `"(language:kuery,query:'POD_FIELD: POD_UID')"` ); expect(searchParams.get('logPosition')).toEqual(null); }); @@ -300,7 +300,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'(POD_FIELD: POD_UID) and (FILTER_FIELD:FILTER_VALUE)',kind:kuery)"` + `"(language:kuery,query:'(POD_FIELD: POD_UID) and (FILTER_FIELD:FILTER_VALUE)')"` ); expect(searchParams.get('logPosition')).toMatchInlineSnapshot( `"(end:'2019-02-20T14:58:09.404Z',position:(tiebreaker:0,time:1550671089404),start:'2019-02-20T12:58:09.404Z',streamLive:!f)"` diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.test.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.test.tsx index a3e261a6bc280..39f276b982d76 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.test.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.test.tsx @@ -20,7 +20,7 @@ describe('RedirectToLogs component', () => { expect(component).toMatchInlineSnapshot(` <Redirect - to="/stream?sourceId=default&logPosition=(end:'2019-02-20T14:58:09.404Z',position:(tiebreaker:0,time:1550671089404),start:'2019-02-20T12:58:09.404Z',streamLive:!f)&logFilter=(expression:'',kind:kuery)" + to="/stream?sourceId=default&logPosition=(end:'2019-02-20T14:58:09.404Z',position:(tiebreaker:0,time:1550671089404),start:'2019-02-20T12:58:09.404Z',streamLive:!f)&logFilter=(language:kuery,query:'')" /> `); }); @@ -34,7 +34,7 @@ describe('RedirectToLogs component', () => { expect(component).toMatchInlineSnapshot(` <Redirect - to="/stream?sourceId=default&logPosition=(end:'2019-02-20T14:58:09.404Z',position:(tiebreaker:0,time:1550671089404),start:'2019-02-20T12:58:09.404Z',streamLive:!f)&logFilter=(expression:'FILTER_FIELD:FILTER_VALUE',kind:kuery)" + to="/stream?sourceId=default&logPosition=(end:'2019-02-20T14:58:09.404Z',position:(tiebreaker:0,time:1550671089404),start:'2019-02-20T12:58:09.404Z',streamLive:!f)&logFilter=(language:kuery,query:'FILTER_FIELD:FILTER_VALUE')" /> `); }); @@ -46,7 +46,7 @@ describe('RedirectToLogs component', () => { expect(component).toMatchInlineSnapshot(` <Redirect - to="/stream?sourceId=SOME-OTHER-SOURCE&logFilter=(expression:'',kind:kuery)" + to="/stream?sourceId=SOME-OTHER-SOURCE&logFilter=(language:kuery,query:'')" /> `); }); diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx index 9606f343dbfdf..4d77077c19a99 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx @@ -26,7 +26,7 @@ export const RedirectToLogs = ({ location, match }: RedirectToLogsProps) => { const sourceId = match.params.sourceId || 'default'; const filter = getFilterFromLocation(location); const searchString = flowRight( - replaceLogFilterInQueryString(filter), + replaceLogFilterInQueryString({ language: 'kuery', query: filter }), replaceLogPositionInQueryString(getTimeFromLocation(location)), replaceSourceIdInQueryString(sourceId) )(''); diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx index 741fad5a5310e..0df8e639b149b 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx @@ -68,7 +68,7 @@ export const RedirectToNodeLogs = ({ const filter = userFilter ? `(${nodeFilter}) and (${userFilter})` : nodeFilter; const searchString = flowRight( - replaceLogFilterInQueryString(filter), + replaceLogFilterInQueryString({ language: 'kuery', query: filter }), replaceLogPositionInQueryString(getTimeFromLocation(location)), replaceSourceIdInQueryString(sourceId) )(''); diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx index d987cbeb439cc..27235295013e3 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx @@ -50,28 +50,30 @@ const LogEntriesStateProvider: React.FC = ({ children }) => { const { startTimestamp, endTimestamp, targetPosition, isInitialized } = useContext( LogPositionState.Context ); - const { filterQueryAsKuery } = useContext(LogFilterState.Context); + const { filterQuery } = useContext(LogFilterState.Context); // Don't render anything if the date range is incorrect. if (!startTimestamp || !endTimestamp) { return null; } - const logStreamProps = { - sourceId, - startTimestamp, - endTimestamp, - query: filterQueryAsKuery?.expression ?? undefined, - center: targetPosition ?? undefined, - }; - // Don't initialize the entries until the position has been fully intialized. // See `<WithLogPositionUrlState />` if (!isInitialized) { return null; } - return <LogStreamProvider {...logStreamProps}>{children}</LogStreamProvider>; + return ( + <LogStreamProvider + sourceId={sourceId} + startTimestamp={startTimestamp} + endTimestamp={endTimestamp} + query={filterQuery?.parsedQuery} + center={targetPosition ?? undefined} + > + {children} + </LogStreamProvider> + ); }; const LogHighlightsStateProvider: React.FC = ({ children }) => { @@ -86,7 +88,7 @@ const LogHighlightsStateProvider: React.FC = ({ children }) => { entriesEnd: bottomCursor, centerCursor: entries.length > 0 ? entries[Math.floor(entries.length / 2)].cursor : null, size: entries.length, - filterQuery, + filterQuery: filterQuery?.serializedQuery ?? null, }; return <LogHighlightsState.Provider {...highlightsProps}>{children}</LogHighlightsState.Provider>; }; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx index 971eb1b3e486f..fc37cd2e11c1b 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx @@ -62,25 +62,18 @@ export const LogsToolbar = () => { iconType="search" indexPatterns={[derivedIndexPattern]} isInvalid={!isFilterQueryDraftValid} - onChange={(expression: Query) => { - if (typeof expression.query === 'string') { - setSurroundingLogsId(null); - setLogFilterQueryDraft(expression.query); - } + onChange={(query: Query) => { + setSurroundingLogsId(null); + setLogFilterQueryDraft(query); }} - onSubmit={(expression: Query) => { - if (typeof expression.query === 'string') { - setSurroundingLogsId(null); - applyLogFilterQuery(expression.query); - } + onSubmit={(query: Query) => { + setSurroundingLogsId(null); + applyLogFilterQuery(query); }} placeholder={i18n.translate('xpack.infra.logsPage.toolbar.kqlSearchFieldPlaceholder', { defaultMessage: 'Search for log entries… (e.g. host.name:host-1)', })} - query={{ - query: filterQueryDraft?.expression ?? '', - language: filterQueryDraft?.kind ?? 'kuery', - }} + query={filterQueryDraft} /> </EuiFlexItem> <EuiFlexItem grow={false}> 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/components/node_details/tabs/logs.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx index 71bf9e50c4bb6..4fa9fdf8cdd4a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx @@ -6,6 +6,7 @@ */ import React, { useCallback, useMemo, useState } from 'react'; +import { useThrottle } from 'react-use'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiFieldSearch } from '@elastic/eui'; @@ -26,16 +27,21 @@ const TabComponent = (props: TabProps) => { const { nodeType } = useWaffleOptionsContext(); const { options, node } = props; + const throttledTextQuery = useThrottle(textQuery, textQueryThrottleInterval); + const filter = useMemo(() => { - let query = options.fields - ? `${findInventoryFields(nodeType, options.fields).id}: "${node.id}"` - : ``; + const query = [ + ...(options.fields != null + ? [`${findInventoryFields(nodeType, options.fields).id}: "${node.id}"`] + : []), + ...(throttledTextQuery !== '' ? [throttledTextQuery] : []), + ].join(' and '); - if (textQuery) { - query += ` and message: ${textQuery}`; - } - return query; - }, [options, nodeType, node.id, textQuery]); + return { + language: 'kuery', + query, + }; + }, [options.fields, nodeType, node.id, throttledTextQuery]); const onQueryChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { setTextQuery(e.target.value); @@ -89,3 +95,5 @@ export const LogsTab = { }), content: TabComponent, }; + +const textQueryThrottleInterval = 1000; // milliseconds 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/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts index 6689aedcd7209..95f98e172541a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts @@ -7,7 +7,7 @@ import DateMath from '@elastic/datemath'; import { isEqual } from 'lodash'; -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState } from 'react'; import { IIndexPattern } from 'src/plugins/data/public'; import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; import { @@ -18,6 +18,7 @@ import { convertKueryToElasticSearchQuery } from '../../../../utils/kuery'; import { MetricsExplorerOptions, MetricsExplorerTimeOptions } from './use_metrics_explorer_options'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { decodeOrThrow } from '../../../../../common/runtime_types'; +import { useTrackedPromise } from '../../../../utils/use_tracked_promise'; function isSameOptions(current: MetricsExplorerOptions, next: MetricsExplorerOptions) { return isEqual(current, next); @@ -40,52 +41,58 @@ export function useMetricsExplorerData( const [lastOptions, setLastOptions] = useState<MetricsExplorerOptions | null>(null); const [lastTimerange, setLastTimerange] = useState<MetricsExplorerTimeOptions | null>(null); - const loadData = useCallback(() => { - (async () => { - setLoading(true); - try { - const from = DateMath.parse(timerange.from); - const to = DateMath.parse(timerange.to, { roundUp: true }); + const from = DateMath.parse(timerange.from); + const to = DateMath.parse(timerange.to, { roundUp: true }); + const [, makeRequest] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: () => { + setLoading(true); if (!from || !to) { - throw new Error('Unalble to parse timerange'); + return Promise.reject(new Error('Unalble to parse timerange')); } if (!fetchFn) { - throw new Error('HTTP service is unavailable'); + return Promise.reject(new Error('HTTP service is unavailable')); } if (!source) { - throw new Error('Source is unavailable'); + return Promise.reject(new Error('Source is unavailable')); + } + if (!fetchFn) { + return Promise.reject(new Error('HTTP service is unavailable')); } - const response = decodeOrThrow(metricsExplorerResponseRT)( - await fetchFn('/api/infra/metrics_explorer', { - method: 'POST', - body: JSON.stringify({ - forceInterval: options.forceInterval, - dropLastBucket: options.dropLastBucket != null ? options.dropLastBucket : true, - metrics: - options.aggregation === 'count' - ? [{ aggregation: 'count' }] - : options.metrics.map((metric) => ({ - aggregation: metric.aggregation, - field: metric.field, - })), - groupBy: options.groupBy, - afterKey, - limit: options.limit, - indexPattern: source.metricAlias, - filterQuery: - (options.filterQuery && - convertKueryToElasticSearchQuery(options.filterQuery, derivedIndexPattern)) || - void 0, - timerange: { - ...timerange, - field: source.fields.timestamp, - from: from.valueOf(), - to: to.valueOf(), - }, - }), - }) - ); + return fetchFn('/api/infra/metrics_explorer', { + method: 'POST', + body: JSON.stringify({ + forceInterval: options.forceInterval, + dropLastBucket: options.dropLastBucket != null ? options.dropLastBucket : true, + metrics: + options.aggregation === 'count' + ? [{ aggregation: 'count' }] + : options.metrics.map((metric) => ({ + aggregation: metric.aggregation, + field: metric.field, + })), + groupBy: options.groupBy, + afterKey, + limit: options.limit, + indexPattern: source.metricAlias, + filterQuery: + (options.filterQuery && + convertKueryToElasticSearchQuery(options.filterQuery, derivedIndexPattern)) || + void 0, + timerange: { + ...timerange, + field: source.fields.timestamp, + from: from.valueOf(), + to: to.valueOf(), + }, + }), + }); + }, + onResolve: (resp: unknown) => { + setLoading(false); + const response = decodeOrThrow(metricsExplorerResponseRT)(resp); if (response) { if ( data && @@ -107,20 +114,20 @@ export function useMetricsExplorerData( setLastTimerange(timerange); setError(null); } - } catch (e) { - setError(e); - } - setLoading(false); - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [options, source, timerange, signal, afterKey]); + }, + onReject: (e: unknown) => { + setError(e as Error); + setLoading(false); + }, + }, + [source, timerange, options, signal, afterKey] + ); useEffect(() => { if (!shouldLoadImmediately) { return; } - - loadData(); - }, [loadData, shouldLoadImmediately]); - return { error, loading, data, loadData }; + makeRequest(); + }, [makeRequest, shouldLoadImmediately]); + return { error, loading, data, loadData: makeRequest }; } 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/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 007368f0997df..0d8930bdb75b8 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -295,7 +295,7 @@ export enum DATA_MAPPING_FUNCTION { } export const DEFAULT_PERCENTILES = [50, 75, 90, 95, 99]; -export type RawValue = string | number | boolean | undefined | null; +export type RawValue = string | string[] | number | boolean | undefined | null; export type FieldFormatter = (value: RawValue) => string | number; diff --git a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts index 197b7f49eda0a..c18a79fa9dcbc 100644 --- a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts +++ b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts @@ -371,7 +371,7 @@ export function createSpatialFilterWithGeometry({ geoFieldName, relation = ES_SPATIAL_RELATIONS.INTERSECTS, }: { - preIndexedShape?: PreIndexedShape; + preIndexedShape?: PreIndexedShape | null; geometry: Polygon; geometryLabel: string; indexPatternId: string; diff --git a/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx b/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx index 79902cf620511..7d6f6757bef18 100644 --- a/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx @@ -26,14 +26,14 @@ export enum UPLOAD_STEPS { } enum INDEXING_STAGE { - READY = 'READY', + CONFIGURE = 'CONFIGURE', TRIGGERED = 'TRIGGERED', SUCCESS = 'SUCCESS', ERROR = 'ERROR', } interface State { - indexingStage: INDEXING_STAGE | null; + indexingStage: INDEXING_STAGE; fileUploadComponent: React.ComponentType<FileUploadComponentProps> | null; results?: FileUploadGeoResults; } @@ -42,7 +42,7 @@ export class ClientFileCreateSourceEditor extends Component<RenderWizardArgument private _isMounted: boolean = false; state: State = { - indexingStage: null, + indexingStage: INDEXING_STAGE.CONFIGURE, fileUploadComponent: null, }; @@ -58,7 +58,7 @@ export class ClientFileCreateSourceEditor extends Component<RenderWizardArgument componentDidUpdate() { if ( this.props.currentStepId === UPLOAD_STEPS.UPLOAD && - this.state.indexingStage === INDEXING_STAGE.READY + this.state.indexingStage === INDEXING_STAGE.CONFIGURE ) { this.setState({ indexingStage: INDEXING_STAGE.TRIGGERED }); this.props.startStepLoading(); @@ -156,19 +156,6 @@ export class ClientFileCreateSourceEditor extends Component<RenderWizardArgument this.setState({ indexingStage: INDEXING_STAGE.ERROR }); }; - // Called on file upload screen when UI state changes - _onIndexReady = (indexReady: boolean) => { - if (!this._isMounted) { - return; - } - this.setState({ indexingStage: indexReady ? INDEXING_STAGE.READY : null }); - if (indexReady) { - this.props.enableNextBtn(); - } else { - this.props.disableNextBtn(); - } - }; - render() { if (!this.state.fileUploadComponent) { return null; @@ -181,7 +168,8 @@ export class ClientFileCreateSourceEditor extends Component<RenderWizardArgument isIndexingTriggered={this.state.indexingStage === INDEXING_STAGE.TRIGGERED} onFileSelect={this._onFileSelect} onFileClear={this._onFileClear} - onIndexReady={this._onIndexReady} + enableImportBtn={this.props.enableNextBtn} + disableImportBtn={this.props.disableNextBtn} onUploadComplete={this._onUploadComplete} onUploadError={this._onUploadError} /> diff --git a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx index c19ded6c2593e..22b873a94d1f7 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx @@ -12,13 +12,8 @@ import { Adapters } from 'src/plugins/inspector/public'; import { FileLayer } from '@elastic/ems-client'; import { Attribution, ImmutableSourceProperty, SourceEditorArgs } from '../source'; import { AbstractVectorSource, GeoJsonWithMeta, IVectorSource } from '../vector_source'; -import { - SOURCE_TYPES, - FIELD_ORIGIN, - VECTOR_SHAPE_TYPE, - FORMAT_TYPE, -} from '../../../../common/constants'; -import { fetchGeoJson, getEmsFileLayers } from '../../../util'; +import { SOURCE_TYPES, FIELD_ORIGIN, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; +import { getEmsFileLayers } from '../../../util'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; import { UpdateSourceEditor } from './update_source_editor'; import { EMSFileField } from '../../fields/ems_file_field'; @@ -122,24 +117,26 @@ export class EMSFileSource extends AbstractVectorSource implements IEmsFileSourc } async getGeoJsonWithMeta(): Promise<GeoJsonWithMeta> { - const emsFileLayer = await this.getEMSFileLayer(); - const featureCollection = await fetchGeoJson( - emsFileLayer.getDefaultFormatUrl(), - emsFileLayer.getDefaultFormatType() as FORMAT_TYPE, - 'data' - ); + try { + const emsFileLayer = await this.getEMSFileLayer(); + const featureCollection = await emsFileLayer.getGeoJson(); - const emsIdField = emsFileLayer.getFields().find((field) => { - return field.type === 'id'; - }); - featureCollection.features.forEach((feature: Feature, index: number) => { - feature.id = emsIdField ? feature!.properties![emsIdField.id] : index; - }); + if (!featureCollection) throw new Error('No features found'); - return { - data: featureCollection, - meta: {}, - }; + const emsIdField = emsFileLayer.getFields().find((field) => { + return field.type === 'id'; + }); + featureCollection.features.forEach((feature: Feature, index: number) => { + feature.id = emsIdField ? feature!.properties![emsIdField.id] : index; + }); + + return { + data: featureCollection, + meta: {}, + }; + } catch (error) { + throw new Error(`${getErrorInfo(this._descriptor.id)} - ${error.message}`); + } } async getImmutableProperties(): Promise<ImmutableSourceProperty[]> { diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index 50043772af95b..8e31ad7855197 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -167,9 +167,8 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); - let resp; try { - resp = await searchSource + const { rawResponse: resp } = await searchSource .fetch$({ abortSignal: abortController.signal, sessionId: searchSessionId, @@ -182,6 +181,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource }, }) .toPromise(); + return resp; } catch (error) { if (isSearchSourceAbortError(error)) { throw new DataRequestAbortError(); @@ -194,8 +194,6 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource }) ); } - - return resp; } async makeSearchSource( diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/style_property.ts b/x-pack/plugins/maps/public/classes/styles/vector/properties/style_property.ts index f77a73e531029..41877406f7489 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/style_property.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/style_property.ts @@ -59,6 +59,8 @@ export class AbstractStyleProperty<T> implements IStyleProperty<T> { return ''; } else if (typeof value === 'boolean') { return value.toString(); + } else if (Array.isArray(value)) { + return value.join(', '); } else { return value; } diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/feature_properties.test.js.snap b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/feature_properties.test.tsx.snap similarity index 100% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/feature_properties.test.js.snap rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/feature_properties.test.tsx.snap diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/footer.test.js.snap b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/footer.test.tsx.snap similarity index 100% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/footer.test.js.snap rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/footer.test.tsx.snap diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_geometry_filter_form.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_geometry_filter_form.tsx similarity index 68% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_geometry_filter_form.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_geometry_filter_form.tsx index 9d4cf78c98754..61732d1c268c2 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_geometry_filter_form.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_geometry_filter_form.tsx @@ -8,19 +8,42 @@ import React, { Component } from 'react'; import { i18n } from '@kbn/i18n'; +import { Filter } from 'src/plugins/data/public'; +import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; +import { Geometry, Polygon } from 'geojson'; +import rison, { RisonObject } from 'rison-node'; import { URL_MAX_LENGTH } from '../../../../../../../src/core/public'; import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../../../src/plugins/data/public'; -import { createSpatialFilterWithGeometry } from '../../../../common/elasticsearch_util'; -import { GEO_JSON_TYPE } from '../../../../common/constants'; +import { + createSpatialFilterWithGeometry, + PreIndexedShape, +} from '../../../../common/elasticsearch_util'; +import { ES_SPATIAL_RELATIONS, GEO_JSON_TYPE } from '../../../../common/constants'; +// @ts-expect-error import { GeometryFilterForm } from '../../../components/geometry_filter_form'; - -import rison from 'rison-node'; +import { GeoFieldWithIndex } from '../../../components/geo_field_with_index'; // over estimated and imprecise value to ensure filter has additional room for any meta keys added when filter is mapped. const META_OVERHEAD = 100; -export class FeatureGeometryFilterForm extends Component { - state = { +interface Props { + onClose: () => void; + geometry: Geometry; + geoFields: GeoFieldWithIndex[]; + addFilters: (filters: Filter[], actionId: string) => Promise<void>; + getFilterActions?: () => Promise<Action[]>; + getActionContext?: () => ActionExecutionContext; + loadPreIndexedShape: () => Promise<PreIndexedShape | null>; +} + +interface State { + isLoading: boolean; + errorMsg: string | undefined; +} + +export class FeatureGeometryFilterForm extends Component<Props, State> { + private _isMounted = false; + state: State = { isLoading: false, errorMsg: undefined, }; @@ -52,7 +75,17 @@ export class FeatureGeometryFilterForm extends Component { return preIndexedShape; }; - _createFilter = async ({ geometryLabel, indexPatternId, geoFieldName, relation }) => { + _createFilter = async ({ + geometryLabel, + indexPatternId, + geoFieldName, + relation, + }: { + geometryLabel: string; + indexPatternId: string; + geoFieldName: string; + relation: ES_SPATIAL_RELATIONS; + }) => { this.setState({ errorMsg: undefined }); const preIndexedShape = await this._loadPreIndexedShape(); if (!this._isMounted) { @@ -62,7 +95,7 @@ export class FeatureGeometryFilterForm extends Component { const filter = createSpatialFilterWithGeometry({ preIndexedShape, - geometry: this.props.geometry, + geometry: this.props.geometry as Polygon, geometryLabel, indexPatternId, geoFieldName, @@ -72,7 +105,7 @@ export class FeatureGeometryFilterForm extends Component { // Ensure filter will not overflow URL. Filters that contain geometry can be extremely large. // No elasticsearch support for pre-indexed shapes and geo_point spatial queries. if ( - window.location.href.length + rison.encode(filter).length + META_OVERHEAD > + window.location.href.length + rison.encode(filter as RisonObject).length + META_OVERHEAD > URL_MAX_LENGTH ) { this.setState({ diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.test.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.test.tsx similarity index 72% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.test.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.test.tsx index e7a2024afb98a..c999e9e6705cc 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.test.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.test.tsx @@ -9,9 +9,15 @@ import React from 'react'; import { shallow } from 'enzyme'; import { FeatureProperties } from './feature_properties'; import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../../../src/plugins/data/public'; +import { ITooltipProperty } from '../../../classes/tooltips/tooltip_property'; +import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; class MockTooltipProperty { - constructor(key, value, isFilterable) { + private _key: string; + private _value: string; + private _isFilterable: boolean; + + constructor(key: string, value: string, isFilterable: boolean) { this._key = key; this._value = value; this._isFilterable = isFilterable; @@ -31,21 +37,27 @@ class MockTooltipProperty { } const defaultProps = { - loadFeatureProperties: () => { + loadFeatureProperties: async () => { return []; }, featureId: `feature`, layerId: `layer`, + mbProperties: {}, onCloseTooltip: () => {}, showFilterButtons: false, - getFilterActions: () => { - return [{ id: ACTION_GLOBAL_APPLY_FILTER }]; + addFilters: async () => {}, + getActionContext: () => { + return ({} as unknown) as ActionExecutionContext; + }, + getFilterActions: async () => { + return [({ id: ACTION_GLOBAL_APPLY_FILTER } as unknown) as Action]; }, + showFilterActions: () => {}, }; const mockTooltipProperties = [ - new MockTooltipProperty('prop1', 'foobar1', true), - new MockTooltipProperty('prop2', 'foobar2', false), + (new MockTooltipProperty('prop1', 'foobar1', true) as unknown) as ITooltipProperty, + (new MockTooltipProperty('prop2', 'foobar2', false) as unknown) as ITooltipProperty, ]; describe('FeatureProperties', () => { @@ -53,7 +65,7 @@ describe('FeatureProperties', () => { const component = shallow( <FeatureProperties {...defaultProps} - loadFeatureProperties={() => { + loadFeatureProperties={async () => { return mockTooltipProperties; }} /> @@ -72,7 +84,7 @@ describe('FeatureProperties', () => { <FeatureProperties {...defaultProps} showFilterButtons={true} - loadFeatureProperties={() => { + loadFeatureProperties={async () => { return mockTooltipProperties; }} /> @@ -91,11 +103,11 @@ describe('FeatureProperties', () => { <FeatureProperties {...defaultProps} showFilterButtons={true} - loadFeatureProperties={() => { + loadFeatureProperties={async () => { return mockTooltipProperties; }} - getFilterActions={() => { - return [{ id: 'drilldown1' }]; + getFilterActions={async () => { + return [({ id: 'drilldown1' } as unknown) as Action]; }} /> ); @@ -113,7 +125,7 @@ describe('FeatureProperties', () => { <FeatureProperties {...defaultProps} showFilterButtons={true} - loadFeatureProperties={() => { + loadFeatureProperties={async () => { throw new Error('Simulated load properties error'); }} /> diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.tsx similarity index 65% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.tsx index 2bd1d5c9cacf5..d221d4d5b1ca5 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { Component, CSSProperties, RefObject, ReactNode } from 'react'; import { EuiCallOut, EuiLoadingSpinner, @@ -15,11 +15,51 @@ import { EuiContextMenu, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; +import { GeoJsonProperties } from 'geojson'; +import { Filter } from 'src/plugins/data/public'; import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../../../src/plugins/data/public'; import { isUrlDrilldown } from '../../../trigger_actions/trigger_utils'; +import { RawValue } from '../../../../common/constants'; +import { ITooltipProperty } from '../../../classes/tooltips/tooltip_property'; -export class FeatureProperties extends React.Component { - state = { +interface Props { + featureId?: string | number; + layerId: string; + mbProperties: GeoJsonProperties; + loadFeatureProperties: ({ + layerId, + featureId, + mbProperties, + }: { + layerId: string; + featureId?: string | number; + mbProperties: GeoJsonProperties; + }) => Promise<ITooltipProperty[]>; + showFilterButtons: boolean; + onCloseTooltip: () => void; + addFilters: ((filters: Filter[], actionId: string) => Promise<void>) | null; + getFilterActions?: () => Promise<Action[]>; + getActionContext?: () => ActionExecutionContext; + onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void; + showFilterActions: (view: ReactNode) => void; +} + +interface State { + properties: ITooltipProperty[] | null; + actions: Action[]; + loadPropertiesErrorMsg: string | null; + prevWidth: number | null; + prevHeight: number | null; +} + +export class FeatureProperties extends Component<Props, State> { + private _isMounted = false; + private _prevLayerId: string = ''; + private _prevFeatureId?: string | number = ''; + private readonly _tableRef: RefObject<HTMLTableElement> = React.createRef(); + + state: State = { properties: null, actions: [], loadPropertiesErrorMsg: null, @@ -29,8 +69,6 @@ export class FeatureProperties extends React.Component { componentDidMount() { this._isMounted = true; - this.prevLayerId = undefined; - this.prevFeatureId = undefined; this._loadProperties(); this._loadActions(); } @@ -61,28 +99,42 @@ export class FeatureProperties extends React.Component { }); }; - _showFilterActions = (tooltipProperty) => { - this.props.showFilterActions(this._renderFilterActions(tooltipProperty)); + _showFilterActions = ( + tooltipProperty: ITooltipProperty, + getActionContext: () => ActionExecutionContext, + addFilters: (filters: Filter[], actionId: string) => Promise<void> + ) => { + this.props.showFilterActions( + this._renderFilterActions(tooltipProperty, getActionContext, addFilters) + ); }; - _fetchProperties = async ({ nextLayerId, nextFeatureId, mbProperties }) => { - if (this.prevLayerId === nextLayerId && this.prevFeatureId === nextFeatureId) { + _fetchProperties = async ({ + nextLayerId, + nextFeatureId, + mbProperties, + }: { + nextLayerId: string; + nextFeatureId?: string | number; + mbProperties: GeoJsonProperties; + }) => { + if (this._prevLayerId === nextLayerId && this._prevFeatureId === nextFeatureId) { // do not reload same feature properties return; } - this.prevLayerId = nextLayerId; - this.prevFeatureId = nextFeatureId; + this._prevLayerId = nextLayerId; + this._prevFeatureId = nextFeatureId; this.setState({ - properties: undefined, - loadPropertiesErrorMsg: undefined, + properties: null, + loadPropertiesErrorMsg: null, }); // Preserve current properties width/height so they can be used while rendering loading indicator. - if (this.state.properties && this._node) { + if (this.state.properties && this._tableRef.current) { this.setState({ - prevWidth: this._node.clientWidth, - prevHeight: this._node.clientHeight, + prevWidth: this._tableRef.current.clientWidth, + prevHeight: this._tableRef.current.clientHeight, }); } @@ -91,7 +143,7 @@ export class FeatureProperties extends React.Component { properties = await this.props.loadFeatureProperties({ layerId: nextLayerId, featureId: nextFeatureId, - mbProperties: mbProperties, + mbProperties, }); } catch (error) { if (this._isMounted) { @@ -103,7 +155,7 @@ export class FeatureProperties extends React.Component { return; } - if (this.prevLayerId !== nextLayerId && this.prevFeatureId !== nextFeatureId) { + if (this._prevLayerId !== nextLayerId && this._prevFeatureId !== nextFeatureId) { // ignore results for old request return; } @@ -113,7 +165,11 @@ export class FeatureProperties extends React.Component { } }; - _renderFilterActions(tooltipProperty) { + _renderFilterActions( + tooltipProperty: ITooltipProperty, + getActionContext: () => ActionExecutionContext, + addFilters: (filters: Filter[], actionId: string) => Promise<void> + ) { const panel = { id: 0, items: this.state.actions @@ -124,24 +180,24 @@ export class FeatureProperties extends React.Component { return true; }) .map((action) => { - const actionContext = this.props.getActionContext(); + const actionContext = getActionContext(); const iconType = action.getIconType(actionContext); const name = action.getDisplayName(actionContext); return { name: name ? name : action.id, - icon: iconType ? <EuiIcon type={iconType} /> : null, + icon: iconType ? <EuiIcon type={iconType} /> : undefined, onClick: async () => { this.props.onCloseTooltip(); if (isUrlDrilldown(action)) { - this.props.onSingleValueTrigger( + this.props.onSingleValueTrigger!( action.id, tooltipProperty.getPropertyKey(), tooltipProperty.getRawValue() ); } else { const filters = await tooltipProperty.getESFilters(); - this.props.addFilters(filters, action.id); + addFilters(filters, action.id); } }, ['data-test-subj']: `mapFilterActionButton__${name}`, @@ -151,10 +207,7 @@ export class FeatureProperties extends React.Component { return ( <div> - <table - className="eui-yScrollWithShadows mapFeatureTooltip_table" - ref={(node) => (this._node = node)} - > + <table className="eui-yScrollWithShadows mapFeatureTooltip_table" ref={this._tableRef}> <tbody> <tr> <td className="eui-textOverflowWrap mapFeatureTooltip__propertyLabel"> @@ -168,7 +221,7 @@ export class FeatureProperties extends React.Component { * Since these formatters produce raw HTML, this component needs to be able to render them as-is, relying * on the field formatter to only produce safe HTML. */ - dangerouslySetInnerHTML={{ __html: tooltipProperty.getHtmlDisplayValue() }} //eslint-disable-line react/no-danger + dangerouslySetInnerHTML={{ __html: tooltipProperty.getHtmlDisplayValue() }} // eslint-disable-line react/no-danger /> </tr> </tbody> @@ -178,8 +231,12 @@ export class FeatureProperties extends React.Component { ); } - _renderFilterCell(tooltipProperty) { - if (!this.props.showFilterButtons || !tooltipProperty.isFilterable()) { + _renderFilterCell(tooltipProperty: ITooltipProperty) { + if ( + !this.props.showFilterButtons || + !tooltipProperty.isFilterable() || + this.props.addFilters === undefined + ) { return <td />; } @@ -192,7 +249,7 @@ export class FeatureProperties extends React.Component { onClick={async () => { this.props.onCloseTooltip(); const filters = await tooltipProperty.getESFilters(); - this.props.addFilters(filters, ACTION_GLOBAL_APPLY_FILTER); + this.props.addFilters!(filters, ACTION_GLOBAL_APPLY_FILTER); }} aria-label={i18n.translate('xpack.maps.tooltip.filterOnPropertyAriaLabel', { defaultMessage: 'Filter on property', @@ -203,7 +260,8 @@ export class FeatureProperties extends React.Component { </EuiButtonEmpty> ); - return this.state.actions.length === 0 || + return this.props.getActionContext === undefined || + this.state.actions.length === 0 || (this.state.actions.length === 1 && this.state.actions[0].id === ACTION_GLOBAL_APPLY_FILTER) ? ( <td>{applyFilterButton}</td> @@ -217,7 +275,11 @@ export class FeatureProperties extends React.Component { defaultMessage: 'View filter actions', })} onClick={() => { - this._showFilterActions(tooltipProperty); + this._showFilterActions( + tooltipProperty, + this.props.getActionContext!, + this.props.addFilters! + ); }} aria-label={i18n.translate('xpack.maps.tooltip.viewActionsTitle', { defaultMessage: 'View filter actions', @@ -253,7 +315,7 @@ export class FeatureProperties extends React.Component { }); // Use width/height of last viewed properties while displaying loading status // to avoid resizing component during loading phase and bouncing tooltip container around - const style = {}; + const style: CSSProperties = {}; if (this.state.prevWidth && this.state.prevHeight) { style.width = this.state.prevWidth; style.height = this.state.prevHeight; @@ -279,7 +341,7 @@ export class FeatureProperties extends React.Component { * Since these formatters produce raw HTML, this component needs to be able to render them as-is, relying * on the field formatter to only produce safe HTML. */ - dangerouslySetInnerHTML={{ __html: tooltipProperty.getHtmlDisplayValue() }} //eslint-disable-line react/no-danger + dangerouslySetInnerHTML={{ __html: tooltipProperty.getHtmlDisplayValue() }} // eslint-disable-line react/no-danger /> {this._renderFilterCell(tooltipProperty)} </tr> @@ -287,10 +349,7 @@ export class FeatureProperties extends React.Component { }); return ( - <table - className="eui-yScrollWithShadows mapFeatureTooltip_table" - ref={(node) => (this._node = node)} - > + <table className="eui-yScrollWithShadows mapFeatureTooltip_table" ref={this._tableRef}> <tbody>{rows}</tbody> </table> ); diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.tsx similarity index 66% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.tsx index be8e960471efa..41a2b98ab4b28 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.tsx @@ -5,26 +5,82 @@ * 2.0. */ -import React, { Component, Fragment } from 'react'; +import React, { Component, Fragment, ReactNode } from 'react'; import { EuiIcon, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; +import { GeoJsonProperties, Geometry } from 'geojson'; +import { Filter } from 'src/plugins/data/public'; import { FeatureProperties } from './feature_properties'; -import { GEO_JSON_TYPE, ES_GEO_FIELD_TYPE } from '../../../../common/constants'; +import { GEO_JSON_TYPE, ES_GEO_FIELD_TYPE, RawValue } from '../../../../common/constants'; import { FeatureGeometryFilterForm } from './feature_geometry_filter_form'; import { Footer } from './footer'; import { Header } from './header'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { PreIndexedShape } from '../../../../common/elasticsearch_util'; +import { GeoFieldWithIndex } from '../../../components/geo_field_with_index'; +import { TooltipFeature } from '../../../../common/descriptor_types'; +import { ITooltipProperty } from '../../../classes/tooltips/tooltip_property'; +import { ILayer } from '../../../classes/layers/layer'; -const VIEWS = { - PROPERTIES_VIEW: 'PROPERTIES_VIEW', - GEOMETRY_FILTER_VIEW: 'GEOMETRY_FILTER_VIEW', - FILTER_ACTIONS_VIEW: 'FILTER_ACTIONS_VIEW', -}; +enum VIEWS { + PROPERTIES_VIEW = 'PROPERTIES_VIEW', + GEOMETRY_FILTER_VIEW = 'GEOMETRY_FILTER_VIEW', + FILTER_ACTIONS_VIEW = 'FILTER_ACTIONS_VIEW', +} -export class FeaturesTooltip extends Component { - state = {}; +interface Props { + addFilters: ((filters: Filter[], actionId: string) => Promise<void>) | null; + getFilterActions?: () => Promise<Action[]>; + getActionContext?: () => ActionExecutionContext; + onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void; + closeTooltip: () => void; + features: TooltipFeature[]; + isLocked: boolean; + loadFeatureProperties: ({ + layerId, + featureId, + mbProperties, + }: { + layerId: string; + featureId?: string | number; + mbProperties: GeoJsonProperties; + }) => Promise<ITooltipProperty[]>; + loadFeatureGeometry: ({ + layerId, + featureId, + }: { + layerId: string; + featureId?: string | number; + }) => Geometry | null; + getLayerName: (layerId: string) => Promise<string | null>; + findLayerById: (layerId: string) => ILayer | undefined; + geoFields: GeoFieldWithIndex[]; + loadPreIndexedShape: ({ + layerId, + featureId, + }: { + layerId: string; + featureId?: string | number; + }) => Promise<PreIndexedShape | null>; +} - static getDerivedStateFromProps(nextProps, prevState) { +interface State { + currentFeature: TooltipFeature | null; + filterView: ReactNode | null; + prevFeatures: TooltipFeature[]; + view: VIEWS; +} + +export class FeaturesTooltip extends Component<Props, State> { + state: State = { + currentFeature: null, + filterView: null, + prevFeatures: [], + view: VIEWS.PROPERTIES_VIEW, + }; + + static getDerivedStateFromProps(nextProps: Props, prevState: State) { if (nextProps.features !== prevState.prevFeatures) { return { currentFeature: nextProps.features ? nextProps.features[0] : null, @@ -36,7 +92,7 @@ export class FeaturesTooltip extends Component { return null; } - _setCurrentFeature = (feature) => { + _setCurrentFeature = (feature: TooltipFeature) => { this.setState({ currentFeature: feature }); }; @@ -48,11 +104,11 @@ export class FeaturesTooltip extends Component { this.setState({ view: VIEWS.PROPERTIES_VIEW, filterView: null }); }; - _showFilterActionsView = (filterView) => { + _showFilterActionsView = (filterView: ReactNode) => { this.setState({ view: VIEWS.FILTER_ACTIONS_VIEW, filterView }); }; - _renderActions(geoFields) { + _renderActions(geoFields: GeoFieldWithIndex[]) { if (!this.props.isLocked || geoFields.length === 0) { return null; } @@ -67,7 +123,7 @@ export class FeaturesTooltip extends Component { ); } - _filterGeoFields(featureGeometry) { + _filterGeoFields(featureGeometry: Geometry | null) { if (!featureGeometry) { return []; } @@ -93,9 +149,9 @@ export class FeaturesTooltip extends Component { return this.props.geoFields; } - _loadCurrentFeaturePreIndexedShape = () => { + _loadCurrentFeaturePreIndexedShape = async () => { if (!this.state.currentFeature) { - return; + return null; } return this.props.loadPreIndexedShape({ @@ -104,7 +160,7 @@ export class FeaturesTooltip extends Component { }); }; - _renderBackButton(label) { + _renderBackButton(label: string) { return ( <button className="euiContextMenuPanelTitle mapFeatureTooltip_backButton" @@ -131,7 +187,11 @@ export class FeaturesTooltip extends Component { }); const geoFields = this._filterGeoFields(currentFeatureGeometry); - if (this.state.view === VIEWS.GEOMETRY_FILTER_VIEW && currentFeatureGeometry) { + if ( + this.state.view === VIEWS.GEOMETRY_FILTER_VIEW && + currentFeatureGeometry && + this.props.addFilters + ) { return ( <Fragment> {this._renderBackButton( @@ -141,7 +201,6 @@ export class FeaturesTooltip extends Component { )} <FeatureGeometryFilterForm onClose={this.props.closeTooltip} - showPropertiesView={this._showPropertiesView} geometry={currentFeatureGeometry} geoFields={geoFields} addFilters={this.props.addFilters} diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/footer.test.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/footer.test.tsx similarity index 89% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/footer.test.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/footer.test.tsx index e794588cff435..f8e60c7e6cf97 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/footer.test.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/footer.test.tsx @@ -8,23 +8,19 @@ import React from 'react'; import { shallow } from 'enzyme'; import { Footer } from './footer'; - -class MockLayer { - constructor(id) { - this._id = id; - } - async getDisplayName() { - return `display + ${this._id}`; - } - getId() { - return this._id; - } -} +import { ILayer } from '../../../classes/layers/layer'; const defaultProps = { isLocked: false, - findLayerById: (id) => { - return new MockLayer(id); + findLayerById: (id: string) => { + return ({ + async getDisplayName() { + return `display + ${id}`; + }, + getId() { + return id; + }, + } as unknown) as ILayer; }, setCurrentFeature: () => {}, }; @@ -35,6 +31,7 @@ describe('Footer', () => { { id: 'feature1', layerId: 'layer1', + mbProperties: {}, }, ]; describe('mouseover (unlocked)', () => { @@ -56,10 +53,12 @@ describe('Footer', () => { { id: 'feature1', layerId: 'layer1', + mbProperties: {}, }, { id: 'feature2', layerId: 'layer1', + mbProperties: {}, }, ]; describe('mouseover (unlocked)', () => { @@ -97,14 +96,17 @@ describe('Footer', () => { { id: 'feature1', layerId: 'layer1', + mbProperties: {}, }, { id: 'feature2', layerId: 'layer1', + mbProperties: {}, }, { id: 'feature1', layerId: 'layer2', + mbProperties: {}, }, ]; describe('mouseover (unlocked)', () => { diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/footer.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/footer.tsx similarity index 83% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/footer.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/footer.tsx index 559e3fb18c182..3ad19a7901b09 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/footer.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/footer.tsx @@ -5,10 +5,11 @@ * 2.0. */ -import React, { Component, Fragment } from 'react'; +import React, { ChangeEvent, Component, Fragment } from 'react'; import { EuiPagination, EuiSelect, + EuiSelectOption, EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, @@ -17,12 +18,30 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { TooltipFeature } from '../../../../common/descriptor_types'; +import { ILayer } from '../../../classes/layers/layer'; const ALL_LAYERS = '_ALL_LAYERS_'; const DEFAULT_PAGE_NUMBER = 0; -export class Footer extends Component { - state = { +interface Props { + features: TooltipFeature[]; + isLocked: boolean; + findLayerById: (layerId: string) => ILayer | undefined; + setCurrentFeature: (feature: TooltipFeature) => void; +} + +interface State { + filteredFeatures: TooltipFeature[]; + pageNumber: number; + selectedLayerId: string; + layerOptions: EuiSelectOption[]; +} + +export class Footer extends Component<Props, State> { + private _isMounted = false; + private _prevFeatures: TooltipFeature[] | null = null; + state: State = { filteredFeatures: this.props.features, pageNumber: DEFAULT_PAGE_NUMBER, selectedLayerId: ALL_LAYERS, @@ -31,7 +50,6 @@ export class Footer extends Component { componentDidMount() { this._isMounted = true; - this._prevFeatures = null; this._loadUniqueLayers(); } @@ -50,7 +68,7 @@ export class Footer extends Component { this._prevFeatures = this.props.features; - const countByLayerId = new Map(); + const countByLayerId = new Map<string, number>(); for (let i = 0; i < this.props.features.length; i++) { let count = countByLayerId.get(this.props.features[i].layerId); if (!count) { @@ -60,9 +78,12 @@ export class Footer extends Component { countByLayerId.set(this.props.features[i].layerId, count); } - const layers = []; + const layers: ILayer[] = []; countByLayerId.forEach((count, layerId) => { - layers.push(this.props.findLayerById(layerId)); + const layer = this.props.findLayerById(layerId); + if (layer) { + layers.push(layer); + } }); const layerNamePromises = layers.map((layer) => { return layer.getDisplayName(); @@ -88,12 +109,12 @@ export class Footer extends Component { } }; - _onPageChange = (pageNumber) => { + _onPageChange = (pageNumber: number) => { this.setState({ pageNumber }); this.props.setCurrentFeature(this.state.filteredFeatures[pageNumber]); }; - _onLayerChange = (e) => { + _onLayerChange = (e: ChangeEvent<HTMLSelectElement>) => { const newLayerId = e.target.value; if (this.state.selectedLayerId === newLayerId) { return; diff --git a/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.test.ts b/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.test.ts index 34a53be48a5cd..eff49c1b1242e 100644 --- a/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.test.ts +++ b/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.test.ts @@ -6,7 +6,6 @@ */ import { suggestEMSTermJoinConfig } from './ems_autosuggest'; -import { FORMAT_TYPE } from '../../common'; import { FeatureCollection } from 'geojson'; class MockFileLayer { @@ -19,16 +18,28 @@ class MockFileLayer { this._id = url; this._fields = fields; } - getDefaultFormatUrl() { - return this._url; - } getFields() { return this._fields; } - getDefaultFormatType() { - return FORMAT_TYPE.GEOJSON; + getGeoJson() { + if (this._url === 'world_countries') { + return ({ + type: 'FeatureCollection', + features: [ + { properties: { iso2: 'CA', iso3: 'CAN' } }, + { properties: { iso2: 'US', iso3: 'USA' } }, + ], + } as unknown) as FeatureCollection; + } else if (this._url === 'zips') { + return ({ + type: 'FeatureCollection', + features: [{ properties: { zip: '40204' } }, { properties: { zip: '40205' } }], + } as unknown) as FeatureCollection; + } else { + throw new Error(`unrecognized mock url ${this._url}`); + } } hasId(id: string) { @@ -44,24 +55,6 @@ jest.mock('../util', () => { new MockFileLayer('zips', [{ id: 'zip' }]), ]; }, - async fetchGeoJson(url: string): Promise<FeatureCollection> { - if (url === 'world_countries') { - return ({ - type: 'FeatureCollection', - features: [ - { properties: { iso2: 'CA', iso3: 'CAN' } }, - { properties: { iso2: 'US', iso3: 'USA' } }, - ], - } as unknown) as FeatureCollection; - } else if (url === 'zips') { - return ({ - type: 'FeatureCollection', - features: [{ properties: { zip: '40204' } }, { properties: { zip: '40205' } }], - } as unknown) as FeatureCollection; - } else { - throw new Error(`unrecognized mock url ${url}`); - } - }, }; }); diff --git a/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.ts b/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.ts index 1d5c1529a004e..952e48a71a9dc 100644 --- a/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.ts +++ b/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.ts @@ -6,8 +6,8 @@ */ import type { FileLayer } from '@elastic/ems-client'; -import { getEmsFileLayers, fetchGeoJson } from '../util'; -import { FORMAT_TYPE, emsWorldLayerId, emsRegionLayerId, emsUsaZipLayerId } from '../../common'; +import { getEmsFileLayers } from '../util'; +import { emsWorldLayerId, emsRegionLayerId, emsUsaZipLayerId } from '../../common'; export interface SampleValuesConfig { emsLayerIds?: string[]; @@ -165,14 +165,9 @@ async function getMatchesForEMSLayer( } const emsFields = emsFileLayer.getFields(); - const url = emsFileLayer.getDefaultFormatUrl(); try { - const emsJson = await fetchGeoJson( - url, - emsFileLayer.getDefaultFormatType() as FORMAT_TYPE, - 'data' - ); + const emsJson = await emsFileLayer.getGeoJson(); const matches: EMSTermJoinConfig[] = []; for (let f = 0; f < emsFields.length; f++) { if (matchesEmsField(emsJson, emsFields[f].id, sampleValues)) { diff --git a/x-pack/plugins/ml/common/util/job_utils.ts b/x-pack/plugins/ml/common/util/job_utils.ts index 7e6d84f9efed7..e7608af553f60 100644 --- a/x-pack/plugins/ml/common/util/job_utils.ts +++ b/x-pack/plugins/ml/common/util/job_utils.ts @@ -101,6 +101,15 @@ export function hasValidComposite(buckets: estypes.AggregationContainer) { return true; } +/** + * Validates if aggregation type is currently not supported + * e.g. any other type other than 'date_histogram' or 'aggregations' + * @param buckets + */ +export function isUnsupportedAggType(aggType: string) { + return aggType !== 'date_histogram' && aggType !== 'aggs' && aggType !== 'aggregations'; +} + // Returns a flag to indicate whether the source data can be plotted in a time // series chart for the specified detector. export function isSourceDataChartableForDetector(job: CombinedJob, detectorIndex: number): boolean { @@ -143,6 +152,9 @@ export function isSourceDataChartableForDetector(job: CombinedJob, detectorIndex if (isPopulatedObject(aggs)) { const aggBucketsName = getFirstKeyInObject(aggs); if (aggBucketsName !== undefined) { + if (Object.keys(aggs[aggBucketsName]).some(isUnsupportedAggType)) { + return false; + } // if fieldName is an aggregated field under nested terms using bucket_script const aggregations = getAggregations<estypes.AggregationContainer>(aggs[aggBucketsName]) ?? {}; @@ -207,7 +219,7 @@ export function getSingleMetricViewerJobErrorMessage(job: CombinedJob): string | return i18n.translate( 'xpack.ml.timeSeriesJob.jobWithUnsupportedCompositeAggregationMessage', { - defaultMessage: 'Disabled because the datafeed contains unsupported composite sources.', + defaultMessage: 'the datafeed contains unsupported composite sources', } ); } @@ -223,7 +235,7 @@ export function getSingleMetricViewerJobErrorMessage(job: CombinedJob): string | if (isChartableTimeSeriesViewJob === false) { return i18n.translate('xpack.ml.timeSeriesJob.notViewableTimeSeriesJobMessage', { - defaultMessage: 'Disabled because not a viewable time series job.', + defaultMessage: 'it is not a viewable time series job', }); } } diff --git a/x-pack/plugins/ml/public/alerting/job_selector.tsx b/x-pack/plugins/ml/public/alerting/job_selector.tsx index da353b52ef1c0..d00d4efc25b8d 100644 --- a/x-pack/plugins/ml/public/alerting/job_selector.tsx +++ b/x-pack/plugins/ml/public/alerting/job_selector.tsx @@ -99,10 +99,7 @@ export const JobSelectorControl: FC<JobSelectorControlProps> = ({ <EuiFormRow fullWidth label={ - <FormattedMessage - id="xpack.ml.jobSelector.formControlLabel" - defaultMessage="Select jobs or groups" - /> + <FormattedMessage id="xpack.ml.jobSelector.formControlLabel" defaultMessage="Select job" /> } isInvalid={!!errors?.length} error={errors} diff --git a/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx b/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx index dac1fad72255c..b87a447bd4b15 100644 --- a/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx +++ b/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx @@ -82,6 +82,7 @@ export const MlAnomalyAlertFlyout: FC<MlAnomalyAlertFlyoutProps> = ({ interface JobListMlAnomalyAlertFlyoutProps { setShowFunction: (callback: Function) => void; unsetShowFunction: () => void; + onSave: () => void; } /** @@ -93,6 +94,7 @@ interface JobListMlAnomalyAlertFlyoutProps { export const JobListMlAnomalyAlertFlyout: FC<JobListMlAnomalyAlertFlyoutProps> = ({ setShowFunction, unsetShowFunction, + onSave, }) => { const [isVisible, setIsVisible] = useState(false); const [jobIds, setJobIds] = useState<JobId[] | undefined>(); @@ -115,6 +117,7 @@ export const JobListMlAnomalyAlertFlyout: FC<JobListMlAnomalyAlertFlyoutProps> = onCloseFlyout={() => setIsVisible(false)} onSave={() => { setIsVisible(false); + onSave(); }} /> ) : null; @@ -122,9 +125,10 @@ export const JobListMlAnomalyAlertFlyout: FC<JobListMlAnomalyAlertFlyoutProps> = interface EditRuleFlyoutProps { initialAlert: MlAnomalyDetectionAlertRule; + onSave: () => void; } -export const EditAlertRule: FC<EditRuleFlyoutProps> = ({ initialAlert }) => { +export const EditAlertRule: FC<EditRuleFlyoutProps> = ({ initialAlert, onSave }) => { const [isVisible, setIsVisible] = useState(false); return ( <> @@ -136,7 +140,10 @@ export const EditAlertRule: FC<EditRuleFlyoutProps> = ({ initialAlert }) => { <MlAnomalyAlertFlyout initialAlert={initialAlert} onCloseFlyout={setIsVisible.bind(null, false)} - onSave={setIsVisible.bind(null, false)} + onSave={() => { + setIsVisible(false); + onSave(); + }} /> ) : null} </> diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotation_flyout/index.tsx b/x-pack/plugins/ml/public/application/components/annotations/annotation_flyout/index.tsx index 4d3ac6e272c49..3d3200faf8095 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotation_flyout/index.tsx +++ b/x-pack/plugins/ml/public/application/components/annotations/annotation_flyout/index.tsx @@ -435,6 +435,7 @@ export const AnnotationFlyout: FC<any> = (props) => { size="m" aria-labelledby="Add annotation" data-test-subj={'mlAnnotationFlyout'} + className={'mlAnnotationFlyout'} > <EuiFlyoutHeader hasBorder> <EuiTitle size="s"> diff --git a/x-pack/plugins/ml/public/application/components/job_messages/job_messages.tsx b/x-pack/plugins/ml/public/application/components/job_messages/job_messages.tsx index 76a7bc3d38f11..6ff348860253e 100644 --- a/x-pack/plugins/ml/public/application/components/job_messages/job_messages.tsx +++ b/x-pack/plugins/ml/public/application/components/job_messages/job_messages.tsx @@ -93,6 +93,7 @@ export const JobMessages: FC<JobMessagesProps> = ({ messages, loading, error, re compressed={true} loading={loading} error={error} + data-test-subj={'mlAnalyticsDetailsJobMessagesTable'} /> </> ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts index 2ae75083bff43..f3b51acf093ad 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts @@ -50,39 +50,22 @@ function getRuntimeFieldColumns(runtimeMappings: RuntimeMappings) { }); } -function getInitialColumns(indexPattern: IndexPattern) { +function getIndexPatternColumns(indexPattern: IndexPattern, fieldsFilter: string[]) { const { fields } = newJobCapsServiceAnalytics; - const columns = fields.map((field: any) => { - const schema = - getDataGridSchemaFromESFieldType(field.type) || getDataGridSchemaFromKibanaFieldType(field); - - return { - id: field.name, - schema, - isExpandable: schema !== 'boolean', - isRuntimeFieldColumn: false, - }; - }); - // Add runtime fields defined in index pattern to columns - if (indexPattern) { - const computedFields = indexPattern?.getComputedFields(); + return fields + .filter((field) => fieldsFilter.includes(field.name)) + .map((field) => { + const schema = + getDataGridSchemaFromESFieldType(field.type) || getDataGridSchemaFromKibanaFieldType(field); - if (isRuntimeMappings(computedFields.runtimeFields)) { - Object.keys(computedFields.runtimeFields).forEach((runtimeField) => { - const schema = getDataGridSchemaFromESFieldType( - computedFields.runtimeFields[runtimeField].type - ); - columns.push({ - id: runtimeField, - schema, - isExpandable: schema !== 'boolean', - isRuntimeFieldColumn: true, - }); - }); - } - } - return columns; + return { + id: field.name, + schema, + isExpandable: schema !== 'boolean', + isRuntimeFieldColumn: false, + }; + }); } export const useIndexData = ( @@ -91,10 +74,71 @@ export const useIndexData = ( toastNotifications: CoreSetup['notifications']['toasts'], runtimeMappings?: RuntimeMappings ): UseIndexDataReturnType => { - const indexPatternFields = useMemo(() => getFieldsFromKibanaIndexPattern(indexPattern), [ - indexPattern, - ]); - const [columns, setColumns] = useState<MLEuiDataGridColumn[]>(getInitialColumns(indexPattern)); + // Fetch 500 random documents to determine populated fields. + // This is a workaround to avoid passing potentially thousands of unpopulated fields + // (for example, as part of filebeat/metricbeat/ECS based indices) + // to the data grid component which would significantly slow down the page. + const [indexPatternFields, setIndexPatternFields] = useState<string[]>(); + useEffect(() => { + async function fetchDataGridSampleDocuments() { + setErrorMessage(''); + setStatus(INDEX_STATUS.LOADING); + + const esSearchRequest = { + index: indexPattern.title, + body: { + fields: ['*'], + _source: false, + query: { + function_score: { + query: { match_all: {} }, + random_score: {}, + }, + }, + size: 500, + }, + }; + + try { + const resp: IndexSearchResponse = await ml.esSearch(esSearchRequest); + const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); + + // Get all field names for each returned doc and flatten it + // to a list of unique field names used across all docs. + const allKibanaIndexPatternFields = getFieldsFromKibanaIndexPattern(indexPattern); + const populatedFields = [...new Set(docs.map(Object.keys).flat(1))] + .filter((d) => allKibanaIndexPatternFields.includes(d)) + .sort(); + + setStatus(INDEX_STATUS.LOADED); + setIndexPatternFields(populatedFields); + } catch (e) { + setErrorMessage(extractErrorMessage(e)); + setStatus(INDEX_STATUS.ERROR); + } + } + + fetchDataGridSampleDocuments(); + }, []); + + // To be used for data grid column selection + // and will be applied to doc and chart queries. + const combinedRuntimeMappings = useMemo( + () => getCombinedRuntimeMappings(indexPattern, runtimeMappings), + [indexPattern, runtimeMappings] + ); + + // Available data grid columns, will be a combination of index pattern and runtime fields. + const [columns, setColumns] = useState<MLEuiDataGridColumn[]>([]); + useEffect(() => { + if (Array.isArray(indexPatternFields)) { + setColumns([ + ...getIndexPatternColumns(indexPattern, indexPatternFields), + ...(combinedRuntimeMappings ? getRuntimeFieldColumns(combinedRuntimeMappings) : []), + ]); + } + }, [indexPattern, indexPatternFields, combinedRuntimeMappings]); + const dataGrid = useDataGrid(columns); const { @@ -114,95 +158,87 @@ export const useIndexData = ( // custom comparison }, [JSON.stringify(query)]); - const getIndexData = async function () { - setErrorMessage(''); - setStatus(INDEX_STATUS.LOADING); - - const combinedRuntimeMappings = getCombinedRuntimeMappings(indexPattern, runtimeMappings); - - const sort: EsSorting = sortingColumns.reduce((s, column) => { - s[column.id] = { order: column.direction }; - return s; - }, {} as EsSorting); - const esSearchRequest = { - index: indexPattern.title, - body: { - query, - from: pagination.pageIndex * pagination.pageSize, - size: pagination.pageSize, - fields: ['*'], - _source: false, - ...(Object.keys(sort).length > 0 ? { sort } : {}), - ...(isRuntimeMappings(combinedRuntimeMappings) - ? { runtime_mappings: combinedRuntimeMappings } - : {}), - }, - }; - - try { - const resp: IndexSearchResponse = await ml.esSearch(esSearchRequest); - const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); - - if (isRuntimeMappings(runtimeMappings)) { - // remove old runtime field from columns - const updatedColumns = columns.filter((col) => col.isRuntimeFieldColumn === false); - setColumns([ - ...updatedColumns, - ...(combinedRuntimeMappings ? getRuntimeFieldColumns(combinedRuntimeMappings) : []), - ]); - } else { - setColumns(getInitialColumns(indexPattern)); + useEffect(() => { + async function fetchIndexData() { + setErrorMessage(''); + setStatus(INDEX_STATUS.LOADING); + + const sort: EsSorting = sortingColumns.reduce((s, column) => { + s[column.id] = { order: column.direction }; + return s; + }, {} as EsSorting); + const esSearchRequest = { + index: indexPattern.title, + body: { + query, + from: pagination.pageIndex * pagination.pageSize, + size: pagination.pageSize, + fields: [ + ...(indexPatternFields ?? []), + ...(isRuntimeMappings(combinedRuntimeMappings) + ? Object.keys(combinedRuntimeMappings) + : []), + ], + _source: false, + ...(Object.keys(sort).length > 0 ? { sort } : {}), + ...(isRuntimeMappings(combinedRuntimeMappings) + ? { runtime_mappings: combinedRuntimeMappings } + : {}), + }, + }; + + try { + const resp: IndexSearchResponse = await ml.esSearch(esSearchRequest); + const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); + + setRowCount(typeof resp.hits.total === 'number' ? resp.hits.total : resp.hits.total.value); + setRowCountRelation( + typeof resp.hits.total === 'number' + ? ('eq' as estypes.TotalHitsRelation) + : resp.hits.total.relation + ); + setTableItems(docs); + setStatus(INDEX_STATUS.LOADED); + } catch (e) { + setErrorMessage(extractErrorMessage(e)); + setStatus(INDEX_STATUS.ERROR); } - setRowCount(typeof resp.hits.total === 'number' ? resp.hits.total : resp.hits.total.value); - setRowCountRelation( - typeof resp.hits.total === 'number' - ? ('eq' as estypes.TotalHitsRelation) - : resp.hits.total.relation - ); - setTableItems(docs); - setStatus(INDEX_STATUS.LOADED); - } catch (e) { - setErrorMessage(extractErrorMessage(e)); - setStatus(INDEX_STATUS.ERROR); } - }; - useEffect(() => { - if (query !== undefined) { - getIndexData(); + if (indexPatternFields !== undefined && query !== undefined) { + fetchIndexData(); } // custom comparison }, [ indexPattern.title, indexPatternFields, - JSON.stringify([query, pagination, sortingColumns, runtimeMappings]), + JSON.stringify([query, pagination, sortingColumns, combinedRuntimeMappings]), ]); const dataLoader = useMemo(() => new DataLoader(indexPattern, toastNotifications), [ indexPattern, ]); - const fetchColumnChartsData = async function (fieldHistogramsQuery: Record<string, any>) { - const combinedRuntimeMappings = getCombinedRuntimeMappings(indexPattern, runtimeMappings); - try { - const columnChartsData = await dataLoader.loadFieldHistograms( - columns - .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) - .map((cT) => ({ - fieldName: cT.id, - type: getFieldType(cT.schema), - })), - fieldHistogramsQuery, - DEFAULT_SAMPLER_SHARD_SIZE, - combinedRuntimeMappings - ); - dataGrid.setColumnCharts(columnChartsData); - } catch (e) { - showDataGridColumnChartErrorMessageToast(e, toastNotifications); + useEffect(() => { + async function fetchColumnChartsData(fieldHistogramsQuery: Record<string, any>) { + try { + const columnChartsData = await dataLoader.loadFieldHistograms( + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => ({ + fieldName: cT.id, + type: getFieldType(cT.schema), + })), + fieldHistogramsQuery, + DEFAULT_SAMPLER_SHARD_SIZE, + combinedRuntimeMappings + ); + dataGrid.setColumnCharts(columnChartsData); + } catch (e) { + showDataGridColumnChartErrorMessageToast(e, toastNotifications); + } } - }; - useEffect(() => { if (dataGrid.chartsVisible && query !== undefined) { fetchColumnChartsData(query); } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx index f7810c9be27a4..3f7072fba4040 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx @@ -192,6 +192,7 @@ export const ExpandedRow: FC<Props> = ({ item }) => { }), items: stateItems as SectionItem[], position: 'left', + dataTestSubj: 'mlAnalyticsTableRowDetailsSection state', }; const { currentPhase, totalPhases } = getDataFrameAnalyticsProgressPhase(item.stats); @@ -217,6 +218,7 @@ export const ExpandedRow: FC<Props> = ({ item }) => { }), ], position: 'right', + dataTestSubj: 'mlAnalyticsTableRowDetailsSection progress', }; const stats: SectionConfig = { @@ -234,6 +236,7 @@ export const ExpandedRow: FC<Props> = ({ item }) => { { title: 'version', description: item.config.version }, ], position: 'left', + dataTestSubj: 'mlAnalyticsTableRowDetailsSection stats', }; const analysisStats: SectionConfig | undefined = analysisStatsValues @@ -263,6 +266,7 @@ export const ExpandedRow: FC<Props> = ({ item }) => { }), ], position: 'right', + dataTestSubj: 'mlAnalyticsTableRowDetailsSection analysisStats', } : undefined; @@ -364,7 +368,13 @@ export const ExpandedRow: FC<Props> = ({ item }) => { name: i18n.translate('xpack.ml.dataframe.analyticsList.expandedRow.tabs.jobSettingsLabel', { defaultMessage: 'Job details', }), - content: <ExpandedRowDetailsPane sections={detailsSections} />, + content: ( + <ExpandedRowDetailsPane + sections={detailsSections} + dataTestSubj={`mlAnalyticsTableRowDetailsTabContent job-details ${item.config.id}`} + /> + ), + 'data-test-subj': `mlAnalyticsTableRowDetailsTab job-details ${item.config.id}`, }, { id: 'ml-analytics-job-stats', @@ -374,12 +384,24 @@ export const ExpandedRow: FC<Props> = ({ item }) => { defaultMessage: 'Job stats', } ), - content: <ExpandedRowDetailsPane sections={statsSections} />, + content: ( + <ExpandedRowDetailsPane + sections={statsSections} + dataTestSubj={`mlAnalyticsTableRowDetailsTabContent job-stats ${item.config.id}`} + /> + ), + 'data-test-subj': `mlAnalyticsTableRowDetailsTab job-stats ${item.config.id}`, }, { id: 'ml-analytics-job-json', name: 'JSON', - content: <ExpandedRowJsonPane json={item.config} />, + content: ( + <ExpandedRowJsonPane + json={item.config} + dataTestSubj={`mlAnalyticsTableRowDetailsTabContent json ${item.config.id}`} + /> + ), + 'data-test-subj': `mlAnalyticsTableRowDetailsTab json ${item.config.id}`, }, { id: 'ml-analytics-job-messages', @@ -389,7 +411,13 @@ export const ExpandedRow: FC<Props> = ({ item }) => { defaultMessage: 'Job messages', } ), - content: <ExpandedRowMessagesPane analyticsId={item.id} />, + content: ( + <ExpandedRowMessagesPane + analyticsId={item.id} + dataTestSubj={`mlAnalyticsTableRowDetailsTabContent job-messages ${item.config.id}`} + /> + ), + 'data-test-subj': `mlAnalyticsTableRowDetailsTab job-messages ${item.config.id}`, }, ]; @@ -406,6 +434,7 @@ export const ExpandedRow: FC<Props> = ({ item }) => { onTabClick={() => {}} expand={false} style={{ width: '100%' }} + data-test-subj={`mlAnalyticsTableRowDetails-${item.config.id}`} /> ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.tsx index 52234f8f2e4be..426bd89f07cca 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.tsx @@ -19,6 +19,7 @@ export interface SectionConfig { title: string; position: 'left' | 'right'; items: SectionItem[]; + dataTestSubj: string; } interface SectionProps { @@ -44,7 +45,7 @@ export const Section: FC<SectionProps> = ({ section }) => { ]; return ( - <> + <div data-test-subj={section.dataTestSubj}> <EuiTitle size="xs"> <span>{section.title}</span> </EuiTitle> @@ -55,18 +56,23 @@ export const Section: FC<SectionProps> = ({ section }) => { tableCaption={section.title} tableLayout="auto" className="mlExpandedRowDetailsSection" + data-test-subj={`${section.dataTestSubj}-table`} /> - </> + </div> ); }; interface ExpandedRowDetailsPaneProps { sections: SectionConfig[]; + dataTestSubj: string; } -export const ExpandedRowDetailsPane: FC<ExpandedRowDetailsPaneProps> = ({ sections }) => { +export const ExpandedRowDetailsPane: FC<ExpandedRowDetailsPaneProps> = ({ + sections, + dataTestSubj, +}) => { return ( - <EuiFlexGroup className="mlExpandedRowDetails"> + <EuiFlexGroup className="mlExpandedRowDetails" data-test-subj={dataTestSubj}> <EuiFlexItem style={{ width: '50%' }}> {sections .filter((s) => s.position === 'left') diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_json_pane.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_json_pane.tsx index 0fb5dd9491904..eba0ac40e937e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_json_pane.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_json_pane.tsx @@ -11,11 +11,12 @@ import { EuiCodeEditor, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eu interface Props { json: object; + dataTestSubj: string; } -export const ExpandedRowJsonPane: FC<Props> = ({ json }) => { +export const ExpandedRowJsonPane: FC<Props> = ({ json, dataTestSubj }) => { return ( - <EuiFlexGroup> + <EuiFlexGroup data-test-subj={dataTestSubj}> <EuiFlexItem> <EuiSpacer size="s" /> <EuiCodeEditor @@ -24,6 +25,7 @@ export const ExpandedRowJsonPane: FC<Props> = ({ json }) => { mode="json" style={{ width: '100%' }} theme="textmate" + data-test-subj={`mlAnalyticsDetailsJsonPreview`} /> </EuiFlexItem> <EuiFlexItem grow={false}> </EuiFlexItem> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_messages_pane.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_messages_pane.tsx index 4f2d9c302184c..7b90648967f39 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_messages_pane.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_messages_pane.tsx @@ -17,9 +17,10 @@ import { useToastNotificationService } from '../../../../../services/toast_notif interface Props { analyticsId: string; + dataTestSubj: string; } -export const ExpandedRowMessagesPane: FC<Props> = ({ analyticsId }) => { +export const ExpandedRowMessagesPane: FC<Props> = ({ analyticsId, dataTestSubj }) => { const [messages, setMessages] = useState<JobMessage[]>([]); const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(''); @@ -58,7 +59,7 @@ export const ExpandedRowMessagesPane: FC<Props> = ({ analyticsId }) => { useRefreshAnalyticsList({ onRefresh: getMessages }); return ( - <div className="mlExpandedRowJobMessages"> + <div className="mlExpandedRowJobMessages" data-test-subj={dataTestSubj}> <JobMessages messages={messages} loading={isLoading} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js index f8195f5747f7e..c09b4afd03443 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js @@ -8,7 +8,7 @@ import PropTypes from 'prop-types'; import React, { useMemo } from 'react'; -import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useCreateADLinks } from '../../../../components/custom_hooks/use_create_ad_links'; @@ -39,6 +39,15 @@ export function ResultLinks({ jobs }) { const singleMetricDisabledMessage = jobs.length === 1 && jobs[0].isNotSingleMetricViewerJobMessage; + const singleMetricDisabledMessageText = + singleMetricDisabledMessage !== undefined + ? i18n.translate('xpack.ml.jobsList.resultActions.singleMetricDisabledMessageText', { + defaultMessage: 'Disabled because {reason}.', + values: { + reason: singleMetricDisabledMessage, + }, + }) + : undefined; const jobActionsDisabled = jobs.length === 1 && jobs[0].deleting === true; const { createLinkWithUserDefaults } = useCreateADLinks(); const timeSeriesExplorerLink = useMemo( @@ -48,34 +57,44 @@ export function ResultLinks({ jobs }) { const anomalyExplorerLink = useMemo(() => createLinkWithUserDefaults('explorer', jobs), [jobs]); return ( - <React.Fragment> + <EuiFlexGroup + gutterSize="xs" + justifyContent="flexEnd" + alignItems="center" + wrap={false} + direction="row" + responsive={false} + > {singleMetricVisible && ( - <EuiToolTip - position="bottom" - content={singleMetricDisabledMessage ?? openJobsInSingleMetricViewerText} - > + <EuiFlexItem grow={false}> + <EuiToolTip + position="bottom" + content={singleMetricDisabledMessageText ?? openJobsInSingleMetricViewerText} + > + <EuiButtonIcon + href={timeSeriesExplorerLink} + iconType="visLine" + aria-label={openJobsInSingleMetricViewerText} + className="results-button" + isDisabled={singleMetricEnabled === false || jobActionsDisabled === true} + data-test-subj="mlOpenJobsInSingleMetricViewerButton" + /> + </EuiToolTip> + </EuiFlexItem> + )} + <EuiFlexItem grow={false}> + <EuiToolTip position="bottom" content={openJobsInAnomalyExplorerText}> <EuiButtonIcon - href={timeSeriesExplorerLink} - iconType="visLine" - aria-label={openJobsInSingleMetricViewerText} + href={anomalyExplorerLink} + iconType="visTable" + aria-label={openJobsInAnomalyExplorerText} className="results-button" - isDisabled={singleMetricEnabled === false || jobActionsDisabled === true} - data-test-subj="mlOpenJobsInSingleMetricViewerButton" + isDisabled={jobActionsDisabled === true} + data-test-subj="mlOpenJobsInAnomalyExplorerButton" /> </EuiToolTip> - )} - <EuiToolTip position="bottom" content={openJobsInAnomalyExplorerText}> - <EuiButtonIcon - href={anomalyExplorerLink} - iconType="visTable" - aria-label={openJobsInAnomalyExplorerText} - className="results-button" - isDisabled={jobActionsDisabled === true} - data-test-subj="mlOpenJobsInAnomalyExplorerButton" - /> - </EuiToolTip> - <div className="actions-border" /> - </React.Fragment> + </EuiFlexItem> + </EuiFlexGroup> ); } ResultLinks.propTypes = { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js index 5b7a41e572dab..673484f08e196 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js @@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n'; import { EuiLink } from '@elastic/eui'; import { EditAlertRule } from '../../../../../alerting/ml_alerting_flyout'; -export function extractJobDetails(job, basePath) { +export function extractJobDetails(job, basePath, refreshJobList) { if (Object.keys(job).length === 0) { return {}; } @@ -82,7 +82,7 @@ export function extractJobDetails(job, basePath) { }), position: 'right', items: (job.alerting_rules ?? []).map((v) => { - return ['', <EditAlertRule initialAlert={v} />]; + return ['', <EditAlertRule initialAlert={v} onSave={refreshJobList} />]; }), }; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js index c8412a2a83d8a..812d156421c16 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js @@ -55,6 +55,8 @@ export class JobDetailsUI extends Component { </div> ); } else { + const { showFullDetails, refreshJobList } = this.props; + const { general, customUrl, @@ -71,9 +73,8 @@ export class JobDetailsUI extends Component { jobTimingStats, datafeedTimingStats, alertRules, - } = extractJobDetails(job, basePath); + } = extractJobDetails(job, basePath, refreshJobList); - const { showFullDetails, refreshJobList } = this.props; const tabs = [ { id: 'job-settings', diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/_jobs_list.scss b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/_jobs_list.scss index 28b1a4259406a..65d8ccf4ec200 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/_jobs_list.scss +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/_jobs_list.scss @@ -87,16 +87,6 @@ } } - .actions-border { - // SASSTODO: Proper calc - height: 20px; - border-right: $euiBorderThin; - width: 1px; - display: inline-block; - vertical-align: middle; - margin: 0 $euiSizeXS; - } - .job-description { display: inline-block; } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index abd0794ff2c35..bd85420397218 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -277,6 +277,7 @@ export class JobsList extends Component { defaultMessage: 'Actions', }), render: (item) => <ResultLinks jobs={[item]} />, + width: '8%', }, ]; @@ -340,6 +341,7 @@ export class JobsList extends Component { this.props.refreshJobs, this.props.showCreateAlertFlyout ), + width: '40px', }); } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index ac7224b3f3164..214b7616cf927 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -521,6 +521,7 @@ export class JobsListView extends Component { <JobListMlAnomalyAlertFlyout setShowFunction={this.setShowCreateAlertFlyoutFunction} unsetShowFunction={this.unsetShowCreateAlertFlyoutFunction} + onSave={this.onRefreshClick} /> </div> </EuiPageContent> diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/_multi_job_actions.scss b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/_multi_job_actions.scss index 131e4ea12ce50..306e1e9d3584b 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/_multi_job_actions.scss +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/_multi_job_actions.scss @@ -1,10 +1,5 @@ // SASSTODO: This looks like it needs some rewriting for all the pixel values .multi-select-actions { - padding-right: $euiSizeS; - padding-bottom: $euiSizeM; - display: inline-block; - white-space: nowrap; - .actions-border, .actions-border-large { height: 20px; border-right: $euiBorderThin; @@ -16,7 +11,6 @@ .actions-border-large { height: 35px; margin: 0 15px; - margin-top: -5px; } .results-button { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js index 6b3d6bc8971f5..2c73a73b77abe 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js @@ -145,7 +145,7 @@ class MultiJobActionsMenuUI extends Component { ); } - if (this.canCreateMlAlerts) { + if (this.canCreateMlAlerts && this.props.jobs.length === 1) { items.push( <EuiContextMenuItem key="create alert" diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/multi_job_actions.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/multi_job_actions.js index f0251cfe07766..f34bbe0918087 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/multi_job_actions.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/multi_job_actions.js @@ -6,9 +6,9 @@ */ import PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; -import { EuiTitle } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; import { ResultLinks } from '../job_actions'; import { MultiJobActionsMenu } from './actions_menu'; @@ -30,33 +30,47 @@ export class MultiJobActions extends Component { data-test-subj={`mlADJobListMultiSelectActionsArea ${jobsSelected ? 'active' : 'inactive'}`} > {jobsSelected && ( - <Fragment> - <EuiTitle size="s" style={{ display: 'inline' }}> - <h3> - <FormattedMessage - id="xpack.ml.jobsList.multiJobsActions.jobsSelectedLabel" - defaultMessage="{selectedJobsCount, plural, one {# job} other {# jobs}} selected" - values={{ selectedJobsCount: this.props.selectedJobs.length }} - /> - </h3> - </EuiTitle> - <div className="actions-border-large" /> - <ResultLinks jobs={this.props.selectedJobs} /> - - <GroupSelector - jobs={this.props.selectedJobs} - allJobIds={this.props.allJobIds} - refreshJobs={this.props.refreshJobs} - /> - - <MultiJobActionsMenu - jobs={this.props.selectedJobs} - showStartDatafeedModal={this.props.showStartDatafeedModal} - showDeleteJobModal={this.props.showDeleteJobModal} - refreshJobs={this.props.refreshJobs} - showCreateAlertFlyout={this.props.showCreateAlertFlyout} - /> - </Fragment> + <EuiFlexGroup + gutterSize="xs" + alignItems="center" + wrap={false} + direction="row" + responsive={false} + > + <EuiFlexItem grow={false}> + <EuiTitle size="s"> + <h3> + <FormattedMessage + id="xpack.ml.jobsList.multiJobsActions.jobsSelectedLabel" + defaultMessage="{selectedJobsCount, plural, one {# job} other {# jobs}} selected" + values={{ selectedJobsCount: this.props.selectedJobs.length }} + /> + </h3> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <div className="actions-border-large" /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <ResultLinks jobs={this.props.selectedJobs} /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <GroupSelector + jobs={this.props.selectedJobs} + allJobIds={this.props.allJobIds} + refreshJobs={this.props.refreshJobs} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <MultiJobActionsMenu + jobs={this.props.selectedJobs} + showStartDatafeedModal={this.props.showStartDatafeedModal} + showDeleteJobModal={this.props.showDeleteJobModal} + refreshJobs={this.props.refreshJobs} + showCreateAlertFlyout={this.props.showCreateAlertFlyout} + /> + </EuiFlexItem> + </EuiFlexGroup> )} </div> ); diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts index 253868343a2a7..afad043fcc4d1 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts @@ -8,7 +8,7 @@ import { each, find, get, map, reduce, sortBy } from 'lodash'; import { i18n } from '@kbn/i18n'; import { Observable, of } from 'rxjs'; -import { map as mapObservable } from 'rxjs/operators'; +import { catchError, map as mapObservable } from 'rxjs/operators'; import { RecordForInfluencer } from './results_service/results_service'; import { isMappableJob, @@ -29,7 +29,11 @@ import { CriteriaField, MlResultsService } from './results_service'; import { TimefilterContract, TimeRange } from '../../../../../../src/plugins/data/public'; import { CHART_TYPE, ChartType } from '../explorer/explorer_constants'; import type { ChartRecord } from '../explorer/explorer_utils'; -import { RecordsForCriteria, ScheduledEventsByBucket } from './results_service/result_service_rx'; +import { + RecordsForCriteria, + ResultResponse, + ScheduledEventsByBucket, +} from './results_service/result_service_rx'; import { isPopulatedObject } from '../../../common/util/object_utils'; import { AnomalyRecordDoc } from '../../../common/types/anomalies'; import { @@ -60,9 +64,8 @@ interface ChartPoint { numberOfCauses?: number; scheduledEvents?: any[]; } -interface MetricData { +interface MetricData extends ResultResponse { results: Record<string, number>; - success: boolean; } interface SeriesConfig { jobId: JobId; @@ -91,6 +94,8 @@ export interface SeriesConfigWithMetadata extends SeriesConfig { loading?: boolean; chartData?: ChartPoint[] | null; mapData?: Array<ChartRecord | undefined>; + plotEarliest?: number; + plotLatest?: number; } export const isSeriesConfigWithMetadata = (arg: unknown): arg is SeriesConfigWithMetadata => { @@ -545,6 +550,19 @@ export class AnomalyExplorerChartsService { return data; } + function handleError(errorMsg: string, jobId: string): void { + // Group the jobIds by the type of error message + if (!data.errorMessages) { + data.errorMessages = {}; + } + + if (data.errorMessages[errorMsg]) { + data.errorMessages[errorMsg].add(jobId); + } else { + data.errorMessages[errorMsg] = new Set([jobId]); + } + } + // Query 1 - load the raw metric data. function getMetricData( mlResultsService: MlResultsService, @@ -577,6 +595,17 @@ export class AnomalyExplorerChartsService { bucketSpanSeconds * 1000, config.datafeedConfig ) + .pipe( + catchError((error) => { + handleError( + i18n.translate('xpack.ml.timeSeriesJob.metricDataErrorMessage', { + defaultMessage: 'an error occurred while retrieving metric data', + }), + job.job_id + ); + return of({ success: false, results: {}, error }); + }) + ) .toPromise(); } else { // Extract the partition, by, over fields on which to filter. @@ -638,8 +667,15 @@ export class AnomalyExplorerChartsService { }); resolve(obj); }) - .catch((resp) => { - reject(resp); + .catch((error) => { + handleError( + i18n.translate('xpack.ml.timeSeriesJob.modelPlotDataErrorMessage', { + defaultMessage: 'an error occurred while retrieving model plot data', + }), + job.job_id + ); + + reject(error); }); }); } @@ -665,6 +701,17 @@ export class AnomalyExplorerChartsService { range.max, ANOMALIES_MAX_RESULTS ) + .pipe( + catchError((error) => { + handleError( + i18n.translate('xpack.ml.timeSeriesJob.recordsForCriteriaErrorMessage', { + defaultMessage: 'an error occurred while retrieving anomaly records', + }), + config.jobId + ); + return of({ success: false, records: [], error }); + }) + ) .toPromise(); } @@ -683,6 +730,17 @@ export class AnomalyExplorerChartsService { 1, MAX_SCHEDULED_EVENTS ) + .pipe( + catchError((error) => { + handleError( + i18n.translate('xpack.ml.timeSeriesJob.scheduledEventsByBucketErrorMessage', { + defaultMessage: 'an error occurred while retrieving scheduled events', + }), + config.jobId + ); + return of({ success: false, events: {}, error }); + }) + ) .toPromise(); } @@ -707,20 +765,30 @@ export class AnomalyExplorerChartsService { } const datafeedQuery = get(config, 'datafeedConfig.query', null); - return mlResultsService.getEventDistributionData( - Array.isArray(config.datafeedConfig.indices) - ? config.datafeedConfig.indices[0] - : config.datafeedConfig.indices, - splitField, - filterField, - datafeedQuery, - config.metricFunction, - config.metricFieldName, - config.timeField, - range.min, - range.max, - config.bucketSpanSeconds * 1000 - ); + + return mlResultsService + .getEventDistributionData( + Array.isArray(config.datafeedConfig.indices) + ? config.datafeedConfig.indices[0] + : config.datafeedConfig.indices, + splitField, + filterField, + datafeedQuery, + config.metricFunction, + config.metricFieldName, + config.timeField, + range.min, + range.max, + config.bucketSpanSeconds * 1000 + ) + .catch((err) => { + handleError( + i18n.translate('xpack.ml.timeSeriesJob.eventDistributionDataErrorMessage', { + defaultMessage: 'an error occurred while retrieving data', + }), + config.jobId + ); + }); } // first load and wait for required data, @@ -883,20 +951,23 @@ export class AnomalyExplorerChartsService { ); const overallChartLimits = chartLimits(allDataPoints); - data.seriesToPlot = response.map((d, i) => { - return { - ...seriesConfigsForPromises[i], - loading: false, - chartData: processedData[i], - plotEarliest: chartRange.min, - plotLatest: chartRange.max, - selectedEarliest: selectedEarliestMs, - selectedLatest: selectedLatestMs, - chartLimits: USE_OVERALL_CHART_LIMITS - ? overallChartLimits - : chartLimits(processedData[i]), - }; - }); + data.seriesToPlot = response + // Don't show the charts if there was an issue retrieving metric or anomaly data + .filter((r) => r[0]?.success === true && r[1]?.success === true) + .map((d, i) => { + return { + ...seriesConfigsForPromises[i], + loading: false, + chartData: processedData[i], + plotEarliest: chartRange.min, + plotLatest: chartRange.max, + selectedEarliest: selectedEarliestMs, + selectedLatest: selectedLatestMs, + chartLimits: USE_OVERALL_CHART_LIMITS + ? overallChartLimits + : chartLimits(processedData[i]), + }; + }); if (mapData.length) { // push map data in if it's available diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index c31194b58d589..22b47f9c28dd9 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -28,9 +28,11 @@ import { isPopulatedObject } from '../../../../common/util/object_utils'; import { InfluencersFilterQuery } from '../../../../common/types/es_client'; import { RecordForInfluencer } from './results_service'; import { isRuntimeMappings } from '../../../../common'; +import { ErrorType } from '../../../../common/util/errors'; -interface ResultResponse { +export interface ResultResponse { success: boolean; + error?: ErrorType; } export interface MetricData extends ResultResponse { diff --git a/x-pack/plugins/ml/public/application/services/toast_notification_service/toast_notification_service.ts b/x-pack/plugins/ml/public/application/services/toast_notification_service/toast_notification_service.ts index 0acbe31e2cf4d..6b8646fec02b7 100644 --- a/x-pack/plugins/ml/public/application/services/toast_notification_service/toast_notification_service.ts +++ b/x-pack/plugins/ml/public/application/services/toast_notification_service/toast_notification_service.ts @@ -31,7 +31,7 @@ export function toastNotificationServiceProvider(toastNotifications: ToastsStart toastNotifications.addSuccess(toastOrTitle, options); } - function displayErrorToast(error: ErrorType, title?: string) { + function displayErrorToast(error: ErrorType, title?: string, toastLifeTimeMs?: number) { const errorObj = extractErrorProperties(error); toastNotifications.addError(new MLRequestFailure(errorObj, error), { title: @@ -39,6 +39,7 @@ export function toastNotificationServiceProvider(toastNotifications: ToastsStart i18n.translate('xpack.ml.toastNotificationService.errorTitle', { defaultMessage: 'An error has occurred', }), + ...(toastLifeTimeMs ? { toastLifeTimeMs } : {}), }); } diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer_annotations.scss b/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer_annotations.scss index 8c2d139157602..a7186597b4135 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer_annotations.scss +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer_annotations.scss @@ -56,6 +56,9 @@ $mlAnnotationRectDefaultFillOpacity: .05; fill: $euiColorFullShade; transition: fill $euiAnimSpeedFast; + + user-select: none; + } .mlAnnotationText-isBlur { diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts index 8180aaa452148..7a44a0ccdec4d 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts @@ -132,6 +132,19 @@ export function renderAnnotations( const levelHeight = ANNOTATION_LEVEL_HEIGHT; const levels = getAnnotationLevels(focusAnnotationData); + const onAnnotationMouseOver = function (this: object, d: Annotation) { + showFocusChartTooltip(d, this); + }; + + const onAnnotationClick = (d: Annotation) => { + // clear a possible existing annotation previously set for editing before setting the new one. + // this needs to be done explicitly here because a new annotation created using the brush tool + // could still be present in the chart. + annotationUpdatesService.setValue(null); + // set the actual annotation and trigger the flyout + annotationUpdatesService.setValue(d); + }; + const annotations = focusChart .select('.mlAnnotations') .selectAll('g.mlAnnotation') @@ -148,18 +161,9 @@ export function renderAnnotations( .attr('ry', ANNOTATION_RECT_BORDER_RADIUS) .classed('mlAnnotationRect', true) .attr('mask', `url(#${ANNOTATION_MASK_ID})`) - .on('mouseover', function (this: object, d: Annotation) { - showFocusChartTooltip(d, this); - }) - .on('mouseout', () => hideFocusChartTooltip()) - .on('click', (d: Annotation) => { - // clear a possible existing annotation set up for editing before setting the new one. - // this needs to be done explicitly here because a new annotation created using the brush tool - // could still be present in the chart. - annotationUpdatesService.setValue(null); - // set the actual annotation and trigger the flyout - annotationUpdatesService.setValue(d); - }); + .on('mouseover', onAnnotationMouseOver) + .on('mouseout', hideFocusChartTooltip) + .on('click', onAnnotationClick); rects .attr('x', (d: Annotation) => { @@ -196,9 +200,18 @@ export function renderAnnotations( .attr('width', ANNOTATION_TEXT_RECT_WIDTH) .attr('height', ANNOTATION_TEXT_RECT_HEIGHT) .attr('rx', ANNOTATION_RECT_BORDER_RADIUS) - .attr('ry', ANNOTATION_RECT_BORDER_RADIUS); + .attr('ry', ANNOTATION_RECT_BORDER_RADIUS) + .on('mouseover', onAnnotationMouseOver) + .on('mouseout', hideFocusChartTooltip) + .on('click', onAnnotationClick); - texts.enter().append('text').classed('mlAnnotationText', true); + texts + .enter() + .append('text') + .classed('mlAnnotationText', true) + .on('mouseover', onAnnotationMouseOver) + .on('mouseout', hideFocusChartTooltip) + .on('click', onAnnotationClick); function labelXOffset(ts: number) { const earliestMs = focusXScale.domain()[0]; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_chart_data_error/index.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_chart_data_error/index.ts new file mode 100644 index 0000000000000..f25384154cb75 --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_chart_data_error/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 { TimeseriesexplorerChartDataError } from './timeseriesexplorer_chart_data_error'; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_chart_data_error/timeseriesexplorer_chart_data_error.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_chart_data_error/timeseriesexplorer_chart_data_error.tsx new file mode 100644 index 0000000000000..ee9b9e7cd3fc2 --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_chart_data_error/timeseriesexplorer_chart_data_error.tsx @@ -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 { EuiEmptyPrompt } from '@elastic/eui'; +import React from 'react'; + +export const TimeseriesexplorerChartDataError = ({ errorMsg }: { errorMsg: string }) => { + return <EuiEmptyPrompt iconType="alert" title={<h2>{errorMsg}</h2>} />; +}; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 8e5bf249ae283..c33b780631f16 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -34,8 +34,6 @@ import { EuiAccordion, EuiBadge, } from '@elastic/eui'; - -import { getToastNotifications } from '../util/dependency_cache'; import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public'; import { ANOMALIES_TABLE_DEFAULT_QUERY_SIZE } from '../../../common/constants/search'; @@ -87,6 +85,7 @@ import { TimeSeriesChartWithTooltips } from './components/timeseries_chart/times import { aggregationTypeTransform } from '../../../common/util/anomaly_utils'; import { isMetricDetector } from './get_function_description'; import { getViewableDetectors } from './timeseriesexplorer_utils/get_viewable_detectors'; +import { TimeseriesexplorerChartDataError } from './components/timeseriesexplorer_chart_data_error'; // Used to indicate the chart is being plotted across // all partition field values, where the cardinality of the field cannot be @@ -131,6 +130,7 @@ function getTimeseriesexplorerDefaultState() { zoomTo: undefined, zoomFromFocusLoaded: undefined, zoomToFocusLoaded: undefined, + chartDataError: undefined, }; } @@ -151,6 +151,7 @@ export class TimeSeriesExplorer extends React.Component { tableInterval: PropTypes.string, tableSeverity: PropTypes.number, zoom: PropTypes.object, + toastNotificationService: PropTypes.object, }; state = getTimeseriesexplorerDefaultState(); @@ -390,6 +391,13 @@ export class TimeSeriesExplorer extends React.Component { this.props.appStateHandler(APP_STATE_ACTION.SET_FORECAST_ID, forecastId); }; + displayErrorToastMessages = (error, errorMsg) => { + if (this.props.toastNotificationService) { + this.props.toastNotificationService.displayErrorToast(error, errorMsg, 2000); + } + this.setState({ loading: false, chartDataError: errorMsg }); + }; + loadSingleMetricData = (fullRefresh = true) => { const { autoZoomDuration, @@ -426,6 +434,7 @@ export class TimeSeriesExplorer extends React.Component { fullRefresh, loadCounter: currentLoadCounter + 1, loading: true, + chartDataError: undefined, ...(fullRefresh ? { chartDetails: undefined, @@ -558,11 +567,11 @@ export class TimeSeriesExplorer extends React.Component { stateUpdate.contextChartData = fullRangeChartData; finish(counter); }) - .catch((resp) => { - console.log( - 'Time series explorer - error getting metric data from elasticsearch:', - resp - ); + .catch((err) => { + const errorMsg = i18n.translate('xpack.ml.timeSeriesExplorer.metricDataErrorMessage', { + defaultMessage: 'Error getting metric data', + }); + this.displayErrorToastMessages(err, errorMsg); }); // Query 2 - load max record score at same granularity as context chart @@ -581,11 +590,15 @@ export class TimeSeriesExplorer extends React.Component { stateUpdate.swimlaneData = fullRangeRecordScoreData; finish(counter); }) - .catch((resp) => { - console.log( - 'Time series explorer - error getting bucket anomaly scores from elasticsearch:', - resp + .catch((err) => { + const errorMsg = i18n.translate( + 'xpack.ml.timeSeriesExplorer.bucketAnomalyScoresErrorMessage', + { + defaultMessage: 'Error getting bucket anomaly scores', + } ); + + this.displayErrorToastMessages(err, errorMsg); }); // Query 3 - load details on the chart used in the chart title (charting function and entity(s)). @@ -601,10 +614,12 @@ export class TimeSeriesExplorer extends React.Component { stateUpdate.chartDetails = resp.results; finish(counter); }) - .catch((resp) => { - console.log( - 'Time series explorer - error getting entity counts from elasticsearch:', - resp + .catch((err) => { + this.displayErrorToastMessages( + err, + i18n.translate('xpack.ml.timeSeriesExplorer.entityCountsErrorMessage', { + defaultMessage: 'Error getting entity counts', + }) ); }); @@ -633,10 +648,13 @@ export class TimeSeriesExplorer extends React.Component { stateUpdate.contextForecastData = processForecastResults(resp.results); finish(counter); }) - .catch((resp) => { - console.log( - `Time series explorer - error loading data for forecast ID ${selectedForecastId}`, - resp + .catch((err) => { + this.displayErrorToastMessages( + err, + i18n.translate('xpack.ml.timeSeriesExplorer.forecastDataErrorMessage', { + defaultMessage: 'Error loading forecast data for forecast ID {forecastId}', + values: { forecastId: selectedForecastId }, + }) ); }); } @@ -695,8 +713,10 @@ export class TimeSeriesExplorer extends React.Component { }, } ); - const toastNotifications = getToastNotifications(); - toastNotifications.addWarning(warningText); + if (this.props.toastNotificationService) { + this.props.toastNotificationService.displayWarningToast(warningText); + } + detectorIndex = detectors[0].index; } @@ -716,16 +736,17 @@ export class TimeSeriesExplorer extends React.Component { // perhaps due to user's advanced setting using incorrect date-maths const { invalidTimeRangeError } = this.props; if (invalidTimeRangeError) { - const toastNotifications = getToastNotifications(); - toastNotifications.addWarning( - i18n.translate('xpack.ml.timeSeriesExplorer.invalidTimeRangeInUrlCallout', { - defaultMessage: - 'The time filter was changed to the full range for this job due to an invalid default time filter. Check the advanced settings for {field}.', - values: { - field: ANOMALY_DETECTION_DEFAULT_TIME_RANGE, - }, - }) - ); + if (this.props.toastNotificationService) { + this.props.toastNotificationService.displayWarningToast( + i18n.translate('xpack.ml.timeSeriesExplorer.invalidTimeRangeInUrlCallout', { + defaultMessage: + 'The time filter was changed to the full range for this job due to an invalid default time filter. Check the advanced settings for {field}.', + values: { + field: ANOMALY_DETECTION_DEFAULT_TIME_RANGE, + }, + }) + ); + } } // Required to redraw the time series chart when the container is resized. @@ -853,7 +874,8 @@ export class TimeSeriesExplorer extends React.Component { if ( previousProps === undefined || !isEqual(previousProps.bounds, this.props.bounds) || - !isEqual(previousProps.lastRefresh, this.props.lastRefresh) || + (!isEqual(previousProps.lastRefresh, this.props.lastRefresh) && + previousProps.lastRefresh !== 0) || !isEqual(previousProps.selectedDetectorIndex, this.props.selectedDetectorIndex) || !isEqual(previousProps.selectedEntities, this.props.selectedEntities) || previousProps.selectedForecastId !== this.props.selectedForecastId || @@ -938,6 +960,7 @@ export class TimeSeriesExplorer extends React.Component { zoomTo, zoomFromFocusLoaded, zoomToFocusLoaded, + chartDataError, } = this.state; const chartProps = { modelPlotEnabled, @@ -1041,10 +1064,15 @@ export class TimeSeriesExplorer extends React.Component { /> )} + {loading === false && chartDataError !== undefined && ( + <TimeseriesexplorerChartDataError errorMsg={chartDataError} /> + )} + {arePartitioningFieldsProvided && jobs.length > 0 && (fullRefresh === false || loading === false) && - hasResults === false && ( + hasResults === false && + chartDataError === undefined && ( <TimeseriesexplorerNoChartData dataNotChartable={dataNotChartable} entities={entityControls} @@ -1149,6 +1177,7 @@ export class TimeSeriesExplorer extends React.Component { </EuiFlexItem> )} </EuiFlexGroup> + <TimeSeriesChartWithTooltips chartProps={chartProps} contextAggregationInterval={contextAggregationInterval} diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts index 7deb392467df5..a9c1d95d933a9 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts @@ -445,7 +445,8 @@ export class AnalyticsManager { // Check meta data if ( link.isWildcardIndexPattern === false && - (link.meta === undefined || link.meta?.created_by === INDEX_META_DATA_CREATED_BY) + (link.meta === undefined || + link.meta?.created_by.includes(INDEX_META_DATA_CREATED_BY)) ) { rootIndexPattern = nextLinkId; complete = true; diff --git a/x-pack/plugins/observability/public/components/app/layout/with_header.tsx b/x-pack/plugins/observability/public/components/app/layout/with_header.tsx index 648b49440546b..f2d50539395bd 100644 --- a/x-pack/plugins/observability/public/components/app/layout/with_header.tsx +++ b/x-pack/plugins/observability/public/components/app/layout/with_header.tsx @@ -16,7 +16,9 @@ const Page = styled(EuiPage)<EuiPageProps>` const Container = styled.div<{ color?: string }>` overflow-y: hidden; - min-height: calc(100vh - ${(props) => props.theme.eui.euiHeaderChildSize}); + min-height: calc( + 100vh - ${(props) => props.theme.eui.euiHeaderHeight + props.theme.eui.euiHeaderHeight} + ); background: ${(props) => props.color}; `; 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 69b8b6eb89e46..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 @@ -6,18 +6,40 @@ */ import React from 'react'; -import { EuiImage, EuiProgress, EuiSpacer, EuiText } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiSpacer, EuiText } from '@elastic/eui'; import styled from 'styled-components'; -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { INITIATING_VIEW } from '../series_builder/series_builder'; +import { i18n } from '@kbn/i18n'; +import { LOADING_VIEW } from '../series_builder/series_builder'; +import { SeriesUrl } from '../types'; -export function EmptyView({ loading }: { loading: boolean }) { - const { - services: { http }, - } = useKibana(); +export function EmptyView({ + loading, + height, + series, +}: { + loading: boolean; + height: string; + series: SeriesUrl; +}) { + const { dataType, reportType, reportDefinitions } = series ?? {}; + + let emptyMessage = EMPTY_LABEL; + + if (dataType) { + if (reportType) { + if (isEmpty(reportDefinitions)) { + emptyMessage = CHOOSE_REPORT_DEFINITION; + } + } else { + emptyMessage = SELECT_REPORT_TYPE_BELOW; + } + } else { + emptyMessage = SELECTED_DATA_TYPE_FOR_REPORT; + } return ( - <Wrapper> + <Wrapper height={height}> {loading && ( <EuiProgress size="xs" @@ -29,21 +51,44 @@ export function EmptyView({ loading }: { loading: boolean }) { /> )} <EuiSpacer /> - <ImageWrap - alt="Visulization" - url={http!.basePath.prepend(`/plugins/observability/assets/kibana_dashboard_light.svg`)} - /> - <EuiText>{INITIATING_VIEW}</EuiText> + <FlexGroup justifyContent="center" alignItems="center"> + <EuiFlexItem> + <EuiText>{loading ? LOADING_VIEW : emptyMessage}</EuiText> + </EuiFlexItem> + </FlexGroup> </Wrapper> ); } -const ImageWrap = styled(EuiImage)` - opacity: 0.4; -`; - -const Wrapper = styled.div` +const Wrapper = styled.div<{ height: string }>` text-align: center; - height: 550px; + height: ${(props) => props.height}; position: relative; `; + +const FlexGroup = styled(EuiFlexGroup)` + height: 100%; +`; + +export const EMPTY_LABEL = i18n.translate('xpack.observability.expView.seriesBuilder.emptyview', { + defaultMessage: 'Nothing to display.', +}); + +export const CHOOSE_REPORT_DEFINITION = i18n.translate( + 'xpack.observability.expView.seriesBuilder.emptyReportDefinition', + { + defaultMessage: 'Select a report type to create a visualization.', + } +); + +export const SELECT_REPORT_TYPE_BELOW = i18n.translate( + 'xpack.observability.expView.seriesBuilder.selectReportType.empty', + { + defaultMessage: 'Select a report type to create a visualization.', + } +); + +const SELECTED_DATA_TYPE_FOR_REPORT = i18n.translate( + 'xpack.observability.expView.reportType.selectDataType', + { defaultMessage: 'Select a data type to create a visualization.' } +); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts index 5c09dbcff2d91..0e1e1681373cb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts @@ -34,7 +34,7 @@ export const FieldLabels: Record<string, string> = { 'monitor.name': 'Monitor name', 'monitor.type': 'Monitor Type', 'url.port': 'Port', - 'url.full': 'Url', + 'url.full': 'URL', tags: 'Tags', // custom diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts index 67d72a656744c..b5a5169216b7b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts @@ -6,6 +6,7 @@ */ export enum URL_KEYS { + DATA_TYPE = 'dt', OPERATION_TYPE = 'op', REPORT_TYPE = 'rt', SERIES_TYPE = 'st', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts index f9637dc653d2c..c6089b2316784 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts @@ -31,7 +31,7 @@ export const getDefaultConfigs = ({ reportType, seriesId, indexPattern }: Props) case 'kpi-trends': return getKPITrendsLensConfig({ seriesId, indexPattern }); case 'uptime-duration': - return getMonitorDurationConfig({ seriesId }); + return getMonitorDurationConfig({ seriesId, indexPattern }); case 'uptime-pings': return getMonitorPingsConfig({ seriesId, indexPattern }); case 'service-latency': diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts index 56c20289669f4..68d9afc76d51a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts @@ -79,7 +79,7 @@ describe('Lens Attribute', () => { it('should return expected field type for custom field with passed value', function () { lnsAttr = new LensAttributes(mockIndexPattern, reportViewConfig, 'line', [], 'count', { - 'performance.metric': LCP_FIELD, + 'performance.metric': [LCP_FIELD], }); expect(JSON.stringify(lnsAttr.getFieldMeta('performance.metric'))).toEqual( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index 146f488450f3a..3e22c4da6115a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -31,7 +31,7 @@ import { IndexPattern, } from '../../../../../../../../src/plugins/data/common'; import { FieldLabels } from './constants'; -import { DataSeries, UrlFilter } from '../types'; +import { DataSeries, UrlFilter, URLReportDefinition } from '../types'; function getLayerReferenceName(layerId: string) { return `indexpattern-datasource-layer-${layerId}`; @@ -49,7 +49,7 @@ function buildNumberColumn(sourceField: string) { export const parseCustomFieldName = ( sourceField: string, reportViewConfig: DataSeries, - selectedDefinitions: Record<string, string> + selectedDefinitions: URLReportDefinition ) => { let fieldName = sourceField; let columnType; @@ -60,7 +60,7 @@ export const parseCustomFieldName = ( if (customField) { if (selectedDefinitions[fieldName]) { - fieldName = selectedDefinitions[fieldName]; + fieldName = selectedDefinitions[fieldName][0]; if (customField?.options) columnType = customField?.options?.find(({ field }) => field === fieldName)?.columnType; } else if (customField.defaultValue) { @@ -81,7 +81,7 @@ export class LensAttributes { filters: UrlFilter[]; seriesType: SeriesType; reportViewConfig: DataSeries; - reportDefinitions: Record<string, string>; + reportDefinitions: URLReportDefinition; constructor( indexPattern: IndexPattern, @@ -89,7 +89,7 @@ export class LensAttributes { seriesType?: SeriesType, filters?: UrlFilter[], operationType?: OperationType, - reportDefinitions?: Record<string, string> + reportDefinitions?: URLReportDefinition ) { this.indexPattern = indexPattern; this.layers = {}; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_duration_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_duration_config.ts index f27fd4476bfe0..a191a6de4f89a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_duration_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_duration_config.ts @@ -5,14 +5,11 @@ * 2.0. */ -import { DataSeries } from '../../types'; +import { ConfigProps, DataSeries } from '../../types'; import { FieldLabels } from '../constants'; +import { buildExistsFilter } from '../utils'; -interface Props { - seriesId: string; -} - -export function getMonitorDurationConfig({ seriesId }: Props): DataSeries { +export function getMonitorDurationConfig({ seriesId, indexPattern }: ConfigProps): DataSeries { return { id: seriesId, reportType: 'uptime-duration', @@ -29,7 +26,7 @@ export function getMonitorDurationConfig({ seriesId }: Props): DataSeries { }, ], hasOperationType: true, - defaultFilters: ['monitor.type', 'observer.geo.name', 'tags', 'monitor.name', 'monitor.id'], + defaultFilters: ['monitor.type', 'observer.geo.name', 'tags'], breakdowns: [ 'observer.geo.name', 'monitor.name', @@ -38,11 +35,8 @@ export function getMonitorDurationConfig({ seriesId }: Props): DataSeries { 'tags', 'url.port', ], - filters: [], + filters: [...buildExistsFilter('summary.up', indexPattern)], reportDefinitions: [ - { - field: 'monitor.id', - }, { field: 'monitor.name', }, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_pings_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_pings_config.ts index 6ffc400394812..400ef960b1f68 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_pings_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_pings_config.ts @@ -7,8 +7,9 @@ import { ConfigProps, DataSeries } from '../../types'; import { FieldLabels } from '../constants'; +import { buildExistsFilter } from '../utils'; -export function getMonitorPingsConfig({ seriesId }: ConfigProps): DataSeries { +export function getMonitorPingsConfig({ seriesId, indexPattern }: ConfigProps): DataSeries { return { id: seriesId, reportType: 'uptime-pings', @@ -31,17 +32,14 @@ export function getMonitorPingsConfig({ seriesId }: ConfigProps): DataSeries { ], yTitle: 'Pings', hasOperationType: false, - defaultFilters: ['observer.geo.name', 'monitor.type', 'monitor.name', 'monitor.id'], + defaultFilters: ['observer.geo.name', 'monitor.type', 'tags'], breakdowns: ['observer.geo.name', 'monitor.type'], - filters: [], + filters: [...buildExistsFilter('summary.up', indexPattern)], palette: { type: 'palette', name: 'status' }, reportDefinitions: [ { field: 'monitor.name', }, - { - field: 'monitor.id', - }, { field: 'url.full', }, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts index 5d5cdb23d3520..0d79f76be341c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts @@ -19,6 +19,7 @@ export function convertToShortUrl(series: SeriesUrl) { breakdown, filters, reportDefinitions, + dataType, ...restSeries } = series; @@ -29,6 +30,7 @@ export function convertToShortUrl(series: SeriesUrl) { [URL_KEYS.BREAK_DOWN]: breakdown, [URL_KEYS.FILTERS]: filters, [URL_KEYS.REPORT_DEFINITIONS]: reportDefinitions, + [URL_KEYS.DATA_TYPE]: dataType, ...restSeries, }; } @@ -48,7 +50,7 @@ export function createExploratoryViewUrl(allSeries: AllSeries, baseHref = '') { ); } -export function buildPhraseFilter(field: string, value: any, indexPattern: IIndexPattern) { +export function buildPhraseFilter(field: string, value: string, indexPattern: IIndexPattern) { const fieldMeta = indexPattern.fields.find((fieldT) => fieldT.name === field); if (fieldMeta) { return [esFilters.buildPhraseFilter(fieldMeta, value, indexPattern)]; @@ -56,6 +58,14 @@ export function buildPhraseFilter(field: string, value: any, indexPattern: IInde return []; } +export function buildPhrasesFilter(field: string, value: string[], indexPattern: IIndexPattern) { + const fieldMeta = indexPattern.fields.find((fieldT) => fieldT.name === field); + if (fieldMeta) { + return [esFilters.buildPhrasesFilter(fieldMeta, value, indexPattern)]; + } + return []; +} + export function buildExistsFilter(field: string, indexPattern: IIndexPattern) { const fieldMeta = indexPattern.fields.find((fieldT) => fieldT.name === field); if (fieldMeta) { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx index 375c021bc9b56..d95fad758565b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { fireEvent, screen, waitFor } from '@testing-library/dom'; +import { screen, waitFor } from '@testing-library/dom'; import { render, mockUrlStorage, mockCore, mockAppIndexPattern } from './rtl_helpers'; import { ExploratoryView } from './exploratory_view'; import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/test_utils'; @@ -44,38 +44,17 @@ describe('ExploratoryView', () => { await waitFor(() => { screen.getByText(/open in lens/i); screen.getByRole('heading', { name: /exploratory view/i }); - screen.getByRole('img', { name: /visulization/i }); - screen.getByText(/add series/i); - screen.getByText(/no series found, please add a series\./i); - }); - }); - - it('can add, cancel new series', async () => { - render(<ExploratoryView />); - - await fireEvent.click(screen.getByText(/add series/i)); - - await waitFor(() => { - screen.getByText(/open in lens/i); - }); - - await waitFor(() => { - screen.getByText(/select a data type to start building a series\./i); - }); - - await fireEvent.click(screen.getByText(/cancel/i)); - - await waitFor(() => { - screen.getByText(/add series/i); }); }); it('renders lens component when there is series', async () => { mockUrlStorage({ data: { - 'uptime-pings-histogram': { - reportType: 'upp', - breakdown: 'monitor.status', + 'ux-series': { + dataType: 'ux', + reportType: 'pld', + breakdown: 'user_agent.name', + reportDefinitions: { 'service.name': ['elastic-co'] }, time: { from: 'now-15m', to: 'now' }, }, }, @@ -83,11 +62,11 @@ describe('ExploratoryView', () => { render(<ExploratoryView />); + expect(await screen.findByText(/open in lens/i)).toBeInTheDocument(); + expect(await screen.findByText('Performance Distribution')).toBeInTheDocument(); + expect(await screen.findByText(/Lens Embeddable Component/i)).toBeInTheDocument(); + await waitFor(() => { - screen.getByText(/open in lens/i); - screen.getByRole('heading', { name: /uptime pings/i }); - screen.getByText(/uptime-pings-histogram/i); - screen.getByText(/Lens Embeddable Component/i); screen.getByRole('table', { name: /this table contains 1 rows\./i }); }); }); 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 6bc91be876cf7..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 @@ -5,24 +5,30 @@ * 2.0. */ import { i18n } from '@kbn/i18n'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { EuiPanel, EuiTitle } from '@elastic/eui'; +import styled from 'styled-components'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { ExploratoryViewHeader } from './header/header'; -import { SeriesEditor } from './series_editor/series_editor'; import { useUrlStorage } from './hooks/use_url_storage'; import { useLensAttributes } from './hooks/use_lens_attributes'; import { EmptyView } from './components/empty_view'; import { TypedLensByValueInput } from '../../../../../lens/public'; import { useAppIndexPatternContext } from './hooks/use_app_index_pattern'; import { ReportToDataTypeMap } from './configurations/constants'; +import { SeriesBuilder } from './series_builder/series_builder'; export function ExploratoryView() { const { - services: { lens }, + services: { lens, notifications }, } = useKibana<ObservabilityPublicPluginsStart>(); + const seriesBuilderRef = useRef<HTMLDivElement>(null); + const wrapperRef = useRef<HTMLDivElement>(null); + + const [height, setHeight] = useState<string>('100vh'); + const [lensAttributes, setLensAttributes] = useState<TypedLensByValueInput['attributes'] | null>( null ); @@ -31,12 +37,20 @@ export function ExploratoryView() { const LensComponent = lens?.EmbeddableComponent; - const { firstSeriesId: seriesId, firstSeries: series } = useUrlStorage(); + const { firstSeriesId: seriesId, firstSeries: series, setSeries } = useUrlStorage(); const lensAttributesT = useLensAttributes({ seriesId, }); + const setHeightOffset = () => { + if (seriesBuilderRef?.current && wrapperRef.current) { + const headerOffset = wrapperRef.current.getBoundingClientRect().top; + const seriesOffset = seriesBuilderRef.current.getBoundingClientRect().height; + setHeight(`calc(100vh - ${seriesOffset + headerOffset + 40}px)`); + } + }; + useEffect(() => { if (series?.reportType || series?.dataType) { loadIndexPattern({ dataType: series?.dataType ?? ReportToDataTypeMap[series?.reportType] }); @@ -48,22 +62,45 @@ export function ExploratoryView() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(lensAttributesT ?? {}), series?.reportType, series?.time?.from]); + useEffect(() => { + setHeightOffset(); + }); + return ( - <EuiPanel style={{ maxWidth: 1800, minWidth: 800, margin: '0 auto' }}> + <Wrapper> {lens ? ( <> <ExploratoryViewHeader lensAttributes={lensAttributes} seriesId={seriesId} /> - {lensAttributes && seriesId && series?.reportType && series?.time ? ( - <LensComponent - id="exploratoryView" - style={{ height: 550 }} - timeRange={series?.time} - attributes={lensAttributes} - /> - ) : ( - <EmptyView loading={loading} /> - )} - <SeriesEditor /> + <LensWrapper ref={wrapperRef} height={height}> + {lensAttributes && seriesId && series?.reportType && series?.time ? ( + <LensComponent + 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} /> + )} + </LensWrapper> + <SeriesBuilder seriesId={seriesId} seriesBuilderRef={seriesBuilderRef} /> </> ) : ( <EuiTitle> @@ -75,6 +112,21 @@ export function ExploratoryView() { </h2> </EuiTitle> )} - </EuiPanel> + </Wrapper> ); } +const LensWrapper = styled.div<{ height: string }>` + min-height: 400px; + height: ${(props) => props.height}; + + &&& > div { + height: 100%; + } +`; +const Wrapper = styled(EuiPanel)` + max-width: 1800px; + min-width: 800px; + margin: 0 auto; + width: 100%; + overflow-x: auto; +`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx index 4c43c11521722..dec69dc0a7b33 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx @@ -25,6 +25,7 @@ describe('ExploratoryViewHeader', function () { mockUrlStorage({ data: { 'uptime-pings-histogram': { + dataType: 'synthetics', reportType: 'upp', breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, 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 af5657a56a995..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 @@ -7,7 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { EuiBetaBadge, EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { TypedLensByValueInput } from '../../../../../../lens/public'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../../plugin'; @@ -34,7 +34,15 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { {DataViewLabels[series.reportType] ?? i18n.translate('xpack.observability.expView.heading.label', { defaultMessage: 'Exploratory view', + })}{' '} + <EuiBetaBadge + style={{ + verticalAlign: `middle`, + }} + label={i18n.translate('xpack.observability.expView.heading.experimental', { + defaultMessage: 'Experimental', })} + /> </h2> </EuiText> </EuiFlexItem> diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts index a5fce74c4a2f6..dc6b4bd0ec879 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts @@ -6,6 +6,7 @@ */ import { useMemo } from 'react'; +import { isEmpty } from 'lodash'; import { TypedLensByValueInput } from '../../../../../../lens/public'; import { LensAttributes } from '../configurations/lens_attributes'; import { useUrlStorage } from './use_url_storage'; @@ -25,7 +26,7 @@ export const getFiltersFromDefs = ( const rdfFilters = Object.entries(reportDefinitions ?? {}).map(([field, value]) => { return { field, - values: [value], + values: value, }; }) as UrlFilter[]; @@ -46,7 +47,7 @@ export const useLensAttributes = ({ const { indexPattern } = useAppIndexPatternContext(); return useMemo(() => { - if (!indexPattern || !reportType) { + if (!indexPattern || !reportType || isEmpty(reportDefinitions)) { return null; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts index 34f0a7c1a7f86..2605818ed7846 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts @@ -20,19 +20,21 @@ export const useSeriesFilters = ({ seriesId }: { seriesId: string }) => { const filters = series.filters ?? []; const removeFilter = ({ field, value, negate }: UpdateFilter) => { - const filtersN = filters.map((filter) => { - if (filter.field === field) { - if (negate) { - const notValuesN = filter.notValues?.filter((val) => val !== value); - return { ...filter, notValues: notValuesN }; - } else { - const valuesN = filter.values?.filter((val) => val !== value); - return { ...filter, values: valuesN }; + const filtersN = filters + .map((filter) => { + if (filter.field === field) { + if (negate) { + const notValuesN = filter.notValues?.filter((val) => val !== value); + return { ...filter, notValues: notValuesN }; + } else { + const valuesN = filter.values?.filter((val) => val !== value); + return { ...filter, values: valuesN }; + } } - } - return filter; - }); + return filter; + }) + .filter(({ values = [], notValues = [] }) => values.length > 0 || notValues.length > 0); setSeries(seriesId, { ...series, filters: filtersN }); }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx index 139b7faac4a37..498886cc94410 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx @@ -7,7 +7,13 @@ import React, { createContext, useContext, Context } from 'react'; import { IKbnUrlStateStorage } from '../../../../../../../../src/plugins/kibana_utils/public'; -import type { AppDataType, ReportViewTypeId, SeriesUrl, UrlFilter } from '../types'; +import type { + AppDataType, + ReportViewTypeId, + SeriesUrl, + UrlFilter, + URLReportDefinition, +} from '../types'; import { convertToShortUrl } from '../configurations/utils'; import { OperationType, SeriesType } from '../../../../../../lens/public'; import { URL_KEYS } from '../configurations/constants/url_constants'; @@ -26,7 +32,7 @@ export function UrlStorageContextProvider({ } function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { - const { op, st, rt, bd, ft, time, rdf, ...restSeries } = newValue; + const { dt, op, st, rt, bd, ft, time, rdf, ...restSeries } = newValue; return { operationType: op, reportType: rt!, @@ -35,6 +41,7 @@ function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { filters: ft!, time: time!, reportDefinitions: rdf, + dataType: dt!, ...restSeries, }; } @@ -42,15 +49,15 @@ function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { interface ShortUrlSeries { [URL_KEYS.OPERATION_TYPE]?: OperationType; [URL_KEYS.REPORT_TYPE]?: ReportViewTypeId; + [URL_KEYS.DATA_TYPE]?: AppDataType; [URL_KEYS.SERIES_TYPE]?: SeriesType; [URL_KEYS.BREAK_DOWN]?: string; [URL_KEYS.FILTERS]?: UrlFilter[]; - [URL_KEYS.REPORT_DEFINITIONS]?: Record<string, string>; + [URL_KEYS.REPORT_DEFINITIONS]?: URLReportDefinition; time?: { to: string; from: string; }; - dataType?: AppDataType; } export type AllShortSeries = Record<string, ShortUrlSeries>; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx index 9fe133098549c..a7edfdea09549 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx @@ -5,12 +5,12 @@ * 2.0. */ -import React, { useContext } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; import { useHistory } from 'react-router-dom'; -import { ThemeContext } from 'styled-components'; import { ExploratoryView } from './exploratory_view'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { useBreadcrumbs } from '../../../hooks/use_breadcrumbs'; import { IndexPatternContextProvider } from './hooks/use_app_index_pattern'; @@ -19,9 +19,12 @@ import { withNotifyOnErrors, } from '../../../../../../../src/plugins/kibana_utils/public/'; import { UrlStorageContextProvider } from './hooks/use_url_storage'; -import { WithHeaderLayout } from '../../app/layout/with_header'; +import { useTrackPageview } from '../../..'; export function ExploratoryViewPage() { + useTrackPageview({ app: 'observability-overview', path: 'exploratory-view' }); + useTrackPageview({ app: 'observability-overview', path: 'exploratory-view', delay: 15000 }); + useBreadcrumbs([ { text: i18n.translate('xpack.observability.overview.exploratoryView', { @@ -30,8 +33,6 @@ export function ExploratoryViewPage() { }, ]); - const theme = useContext(ThemeContext); - const { services: { uiSettings, notifications }, } = useKibana<ObservabilityPublicPluginsStart>(); @@ -45,15 +46,16 @@ export function ExploratoryViewPage() { }); return ( - <WithHeaderLayout - headerColor={theme.eui.euiColorEmptyShade} - bodyColor={theme.eui.euiPageBackgroundColor} - > + <Wrapper> <IndexPatternContextProvider> <UrlStorageContextProvider storage={kbnUrlStateStorage}> <ExploratoryView /> </UrlStorageContextProvider> </IndexPatternContextProvider> - </WithHeaderLayout> + </Wrapper> ); } + +const Wrapper = euiStyled.div` + padding: ${(props) => props.theme.eui.paddingSizes.l}; +`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx index 7763e8ad71222..beb1daafbd55f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx @@ -268,6 +268,7 @@ export const mockUrlStorage = ({ const mockDataSeries = data || { 'performance-distribution': { reportType: 'pld', + dataType: 'ux', breakdown: breakdown || 'user_agent.name', time: { from: 'now-15m', to: 'now' }, ...(filters ? { filters } : {}), 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/data_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx index cf5d08b4110d7..9348fcbe15f6c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx @@ -9,13 +9,14 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { mockAppIndexPattern, mockUrlStorage, render } from '../../rtl_helpers'; import { dataTypes, DataTypesCol } from './data_types_col'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; describe('DataTypesCol', function () { + const seriesId = 'test-series-id'; + mockAppIndexPattern(); it('should render properly', function () { - const { getByText } = render(<DataTypesCol />); + const { getByText } = render(<DataTypesCol seriesId={seriesId} />); dataTypes.forEach(({ label }) => { getByText(label); @@ -25,18 +26,18 @@ describe('DataTypesCol', function () { it('should set series on change', function () { const { setSeries } = mockUrlStorage({}); - render(<DataTypesCol />); + render(<DataTypesCol seriesId={seriesId} />); - fireEvent.click(screen.getByText(/user experience\(rum\)/i)); + fireEvent.click(screen.getByText(/user experience \(rum\)/i)); expect(setSeries).toHaveBeenCalledTimes(1); - expect(setSeries).toHaveBeenCalledWith(NEW_SERIES_KEY, { dataType: 'ux' }); + expect(setSeries).toHaveBeenCalledWith(seriesId, { dataType: 'ux' }); }); it('should set series on change on already selected', function () { mockUrlStorage({ data: { - [NEW_SERIES_KEY]: { + [seriesId]: { dataType: 'synthetics', reportType: 'upp', breakdown: 'monitor.status', @@ -45,7 +46,7 @@ describe('DataTypesCol', function () { }, }); - render(<DataTypesCol />); + render(<DataTypesCol seriesId={seriesId} />); const button = screen.getByRole('button', { name: /Synthetic Monitoring/i, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx index 79008d91b294d..9d15206db1e62 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx @@ -10,30 +10,31 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; import { AppDataType } from '../../types'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_storage'; +import { useUrlStorage } from '../../hooks/use_url_storage'; +import { ReportToDataTypeMap } from '../../configurations/constants'; export const dataTypes: Array<{ id: AppDataType; label: string }> = [ { id: 'synthetics', label: 'Synthetic Monitoring' }, - { id: 'ux', label: 'User Experience(RUM)' }, + { id: 'ux', label: 'User Experience (RUM)' }, // { id: 'infra_logs', label: 'Logs' }, // { id: 'infra_metrics', label: 'Metrics' }, // { id: 'apm', label: 'APM' }, ]; -export function DataTypesCol() { - const { series, setSeries, removeSeries } = useUrlStorage(NEW_SERIES_KEY); +export function DataTypesCol({ seriesId }: { seriesId: string }) { + const { series, setSeries, removeSeries } = useUrlStorage(seriesId); const { loading } = useAppIndexPatternContext(); const onDataTypeChange = (dataType?: AppDataType) => { if (!dataType) { - removeSeries(NEW_SERIES_KEY); + removeSeries(seriesId); } else { - setSeries(NEW_SERIES_KEY, { dataType } as any); + setSeries(seriesId || `${dataType}-series`, { dataType } as any); } }; - const selectedDataType = series.dataType; + const selectedDataType = series.dataType ?? ReportToDataTypeMap[series.reportType]; return ( <FlexGroup direction="column" gutterSize="xs"> diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/date_picker_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/date_picker_col.tsx index a245d39cee089..175fbea9445c1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/date_picker_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/date_picker_col.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import styled from 'styled-components'; import { SeriesDatePicker } from '../../series_date_picker'; interface Props { @@ -13,8 +14,17 @@ interface Props { } export function DatePickerCol({ seriesId }: Props) { return ( - <div> + <Wrapper> <SeriesDatePicker seriesId={seriesId} /> - </div> + </Wrapper> ); } + +const Wrapper = styled.div` + .euiSuperDatePicker__flexWrapper { + width: 100%; + > .euiFlexItem { + margin-right: 0px; + } + } +`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx index e05f91b4bb0bd..9550b8e98103b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx @@ -21,6 +21,7 @@ describe('OperationTypeSelect', function () { mockUrlStorage({ data: { 'performance-distribution': { + dataType: 'ux', reportType: 'kpi', operationType: 'median', time: { from: 'now-15m', to: 'now' }, @@ -37,6 +38,7 @@ describe('OperationTypeSelect', function () { const { setSeries } = mockUrlStorage({ data: { 'series-id': { + dataType: 'ux', reportType: 'kpi', operationType: 'median', time: { from: 'now-15m', to: 'now' }, @@ -50,6 +52,7 @@ describe('OperationTypeSelect', function () { expect(setSeries).toHaveBeenCalledWith('series-id', { operationType: 'median', + dataType: 'ux', reportType: 'kpi', time: { from: 'now-15m', to: 'now' }, }); @@ -57,6 +60,7 @@ describe('OperationTypeSelect', function () { fireEvent.click(screen.getByText('95th Percentile')); expect(setSeries).toHaveBeenCalledWith('series-id', { operationType: '95th', + dataType: 'ux', reportType: 'kpi', time: { from: 'now-15m', to: 'now' }, }); 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_breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx index 63cabeabc2ced..3363d17d81eab 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx @@ -10,21 +10,21 @@ import { fireEvent, screen } from '@testing-library/react'; import { render } from '../../../../../utils/test_helper'; import { getDefaultConfigs } from '../../configurations/default_configs'; import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; import { ReportBreakdowns } from './report_breakdowns'; import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; describe('Series Builder ReportBreakdowns', function () { + const seriesId = 'test-series-id'; const dataViewSeries = getDefaultConfigs({ + seriesId, reportType: 'pld', indexPattern: mockIndexPattern, - seriesId: NEW_SERIES_KEY, }); it('should render properly', function () { mockUrlStorage({}); - render(<ReportBreakdowns dataViewSeries={dataViewSeries} />); + render(<ReportBreakdowns dataViewSeries={dataViewSeries} seriesId={seriesId} />); screen.getByText('Select an option: , is selected'); screen.getAllByText('Browser family'); @@ -33,7 +33,7 @@ describe('Series Builder ReportBreakdowns', function () { it('should set new series breakdown on change', function () { const { setSeries } = mockUrlStorage({}); - render(<ReportBreakdowns dataViewSeries={dataViewSeries} />); + render(<ReportBreakdowns dataViewSeries={dataViewSeries} seriesId={seriesId} />); const btn = screen.getByRole('button', { name: /select an option: Browser family , is selected/i, @@ -45,8 +45,9 @@ describe('Series Builder ReportBreakdowns', function () { fireEvent.click(screen.getByText(/operating system/i)); expect(setSeries).toHaveBeenCalledTimes(1); - expect(setSeries).toHaveBeenCalledWith(NEW_SERIES_KEY, { + expect(setSeries).toHaveBeenCalledWith(seriesId, { breakdown: USER_AGENT_OS, + dataType: 'ux', reportType: 'pld', time: { from: 'now-15m', to: 'now' }, }); @@ -54,7 +55,7 @@ describe('Series Builder ReportBreakdowns', function () { it('should set undefined on new series on no select breakdown', function () { const { setSeries } = mockUrlStorage({}); - render(<ReportBreakdowns dataViewSeries={dataViewSeries} />); + render(<ReportBreakdowns dataViewSeries={dataViewSeries} seriesId={seriesId} />); const btn = screen.getByRole('button', { name: /select an option: Browser family , is selected/i, @@ -66,8 +67,9 @@ describe('Series Builder ReportBreakdowns', function () { fireEvent.click(screen.getByText(/no breakdown/i)); expect(setSeries).toHaveBeenCalledTimes(1); - expect(setSeries).toHaveBeenCalledWith(NEW_SERIES_KEY, { + expect(setSeries).toHaveBeenCalledWith(seriesId, { breakdown: undefined, + dataType: 'ux', reportType: 'pld', time: { from: 'now-15m', to: 'now' }, }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx index 619e2ec4fe9b0..162892071dbff 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx @@ -7,9 +7,14 @@ import React from 'react'; import { Breakdowns } from '../../series_editor/columns/breakdowns'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; import { DataSeries } from '../../types'; -export function ReportBreakdowns({ dataViewSeries }: { dataViewSeries: DataSeries }) { - return <Breakdowns breakdowns={dataViewSeries.breakdowns ?? []} seriesId={NEW_SERIES_KEY} />; +export function ReportBreakdowns({ + seriesId, + dataViewSeries, +}: { + dataViewSeries: DataSeries; + seriesId: string; +}) { + return <Breakdowns breakdowns={dataViewSeries.breakdowns ?? []} seriesId={seriesId} />; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx index 91786a2f48fab..27adcf4682c02 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx @@ -15,32 +15,34 @@ import { mockUseValuesList, render, } from '../../rtl_helpers'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; import { ReportDefinitionCol } from './report_definition_col'; import { SERVICE_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; describe('Series Builder ReportDefinitionCol', function () { mockAppIndexPattern(); + const seriesId = 'test-series-id'; const dataViewSeries = getDefaultConfigs({ + seriesId, reportType: 'pld', indexPattern: mockIndexPattern, - seriesId: NEW_SERIES_KEY, }); const { setSeries } = mockUrlStorage({ data: { - 'performance-dist': { + [seriesId]: { dataType: 'ux', reportType: 'pld', time: { from: 'now-30d', to: 'now' }, - reportDefinitions: { [SERVICE_NAME]: 'elastic-co' }, + reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] }, }, }, }); + mockUseValuesList(['elastic-co']); + it('should render properly', async function () { - render(<ReportDefinitionCol dataViewSeries={dataViewSeries} />); + render(<ReportDefinitionCol dataViewSeries={dataViewSeries} seriesId={seriesId} />); screen.getByText('Web Application'); screen.getByText('Environment'); @@ -48,36 +50,33 @@ describe('Series Builder ReportDefinitionCol', function () { screen.getByText('Page load time'); }); - it('should render selected report definitions', function () { - render(<ReportDefinitionCol dataViewSeries={dataViewSeries} />); + it('should render selected report definitions', async function () { + render(<ReportDefinitionCol dataViewSeries={dataViewSeries} seriesId={seriesId} />); + + expect(await screen.findByText('elastic-co')).toBeInTheDocument(); - screen.getByText('elastic-co'); + expect(screen.getAllByTestId('comboBoxToggleListButton')[0]).toBeInTheDocument(); }); - it('should be able to remove selected definition', function () { - render(<ReportDefinitionCol dataViewSeries={dataViewSeries} />); + it('should be able to remove selected definition', async function () { + render(<ReportDefinitionCol dataViewSeries={dataViewSeries} seriesId={seriesId} />); + + expect( + await screen.findByLabelText('Remove elastic-co from selection in this group') + ).toBeInTheDocument(); - const removeBtn = screen.getByText(/elastic-co/i); + fireEvent.click(screen.getAllByTestId('comboBoxToggleListButton')[0]); + + const removeBtn = await screen.findByTitle(/Remove elastic-co from selection in this group/i); fireEvent.click(removeBtn); expect(setSeries).toHaveBeenCalledTimes(1); - expect(setSeries).toHaveBeenCalledWith(NEW_SERIES_KEY, { + expect(setSeries).toHaveBeenCalledWith(seriesId, { dataType: 'ux', reportDefinitions: {}, reportType: 'pld', time: { from: 'now-30d', to: 'now' }, }); }); - - it('should be able to unselected selected definition', async function () { - mockUseValuesList(['elastic-co']); - render(<ReportDefinitionCol dataViewSeries={dataViewSeries} />); - - const definitionBtn = screen.getByText(/web application/i); - - fireEvent.click(definitionBtn); - - screen.getByText('Apply'); - }); }); 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 0351508ebb59e..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 @@ -6,19 +6,19 @@ */ import React from 'react'; -import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_storage'; +import { useUrlStorage } from '../../hooks/use_url_storage'; import { CustomReportField } from '../custom_report_field'; -import FieldValueSuggestions from '../../../field_value_suggestions'; -import { DataSeries } from '../../types'; +import { DataSeries, URLReportDefinition } from '../../types'; import { SeriesChartTypesSelect } from './chart_types'; import { OperationTypeSelect } from './operation_type_select'; import { DatePickerCol } from './date_picker_col'; import { parseCustomFieldName } from '../../configurations/lens_attributes'; +import { ReportDefinitionField } from './report_definition_field'; -function getColumnType(dataView: DataSeries, selectedDefinition: Record<string, string>) { +function getColumnType(dataView: DataSeries, selectedDefinition: URLReportDefinition) { const { reportDefinitions } = dataView; const customColumn = reportDefinitions.find((item) => item.custom); if (customColumn?.field && selectedDefinition[customColumn?.field]) { @@ -29,108 +29,73 @@ function getColumnType(dataView: DataSeries, selectedDefinition: Record<string, return null; } -const MaxWidthStyle = { maxWidth: 250 }; - -export function ReportDefinitionCol({ dataViewSeries }: { dataViewSeries: DataSeries }) { +export function ReportDefinitionCol({ + dataViewSeries, + seriesId, +}: { + dataViewSeries: DataSeries; + seriesId: string; +}) { const { indexPattern } = useAppIndexPatternContext(); - const { series, setSeries } = useUrlStorage(NEW_SERIES_KEY); + const { series, setSeries } = useUrlStorage(seriesId); - const { reportDefinitions: rtd = {} } = series; + const { reportDefinitions: selectedReportDefinitions = {} } = series; - const { - reportDefinitions, - labels, - filters, - defaultSeriesType, - hasOperationType, - yAxisColumns, - } = dataViewSeries; + const { reportDefinitions, defaultSeriesType, hasOperationType, yAxisColumns } = dataViewSeries; - const onChange = (field: string, value?: string) => { - if (!value) { - delete rtd[field]; - setSeries(NEW_SERIES_KEY, { + const onChange = (field: string, value?: string[]) => { + if (!value?.[0]) { + delete selectedReportDefinitions[field]; + setSeries(seriesId, { ...series, - reportDefinitions: { ...rtd }, + reportDefinitions: { ...selectedReportDefinitions }, }); } else { - setSeries(NEW_SERIES_KEY, { + setSeries(seriesId, { ...series, - reportDefinitions: { ...rtd, [field]: value }, + reportDefinitions: { ...selectedReportDefinitions, [field]: value }, }); } }; - const onRemove = (field: string) => { - delete rtd[field]; - setSeries(NEW_SERIES_KEY, { - ...series, - reportDefinitions: rtd, - }); - }; - - const columnType = getColumnType(dataViewSeries, rtd); + const columnType = getColumnType(dataViewSeries, selectedReportDefinitions); return ( <FlexGroup direction="column" gutterSize="s"> <EuiFlexItem> - <DatePickerCol seriesId={NEW_SERIES_KEY} /> + <DatePickerCol seriesId={seriesId} /> </EuiFlexItem> {indexPattern && reportDefinitions.map(({ field, custom, options, defaultValue }) => ( <EuiFlexItem key={field}> {!custom ? ( - <EuiFlexGroup justifyContent="flexStart" gutterSize="s" alignItems="center" wrap> - <EuiFlexItem grow={false} style={{ flexBasis: 250 }}> - <FieldValueSuggestions - label={labels[field]} - sourceField={field} - indexPattern={indexPattern} - value={rtd?.[field]} - onChange={(val?: string) => onChange(field, val)} - filters={(filters ?? []).map(({ query }) => query)} - time={series.time} - fullWidth={true} - /> - </EuiFlexItem> - {rtd?.[field] && ( - <EuiFlexItem grow={false}> - <EuiBadge - className="globalFilterItem" - iconSide="right" - iconType="cross" - color="hollow" - onClick={() => onRemove(field)} - iconOnClick={() => onRemove(field)} - iconOnClickAriaLabel={'Click to remove'} - onClickAriaLabel={'Click to remove'} - > - {rtd?.[field]} - </EuiBadge> - </EuiFlexItem> - )} - </EuiFlexGroup> + <ReportDefinitionField + seriesId={seriesId} + dataSeries={dataViewSeries} + field={field} + onChange={onChange} + /> ) : ( <CustomReportField field={field} options={options} defaultValue={defaultValue} - seriesId={NEW_SERIES_KEY} + seriesId={seriesId} /> )} </EuiFlexItem> ))} {(hasOperationType || columnType === 'operation') && ( - <EuiFlexItem style={MaxWidthStyle}> + <EuiFlexItem> <OperationTypeSelect - seriesId={NEW_SERIES_KEY} + seriesId={seriesId} defaultOperationType={yAxisColumns[0].operationType} /> </EuiFlexItem> )} - <EuiFlexItem style={MaxWidthStyle}> - <SeriesChartTypesSelect seriesId={NEW_SERIES_KEY} defaultChartType={defaultSeriesType} /> + <EuiFlexItem> + <SeriesChartTypesSelect seriesId={seriesId} defaultChartType={defaultSeriesType} /> </EuiFlexItem> </FlexGroup> ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx new file mode 100644 index 0000000000000..9f92bec4d1f9c --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import FieldValueSuggestions from '../../../field_value_suggestions'; +import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; +import { ESFilter } from '../../../../../../../../../typings/elasticsearch'; +import { PersistableFilter } from '../../../../../../../lens/common'; +import { ExistsFilter } from '../../../../../../../../../src/plugins/data/common/es_query/filters'; +import { buildPhrasesFilter } from '../../configurations/utils'; +import { DataSeries } from '../../types'; + +interface Props { + seriesId: string; + field: string; + dataSeries: DataSeries; + onChange: (field: string, value?: string[]) => void; +} + +export function ReportDefinitionField({ seriesId, field, dataSeries, onChange }: Props) { + const { series } = useUrlStorage(seriesId); + + const { indexPattern } = useAppIndexPatternContext(); + + const { reportDefinitions: selectedReportDefinitions = {} } = series; + + const { labels, filters, reportDefinitions } = dataSeries; + + const queryFilters = useMemo(() => { + const filtersN: ESFilter[] = []; + (filters ?? []).forEach((qFilter: PersistableFilter | ExistsFilter) => { + if (qFilter.query) { + filtersN.push(qFilter.query); + } + const existFilter = qFilter as ExistsFilter; + if (existFilter.exists) { + filtersN.push({ exists: existFilter.exists }); + } + }); + + if (!isEmpty(selectedReportDefinitions)) { + reportDefinitions.forEach(({ field: fieldT, custom }) => { + if (!custom && selectedReportDefinitions?.[fieldT] && fieldT !== field) { + const values = selectedReportDefinitions?.[fieldT]; + const valueFilter = buildPhrasesFilter(fieldT, values, indexPattern)[0]; + filtersN.push(valueFilter.query); + } + }); + } + + return filtersN; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(selectedReportDefinitions), JSON.stringify(filters)]); + + return ( + <EuiFlexGroup justifyContent="flexStart" gutterSize="s" alignItems="center" wrap> + <EuiFlexItem> + <FieldValueSuggestions + label={labels[field]} + sourceField={field} + indexPattern={indexPattern} + selectedValue={selectedReportDefinitions?.[field]} + onChange={(val?: string[]) => onChange(field, val)} + filters={queryFilters} + time={series.time} + fullWidth={true} + /> + </EuiFlexItem> + </EuiFlexGroup> + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx index 4c9b5827e2bde..1467cb54d648a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx @@ -11,17 +11,18 @@ import { render } from '../../../../../utils/test_helper'; import { ReportFilters } from './report_filters'; import { getDefaultConfigs } from '../../configurations/default_configs'; import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; describe('Series Builder ReportFilters', function () { + const seriesId = 'test-series-id'; + const dataViewSeries = getDefaultConfigs({ + seriesId, reportType: 'pld', indexPattern: mockIndexPattern, - seriesId: NEW_SERIES_KEY, }); mockUrlStorage({}); it('should render properly', function () { - render(<ReportFilters dataViewSeries={dataViewSeries} />); + render(<ReportFilters dataViewSeries={dataViewSeries} seriesId={seriesId} />); screen.getByText('Add filter'); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx index 24b24b76af1f6..9687f1bea4ec9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx @@ -7,15 +7,20 @@ import React from 'react'; import { SeriesFilter } from '../../series_editor/columns/series_filter'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; import { DataSeries } from '../../types'; -export function ReportFilters({ dataViewSeries }: { dataViewSeries: DataSeries }) { +export function ReportFilters({ + dataViewSeries, + seriesId, +}: { + dataViewSeries: DataSeries; + seriesId: string; +}) { return ( <SeriesFilter series={dataViewSeries} defaultFilters={dataViewSeries.defaultFilters} - seriesId={NEW_SERIES_KEY} + seriesId={seriesId} isNew={true} /> ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx index c5b8f1147af54..20c4ea98d482d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx @@ -14,27 +14,30 @@ import { DEFAULT_TIME } from '../../configurations/constants'; import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; describe('ReportTypesCol', function () { + const seriesId = 'test-series-id'; + mockAppIndexPattern(); it('should render properly', function () { - render(<ReportTypesCol reportTypes={ReportTypes.ux} />); + render(<ReportTypesCol reportTypes={ReportTypes.ux} seriesId={seriesId} />); screen.getByText('Performance distribution'); screen.getByText('KPI over time'); }); it('should display empty message', function () { - render(<ReportTypesCol reportTypes={[]} />); + render(<ReportTypesCol reportTypes={[]} seriesId={seriesId} />); screen.getByText(SELECTED_DATA_TYPE_FOR_REPORT); }); it('should set series on change', function () { const { setSeries } = mockUrlStorage({}); - render(<ReportTypesCol reportTypes={ReportTypes.synthetics} />); + render(<ReportTypesCol reportTypes={ReportTypes.synthetics} seriesId={seriesId} />); fireEvent.click(screen.getByText(/monitor duration/i)); - expect(setSeries).toHaveBeenCalledWith(NEW_SERIES_KEY, { + expect(setSeries).toHaveBeenCalledWith(seriesId, { breakdown: 'user_agent.name', + dataType: 'ux', reportDefinitions: {}, reportType: 'upd', time: { from: 'now-15m', to: 'now' }, @@ -54,7 +57,7 @@ describe('ReportTypesCol', function () { }, }); - render(<ReportTypesCol reportTypes={ReportTypes.synthetics} />); + render(<ReportTypesCol reportTypes={ReportTypes.synthetics} seriesId={seriesId} />); const button = screen.getByRole('button', { name: /pings histogram/i, @@ -64,7 +67,7 @@ describe('ReportTypesCol', function () { fireEvent.click(button); // undefined on click selected - expect(setSeries).toHaveBeenCalledWith(NEW_SERIES_KEY, { + expect(setSeries).toHaveBeenCalledWith(seriesId, { dataType: 'synthetics', time: DEFAULT_TIME, }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx index b8ab1c80009d9..9c95b3874c242 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx @@ -11,19 +11,20 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import styled from 'styled-components'; import { ReportViewTypeId, SeriesUrl } from '../../types'; -import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_storage'; +import { useUrlStorage } from '../../hooks/use_url_storage'; import { DEFAULT_TIME } from '../../configurations/constants'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; interface Props { + seriesId: string; reportTypes: Array<{ id: ReportViewTypeId; label: string }>; } -export function ReportTypesCol({ reportTypes }: Props) { +export function ReportTypesCol({ seriesId, reportTypes }: Props) { const { series: { reportType: selectedReportType, ...restSeries }, setSeries, - } = useUrlStorage(NEW_SERIES_KEY); + } = useUrlStorage(seriesId); const { loading, hasData, selectedApp } = useAppIndexPatternContext(); @@ -50,14 +51,15 @@ export function ReportTypesCol({ reportTypes }: Props) { isDisabled={loading} onClick={() => { if (reportType === selectedReportType) { - setSeries(NEW_SERIES_KEY, { + setSeries(seriesId, { dataType: restSeries.dataType, time: DEFAULT_TIME, } as SeriesUrl); } else { - setSeries(NEW_SERIES_KEY, { + setSeries(seriesId, { ...restSeries, reportType, + operationType: undefined, reportDefinitions: {}, time: restSeries?.time ?? DEFAULT_TIME, }); @@ -76,7 +78,7 @@ export function ReportTypesCol({ reportTypes }: Props) { export const SELECTED_DATA_TYPE_FOR_REPORT = i18n.translate( 'xpack.observability.expView.reportType.noDataType', - { defaultMessage: 'Select a data type to start building a series.' } + { defaultMessage: 'No data type selected.' } ); const FlexGroup = styled(EuiFlexGroup)` 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 b630ee55b5780..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 @@ -23,7 +23,7 @@ export function CustomReportField({ field, seriesId, options: opts, defaultValue const { reportDefinitions: rtd = {} } = series; const onChange = (value: string) => { - setSeries(seriesId, { ...series, reportDefinitions: { ...rtd, [field]: value } }); + setSeries(seriesId, { ...series, reportDefinitions: { ...rtd, [field]: [value] } }); }; const { reportDefinitions } = series; @@ -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, description }) => ({ - value: fd, - inputDisplay: label, - }))} - valueOfSelected={reportDefinitions?.[field] || 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_builder/series_builder.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx index db6e075cc90fb..5e270524b1880 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx @@ -5,10 +5,10 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { RefObject } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButton, EuiBasicTable, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiBasicTable } from '@elastic/eui'; import { AppDataType, ReportViewTypeId, ReportViewTypes, SeriesUrl } from '../types'; import { DataTypesCol } from './columns/data_types_col'; import { ReportTypesCol } from './columns/report_types_col'; @@ -45,8 +45,14 @@ export const ReportTypes: Record<AppDataType, Array<{ id: ReportViewTypeId; labe ], }; -export function SeriesBuilder() { - const { series, setSeries, allSeriesIds, removeSeries } = useUrlStorage(NEW_SERIES_KEY); +export function SeriesBuilder({ + seriesBuilderRef, + seriesId, +}: { + seriesId: string; + seriesBuilderRef: RefObject<HTMLDivElement>; +}) { + const { series, setSeries, removeSeries } = useUrlStorage(seriesId); const { dataType, @@ -59,29 +65,23 @@ export function SeriesBuilder() { time, } = series; - const [isFlyoutVisible, setIsFlyoutVisible] = useState(!!series.dataType); - const { indexPattern, loading, hasData } = useAppIndexPatternContext(); const getDataViewSeries = () => { return getDefaultConfigs({ + seriesId, indexPattern, reportType: reportType!, - seriesId: NEW_SERIES_KEY, }); }; - useEffect(() => { - setIsFlyoutVisible(!!series.dataType); - }, [series.dataType]); - const columns = [ { name: i18n.translate('xpack.observability.expView.seriesBuilder.dataType', { defaultMessage: 'Data Type', }), width: '15%', - render: (val: string) => <DataTypesCol />, + render: (val: string) => <DataTypesCol seriesId={seriesId} />, }, { name: i18n.translate('xpack.observability.expView.seriesBuilder.report', { @@ -89,7 +89,7 @@ export function SeriesBuilder() { }), width: '15%', render: (val: string) => ( - <ReportTypesCol reportTypes={dataType ? ReportTypes[dataType] : []} /> + <ReportTypesCol seriesId={seriesId} reportTypes={dataType ? ReportTypes[dataType] : []} /> ), }, { @@ -100,9 +100,9 @@ export function SeriesBuilder() { render: (val: string) => { if (dataType && hasData) { return loading ? ( - INITIATING_VIEW + LOADING_VIEW ) : reportType ? ( - <ReportDefinitionCol dataViewSeries={getDataViewSeries()} /> + <ReportDefinitionCol seriesId={seriesId} dataViewSeries={getDataViewSeries()} /> ) : ( SELECT_REPORT_TYPE ); @@ -117,7 +117,9 @@ export function SeriesBuilder() { }), width: '20%', render: (val: string) => - reportType && indexPattern ? <ReportFilters dataViewSeries={getDataViewSeries()} /> : null, + reportType && indexPattern ? ( + <ReportFilters seriesId={seriesId} dataViewSeries={getDataViewSeries()} /> + ) : null, }, { name: i18n.translate('xpack.observability.expView.seriesBuilder.breakdown', { @@ -127,11 +129,13 @@ export function SeriesBuilder() { field: 'id', render: (val: string) => reportType && indexPattern ? ( - <ReportBreakdowns dataViewSeries={getDataViewSeries()} /> + <ReportBreakdowns seriesId={seriesId} dataViewSeries={getDataViewSeries()} /> ) : null, }, ]; + // TODO: Remove this if remain unused during multiple series view + // @ts-expect-error const addSeries = () => { if (reportType) { const newSeriesId = `${ @@ -141,6 +145,7 @@ export function SeriesBuilder() { }`; const newSeriesN: SeriesUrl = { + dataType, time, filters, breakdown, @@ -152,92 +157,34 @@ export function SeriesBuilder() { setSeries(newSeriesId, newSeriesN).then(() => { removeSeries(NEW_SERIES_KEY); - setIsFlyoutVisible(false); }); } }; - const items = [{ id: NEW_SERIES_KEY }]; - - let flyout; - - if (isFlyoutVisible) { - flyout = ( - <> - <EuiBasicTable - items={items as any} - columns={columns} - cellProps={{ style: { borderRight: '1px solid #d3dae6' } }} - /> - <EuiSpacer size="xs" /> - <EuiFlexGroup justifyContent="flexEnd"> - <EuiFlexItem grow={false}> - <EuiButton - fill - iconType="plus" - color="primary" - onClick={addSeries} - size="s" - isDisabled={!series?.reportType} - > - {i18n.translate('xpack.observability.expView.seriesBuilder.add', { - defaultMessage: 'Add', - })} - </EuiButton> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButton - size="s" - iconType="cross" - color="text" - onClick={() => { - removeSeries(NEW_SERIES_KEY); - setIsFlyoutVisible(false); - }} - > - {i18n.translate('xpack.observability.expView.seriesBuilder.cancel', { - defaultMessage: 'Cancel', - })} - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </> - ); - } + const items = [{ id: seriesId }]; return ( - <div> - {!isFlyoutVisible && ( - <> - <EuiButton - iconType={isFlyoutVisible ? 'arrowDown' : 'arrowRight'} - color="primary" - iconSide="right" - onClick={() => setIsFlyoutVisible((prevState) => !prevState)} - disabled={allSeriesIds.length > 0} - > - {i18n.translate('xpack.observability.expView.seriesBuilder.addSeries', { - defaultMessage: 'Add series', - })} - </EuiButton> - <EuiSpacer /> - </> - )} - {flyout} + <div ref={seriesBuilderRef}> + <EuiBasicTable + items={items as any} + columns={columns} + cellProps={{ style: { borderRight: '1px solid #d3dae6', verticalAlign: 'initial' } }} + tableLayout="auto" + /> </div> ); } -export const INITIATING_VIEW = i18n.translate( - 'xpack.observability.expView.seriesBuilder.initView', +export const LOADING_VIEW = i18n.translate( + 'xpack.observability.expView.seriesBuilder.loadingView', { - defaultMessage: 'Initiating view ...', + defaultMessage: 'Loading view ...', } ); -const SELECT_REPORT_TYPE = i18n.translate( +export const SELECT_REPORT_TYPE = i18n.translate( 'xpack.observability.expView.seriesBuilder.selectReportType', { - defaultMessage: 'Select a report type to define visualization.', + defaultMessage: 'No report type selected', } ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx index 8fe1d5ed9f2ac..e99b701f091fe 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx @@ -16,6 +16,7 @@ describe('SeriesDatePicker', function () { mockUrlStorage({ data: { 'uptime-pings-histogram': { + dataType: 'synthetics', reportType: 'upp', breakdown: 'monitor.status', time: { from: 'now-30m', to: 'now' }, @@ -32,6 +33,7 @@ describe('SeriesDatePicker', function () { data: { 'uptime-pings-histogram': { reportType: 'upp', + dataType: 'synthetics', breakdown: 'monitor.status', }, }, @@ -40,6 +42,7 @@ describe('SeriesDatePicker', function () { expect(setSeries1).toHaveBeenCalledTimes(1); expect(setSeries1).toHaveBeenCalledWith('uptime-pings-histogram', { breakdown: 'monitor.status', + dataType: 'synthetics', reportType: 'upp', time: DEFAULT_TIME, }); @@ -49,6 +52,7 @@ describe('SeriesDatePicker', function () { const { setSeries } = mockUrlStorage({ data: { 'uptime-pings-histogram': { + dataType: 'synthetics', reportType: 'upp', breakdown: 'monitor.status', time: { from: 'now-30m', to: 'now' }, @@ -69,6 +73,7 @@ describe('SeriesDatePicker', function () { expect(setSeries).toHaveBeenCalledWith('series-id', { breakdown: 'monitor.status', + dataType: 'synthetics', reportType: 'upp', time: { from: 'now/d', to: 'now/d' }, }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/chart_edit_options.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/chart_edit_options.tsx new file mode 100644 index 0000000000000..c95d322748607 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/chart_edit_options.tsx @@ -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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { Breakdowns } from './columns/breakdowns'; +import { DataSeries } from '../types'; +import { ChartOptions } from './columns/chart_options'; + +interface Props { + series: DataSeries; + seriesId: string; + breakdowns: string[]; +} +export function ChartEditOptions({ series, seriesId, breakdowns }: Props) { + return ( + <EuiFlexGroup wrap> + <EuiFlexItem> + <Breakdowns seriesId={seriesId} breakdowns={breakdowns} /> + </EuiFlexItem> + <EuiFlexItem> + <ChartOptions series={series} /> + </EuiFlexItem> + </EuiFlexGroup> + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx index 0824f13e6b3fe..eff62a60509e3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx @@ -41,6 +41,7 @@ describe('Breakdowns', function () { expect(setSeries).toHaveBeenCalledWith('series-id', { breakdown: 'user_agent.name', + dataType: 'ux', reportType: 'pld', time: { from: 'now-15m', to: 'now' }, }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx index 849fb8ffa66ba..ccb9c90a884bb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx @@ -71,8 +71,10 @@ export function FilterValueButton({ </FilterButton> ); - const onNestedChange = (val?: string) => { - setFilter({ field: nestedField!, value: val! }); + const onNestedChange = (valuesN?: string[]) => { + (valuesN ?? []).forEach((valN) => { + setFilter({ field: nestedField!, value: valN! }); + }); setIsNestedOpen({ value: '', negate }); }; 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 2d82aca658ec3..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 @@ -19,7 +19,7 @@ import { FilterExpanded } from './filter_expanded'; import { DataSeries } from '../../types'; import { FieldLabels } from '../../configurations/constants/constants'; import { SelectedFilters } from '../selected_filters'; -import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_storage'; +import { useUrlStorage } from '../../hooks/use_url_storage'; interface Props { seriesId: string; @@ -46,13 +46,12 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P } return { - label: FieldLabels[field.field], field: field.field, nested: field.nested, isNegated: field.isNegated, + label: FieldLabels[field.field], }; }); - const disabled = seriesId === NEW_SERIES_KEY && !isNew; const { setSeries, series: urlSeries } = useUrlStorage(seriesId); @@ -61,9 +60,8 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P flush="left" iconType="plus" onClick={() => { - setIsPopoverVisible(true); + setIsPopoverVisible((prevState) => !prevState); }} - isDisabled={disabled} size="s" > {i18n.translate('xpack.observability.expView.seriesEditor.addFilter', { @@ -113,7 +111,7 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P return ( <EuiFlexGroup wrap direction="column" gutterSize="xs" alignItems="flexStart"> - {!disabled && <SelectedFilters seriesId={seriesId} series={series} isNew={isNew} />} + <SelectedFilters seriesId={seriesId} series={series} isNew={isNew} /> <EuiFlexItem grow={false}> <EuiPopover button={button} @@ -133,7 +131,6 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P onClick={() => { setSeries(seriesId, { ...urlSeries, filters: undefined }); }} - isDisabled={disabled} size="s" > {i18n.translate('xpack.observability.expView.seriesEditor.clearFilter', { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx index 4055a592c75d2..aabb39f88507f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx @@ -7,7 +7,7 @@ import React, { Fragment } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_storage'; +import { useUrlStorage } from '../hooks/use_url_storage'; import { FilterLabel } from '../components/filter_label'; import { DataSeries, UrlFilter } from '../types'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; @@ -31,7 +31,7 @@ export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props) let definitionFilters: UrlFilter[] = getFiltersFromDefs(reportDefinitions, dataSeries); // we don't want to display report definition filters in new series view - if (seriesId === NEW_SERIES_KEY && isNew) { + if (isNew) { definitionFilters = []; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx index b9e68c1267c9e..d883b854c88cb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx @@ -10,15 +10,13 @@ import { i18n } from '@kbn/i18n'; import { EuiBasicTable, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { SeriesFilter } from './columns/series_filter'; -import { Breakdowns } from './columns/breakdowns'; import { DataSeries } from '../types'; -import { SeriesBuilder } from '../series_builder/series_builder'; import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; import { DatePickerCol } from './columns/date_picker_col'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; -import { ChartOptions } from './columns/chart_options'; import { SeriesActions } from './columns/series_actions'; +import { ChartEditOptions } from './chart_edit_options'; export function SeriesEditor() { const { allSeries, firstSeriesId } = useUrlStorage(); @@ -44,7 +42,7 @@ export function SeriesEditor() { defaultMessage: 'Filters', }), field: 'defaultFilters', - width: '25%', + width: '15%', render: (defaultFilters: string[], series: DataSeries) => ( <SeriesFilter defaultFilters={defaultFilters} seriesId={series.id} series={series} /> ), @@ -54,18 +52,11 @@ export function SeriesEditor() { defaultMessage: 'Breakdowns', }), field: 'breakdowns', - width: '15%', + width: '25%', render: (val: string[], item: DataSeries) => ( - <Breakdowns seriesId={item.id} breakdowns={val} /> + <ChartEditOptions seriesId={item.id} breakdowns={val} series={item} /> ), }, - { - name: '', - align: 'center' as const, - width: '15%', - field: 'id', - render: (val: string, item: DataSeries) => <ChartOptions series={item} />, - }, { name: ( <div> @@ -85,7 +76,7 @@ export function SeriesEditor() { defaultMessage: 'Actions', }), align: 'center' as const, - width: '8%', + width: '10%', field: 'id', render: (val: string, item: DataSeries) => <SeriesActions seriesId={item.id} />, }, @@ -128,9 +119,9 @@ export function SeriesEditor() { verticalAlign: 'top', }, }} + tableLayout="auto" /> <EuiSpacer /> - <SeriesBuilder /> </> ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts index 3878c1cde7aa5..7b9278352925e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts @@ -16,6 +16,7 @@ import { import { PersistableFilter } from '../../../../../lens/common'; import { IIndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns'; +import { ExistsFilter } from '../../../../../../../src/plugins/data/common/es_query/filters'; export const ReportViewTypes = { pld: 'page-load-dist', @@ -59,7 +60,7 @@ export interface DataSeries { defaultSeriesType: SeriesType; defaultFilters: Array<string | { field: string; nested?: string; isNegated?: boolean }>; seriesTypes: SeriesType[]; - filters?: PersistableFilter[]; + filters?: PersistableFilter[] | ExistsFilter[]; reportDefinitions: ReportDefinition[]; labels: Record<string, string>; hasOperationType: boolean; @@ -67,6 +68,8 @@ export interface DataSeries { yTitle?: string; } +export type URLReportDefinition = Record<string, string[]>; + export interface SeriesUrl { time: { to: string; @@ -77,8 +80,8 @@ export interface SeriesUrl { seriesType?: SeriesType; reportType: ReportViewTypeId; operationType?: OperationType; - dataType?: AppDataType; - reportDefinitions?: Record<string, string>; + dataType: AppDataType; + reportDefinitions?: URLReportDefinition; } export interface UrlFilter { diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx index ae91e1a439188..809937c2c4b18 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx @@ -31,7 +31,7 @@ export default { label="Service name" values={['elastic co frontend', 'apm server', 'opbean python']} onChange={() => {}} - value={''} + selectedValue={[]} loading={false} setQuery={() => {}} /> @@ -48,7 +48,7 @@ export function ValuesLoaded() { label="Service name" values={['elastic co frontend', 'apm server', 'opbean python']} onChange={() => {}} - value={''} + selectedValue={[]} loading={false} setQuery={() => {}} /> @@ -61,7 +61,7 @@ export function LoadingState() { label="Service name" values={['elastic co frontend', 'apm server', 'opbean python']} onChange={() => {}} - value={''} + selectedValue={[]} loading={true} setQuery={() => {}} /> @@ -74,7 +74,7 @@ export function EmptyState() { label="Service name" values={[]} onChange={() => {}} - value={''} + selectedValue={[]} loading={false} setQuery={() => {}} /> @@ -94,7 +94,7 @@ export function SearchState(args: FieldValueSelectionProps) { label="Service name" values={['elastic co frontend', 'apm server', 'opbean python']} onChange={() => {}} - value={''} + selectedValue={[]} loading={false} setQuery={setQuery} /> 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 new file mode 100644 index 0000000000000..55c65ce175fe0 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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, { useEffect, useState } from 'react'; +import { union } from 'lodash'; +import { EuiComboBox, EuiFormControlLayout, EuiComboBoxOptionOption } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { FieldValueSelectionProps } from './types'; + +const formatOptions = (values?: string[]) => { + const uniqueValues = Array.from(new Set(values)); + + return (uniqueValues ?? []).map((val) => ({ + label: val, + })); +}; + +type ValueOption = EuiComboBoxOptionOption<string>; + +export function FieldValueCombobox({ + label, + selectedValue, + loading, + values, + setQuery, + onChange: onSelectionChange, +}: FieldValueSelectionProps) { + const [options, setOptions] = useState<ValueOption[]>( + formatOptions(union(values ?? [], selectedValue ?? [])) + ); + + useEffect(() => { + setOptions(formatOptions(union(values ?? [], selectedValue ?? []))); + }, [selectedValue, values]); + + const onChange = (selectedValuesN: ValueOption[]) => { + onSelectionChange(selectedValuesN.map(({ label: lbl }) => lbl)); + }; + + return ( + <ComboWrapper> + <EuiFormControlLayout fullWidth prepend={label} compressed> + <EuiComboBox + fullWidth + compressed + placeholder={i18n.translate( + 'xpack.observability.fieldValueSelection.placeholder.search', + { + defaultMessage: 'Search {label}', + values: { label }, + } + )} + isLoading={loading} + onSearchChange={(searchVal) => { + setQuery(searchVal); + }} + options={options} + selectedOptions={options.filter((opt) => selectedValue?.includes(opt.label))} + onChange={onChange} + /> + </EuiFormControlLayout> + </ComboWrapper> + ); +} + +const ComboWrapper = styled.div` + &&& { + .euiFormControlLayout { + height: auto; + .euiFormControlLayout__prepend { + margin: auto; + } + .euiComboBoxPill { + max-width: 250px; + } + .euiComboBox__inputWrap { + border-radius: 0; + } + } + } +`; diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.test.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.test.tsx index 46f2ce6efa97c..ba898c7dc380f 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.test.tsx @@ -17,7 +17,7 @@ describe('FieldValueSelection', () => { label="Service name" values={['elastic co frontend', 'apm server', 'opbean python']} onChange={() => {}} - value={''} + selectedValue={[]} loading={false} setQuery={() => {}} /> @@ -33,7 +33,7 @@ describe('FieldValueSelection', () => { label="Service name" values={['elastic co frontend', 'apm server', 'opbean python']} onChange={() => {}} - value={''} + selectedValue={[]} loading={false} setQuery={() => {}} /> diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx index d7e075d7fc3f2..cbe70c73b2681 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx @@ -16,19 +16,20 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; +import { isEqual } from 'lodash'; import { FieldValueSelectionProps } from './types'; -const formatOptions = (values?: string[], value?: string): EuiSelectableOption[] => { +const formatOptions = (values?: string[], selectedValue?: string[]): EuiSelectableOption[] => { return (values ?? []).map((val) => ({ label: val, - ...(value === val ? { checked: 'on' } : {}), + ...(selectedValue?.includes(val) ? { checked: 'on' } : {}), })); }; export function FieldValueSelection({ fullWidth, label, - value, + selectedValue, loading, values, setQuery, @@ -39,12 +40,15 @@ export function FieldValueSelection({ singleSelection, onChange: onSelectionChange, }: FieldValueSelectionProps) { - const [options, setOptions] = useState<EuiSelectableOption[]>(formatOptions(values, value)); + const [options, setOptions] = useState<EuiSelectableOption[]>( + formatOptions(values, selectedValue ?? []) + ); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); useEffect(() => { - setOptions(formatOptions(values, value)); - }, [values, value]); + setOptions(formatOptions(values, selectedValue)); + }, [values, selectedValue]); const onButtonClick = () => { setIsPopoverOpen(!isPopoverOpen); @@ -77,6 +81,14 @@ export function FieldValueSelection({ </EuiButton> ); + const applyDisabled = () => { + const currSelected = (options ?? []) + .filter((opt) => opt?.checked === 'on') + .map(({ label: labelN }) => labelN); + + return isEqual(selectedValue ?? [], currSelected); + }; + return ( <Wrapper> <EuiPopover @@ -111,13 +123,10 @@ export function FieldValueSelection({ <EuiButton size="s" fullWidth - disabled={ - !value && - (options.length === 0 || !options.find((opt) => opt?.checked === 'on')) - } + isDisabled={applyDisabled()} onClick={() => { - const selected = options.find((opt) => opt?.checked === 'on'); - onSelectionChange(selected?.label); + const selectedValuesN = options.filter((opt) => opt?.checked === 'on'); + onSelectionChange(selectedValuesN.map(({ label: lbl }) => lbl)); setIsPopoverOpen(false); }} > diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx index a3bfd8daac7dc..359710e4b9c59 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx @@ -11,13 +11,14 @@ import { useDebounce } from 'react-use'; import { useValuesList } from '../../../hooks/use_values_list'; import { FieldValueSelection } from './field_value_selection'; import { FieldValueSuggestionsProps } from './types'; +import { FieldValueCombobox } from './field_value_combobox'; export function FieldValueSuggestions({ fullWidth, sourceField, label, indexPattern, - value, + selectedValue, filters, button, time, @@ -25,12 +26,20 @@ export function FieldValueSuggestions({ forceOpen, anchorPosition, singleSelection, + asCombobox = true, onChange: onSelectionChange, }: FieldValueSuggestionsProps) { const [query, setQuery] = useState(''); const [debouncedValue, setDebouncedValue] = useState(''); - const { values, loading } = useValuesList({ indexPattern, query, sourceField, filters, time }); + const { values, loading } = useValuesList({ + indexPattern, + query, + sourceField, + filters, + time, + keepHistory: true, + }); useDebounce( () => { @@ -40,8 +49,10 @@ export function FieldValueSuggestions({ [debouncedValue] ); + const SelectionComponent = asCombobox ? FieldValueCombobox : FieldValueSelection; + return ( - <FieldValueSelection + <SelectionComponent fullWidth={fullWidth} singleSelection={singleSelection} values={values as string[]} @@ -49,7 +60,7 @@ export function FieldValueSuggestions({ onChange={onSelectionChange} setQuery={setDebouncedValue} loading={loading} - value={value} + selectedValue={selectedValue} button={button} forceOpen={forceOpen} anchorPosition={anchorPosition} diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts index c7f8aa4a09514..547d786c9cefa 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts @@ -11,7 +11,7 @@ import { ESFilter } from 'typings/elasticsearch'; import { IndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; interface CommonProps { - value?: string; + selectedValue?: string[]; label: string; button?: JSX.Element; width?: number; @@ -24,14 +24,15 @@ interface CommonProps { export type FieldValueSuggestionsProps = CommonProps & { indexPattern: IndexPattern; sourceField: string; - onChange: (val?: string) => void; + asCombobox?: boolean; + onChange: (val?: string[]) => void; filters: ESFilter[]; time?: { from: string; to: string }; }; export type FieldValueSelectionProps = CommonProps & { loading?: boolean; - onChange: (val?: string) => void; + onChange: (val?: string[]) => void; values?: string[]; setQuery: Dispatch<SetStateAction<string>>; }; diff --git a/x-pack/plugins/observability/public/context/has_data_context.tsx b/x-pack/plugins/observability/public/context/has_data_context.tsx index 0b8b2b5d80a17..97aa72f07b09c 100644 --- a/x-pack/plugins/observability/public/context/has_data_context.tsx +++ b/x-pack/plugins/observability/public/context/has_data_context.tsx @@ -76,7 +76,7 @@ export function HasDataContextProvider({ children }: { children: React.ReactNode }); }, // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [isExploratoryView] ); useEffect(() => { diff --git a/x-pack/plugins/observability/public/hooks/use_breadcrumbs.ts b/x-pack/plugins/observability/public/hooks/use_breadcrumbs.ts index a354ac8a07f05..d31b6b52744c0 100644 --- a/x-pack/plugins/observability/public/hooks/use_breadcrumbs.ts +++ b/x-pack/plugins/observability/public/hooks/use_breadcrumbs.ts @@ -9,12 +9,9 @@ import { ChromeBreadcrumb } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { MouseEvent, useEffect } from 'react'; import { EuiBreadcrumb } from '@elastic/eui'; -import { stringify } from 'query-string'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import { useQueryParams } from './use_query_params'; -const EMPTY_QUERY = '?'; - function handleBreadcrumbClick( breadcrumbs: ChromeBreadcrumb[], navigateToHref?: (url: string) => Promise<void> @@ -34,14 +31,7 @@ function handleBreadcrumbClick( })); } -export const makeBaseBreadcrumb = (href: string, params?: any): EuiBreadcrumb => { - if (params) { - const crumbParams = { ...params }; - - delete crumbParams.statusFilter; - const query = stringify(crumbParams, { skipEmptyString: true, skipNull: true }); - href += query === EMPTY_QUERY ? '' : query; - } +export const makeBaseBreadcrumb = (href: string): EuiBreadcrumb => { return { text: i18n.translate('xpack.observability.breadcrumbs.observability', { defaultMessage: 'Observability', @@ -64,7 +54,10 @@ export const useBreadcrumbs = (extraCrumbs: ChromeBreadcrumb[]) => { useEffect(() => { if (setBreadcrumbs) { setBreadcrumbs( - handleBreadcrumbClick([makeBaseBreadcrumb(appPath, params)].concat(extraCrumbs), navigate) + handleBreadcrumbClick( + [makeBaseBreadcrumb(appPath + '/overview')].concat(extraCrumbs), + navigate + ) ); } }, [appPath, extraCrumbs, navigate, params, setBreadcrumbs]); 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 147a66f3d505e..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'; @@ -89,21 +89,21 @@ export const useValuesList = ({ }, }, }), - [debouncedQuery, from, to] + [debouncedQuery, from, to, JSON.stringify(filters)] ); useEffect(() => { const newValues = data?.aggregations?.values.buckets.map(({ key: value }) => value as string) ?? []; - if (keepHistory) { + if (keepHistory && query) { setValues((prevState) => { - return merge(newValues, prevState); + return union(newValues, prevState); }); } else { setValues(newValues); } - }, [data, keepHistory, loading]); + }, [data, keepHistory, loading, query]); return { values, loading }; }; 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/reporting/server/routes/jobs.ts b/x-pack/plugins/reporting/server/routes/jobs.ts index 7141b1a141185..3f2a95a34224c 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.ts @@ -7,6 +7,7 @@ import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; +import { ROUTE_TAG_CAN_REDIRECT } from '../../../security/server'; import { ReportingCore } from '../'; import { API_BASE_URL } from '../../common/constants'; import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; @@ -198,6 +199,7 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { docId: schema.string({ minLength: 3 }), }), }, + options: { tags: [ROUTE_TAG_CAN_REDIRECT] }, }, userHandler(async (user, context, req, res) => { // ensure the async dependencies are loaded diff --git a/x-pack/plugins/security/common/constants.ts b/x-pack/plugins/security/common/constants.ts index a205109f537e7..0ff04e4f731d0 100644 --- a/x-pack/plugins/security/common/constants.ts +++ b/x-pack/plugins/security/common/constants.ts @@ -17,9 +17,30 @@ export const UNKNOWN_SPACE = '?'; export const GLOBAL_RESOURCE = '*'; export const APPLICATION_PREFIX = 'kibana-'; + +/** + * Reserved application privileges are always assigned to this "wildcard" application. + * This allows them to be applied to any Kibana "tenant" (`kibana.index`). Since reserved privileges are always assigned to reserved (built-in) roles, + * it's not possible to know the tenant ahead of time. + */ export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*'; +/** + * This is the key of a query parameter that contains the name of the authentication provider that should be used to + * authenticate request. It's also used while the user is being redirected during single-sign-on authentication flows. + * That query parameter is discarded after the authentication flow succeeds. See the `Authenticator`, + * `OIDCAuthenticationProvider`, and `SAMLAuthenticationProvider` classes for more information. + */ export const AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER = 'auth_provider_hint'; + +/** + * This is the key of a query parameter that contains metadata about the (client-side) URL hash while the user is being + * redirected during single-sign-on authentication flows. That query parameter is discarded after the authentication + * flow succeeds. See the `Authenticator`, `OIDCAuthenticationProvider`, and `SAMLAuthenticationProvider` classes for + * more information. + */ +export const AUTH_URL_HASH_QUERY_STRING_PARAMETER = 'auth_url_hash'; + export const LOGOUT_PROVIDER_QUERY_STRING_PARAMETER = 'provider'; export const LOGOUT_REASON_QUERY_STRING_PARAMETER = 'msg'; export const NEXT_URL_QUERY_STRING_PARAMETER = 'next'; diff --git a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts index bf68d9f7a6e5e..44fd5ab195341 100644 --- a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts +++ b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts @@ -5,19 +5,29 @@ * 2.0. */ -import type { AppMount, ScopedHistory } from 'src/core/public'; -import { coreMock, scopedHistoryMock } from 'src/core/public/mocks'; +import { coreMock } from 'src/core/public/mocks'; import { captureURLApp } from './capture_url_app'; describe('captureURLApp', () => { + let mockLocationReplace: jest.Mock; beforeAll(() => { + mockLocationReplace = jest.fn(); Object.defineProperty(window, 'location', { - value: { href: 'https://some-host' }, + value: { + href: 'https://some-host', + hash: '#/?_g=()', + origin: 'https://some-host', + replace: mockLocationReplace, + }, writable: true, }); }); + beforeEach(() => { + mockLocationReplace.mockClear(); + }); + it('properly registers application', () => { const coreSetupMock = coreMock.createSetup(); @@ -42,34 +52,37 @@ describe('captureURLApp', () => { it('properly handles captured URL', async () => { window.location.href = `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent( - '/mock-base-path/app/home' - )}&providerType=saml&providerName=saml1#/?_g=()`; + '/mock-base-path/app/home?auth_provider_hint=saml1' + )}#/?_g=()`; const coreSetupMock = coreMock.createSetup(); - coreSetupMock.http.post.mockResolvedValue({ location: '/mock-base-path/app/home#/?_g=()' }); - captureURLApp.create(coreSetupMock); const [[{ mount }]] = coreSetupMock.application.register.mock.calls; - await (mount as AppMount)({ - element: document.createElement('div'), - appBasePath: '', - onAppLeave: jest.fn(), - setHeaderActionMenu: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, - }); + await mount(coreMock.createAppMountParamters()); - expect(coreSetupMock.http.post).toHaveBeenCalledTimes(1); - expect(coreSetupMock.http.post).toHaveBeenCalledWith('/internal/security/login', { - body: JSON.stringify({ - providerType: 'saml', - providerName: 'saml1', - currentURL: `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent( - '/mock-base-path/app/home' - )}&providerType=saml&providerName=saml1#/?_g=()`, - }), - }); + expect(mockLocationReplace).toHaveBeenCalledTimes(1); + expect(mockLocationReplace).toHaveBeenCalledWith( + 'https://some-host/mock-base-path/app/home?auth_provider_hint=saml1&auth_url_hash=%23%2F%3F_g%3D%28%29#/?_g=()' + ); + expect(coreSetupMock.fatalErrors.add).not.toHaveBeenCalled(); + }); - expect(window.location.href).toBe('/mock-base-path/app/home#/?_g=()'); + it('properly handles open redirects', async () => { + window.location.href = `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent( + 'https://evil.com/mock-base-path/app/home?auth_provider_hint=saml1' + )}#/?_g=()`; + + const coreSetupMock = coreMock.createSetup(); + captureURLApp.create(coreSetupMock); + + const [[{ mount }]] = coreSetupMock.application.register.mock.calls; + await mount(coreMock.createAppMountParamters()); + + expect(mockLocationReplace).toHaveBeenCalledTimes(1); + expect(mockLocationReplace).toHaveBeenCalledWith( + 'https://some-host/?auth_url_hash=%23%2F%3F_g%3D%28%29' + ); + expect(coreSetupMock.fatalErrors.add).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts index 7797ce4e62102..af45314c5bacb 100644 --- a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts +++ b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts @@ -5,10 +5,11 @@ * 2.0. */ -import { parse } from 'url'; - import type { ApplicationSetup, FatalErrorsSetup, HttpSetup } from 'src/core/public'; +import { AUTH_URL_HASH_QUERY_STRING_PARAMETER } from '../../../common/constants'; +import { parseNext } from '../../../common/parse_next'; + interface CreateDeps { application: ApplicationSetup; http: HttpSetup; @@ -22,20 +23,17 @@ interface CreateDeps { * path segment into the `next` query string parameter (so that it's not lost during redirect). And * since browsers preserve hash fragments during redirects (assuming redirect location doesn't * specify its own hash fragment, which is true in our case) this app can capture both path and - * hash URL segments and send them back to the authentication provider via login endpoint. + * hash URL segments and re-try request sending hash fragment in a dedicated query string parameter. * * The flow can look like this: - * 1. User visits `/app/kibana#/management/elasticsearch` that initiates authentication. - * 2. Provider redirect user to `/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1`. - * 3. Browser preserves hash segment and users ends up at `/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1#/management/elasticsearch`. - * 4. The app captures full URL and sends it back as is via login endpoint: - * { - * providerType: 'saml', - * providerName: 'saml1', - * currentURL: 'https://kibana.com/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1#/management/elasticsearch' - * } - * 5. Login endpoint handler parses and validates `next` parameter, joins it with the hash segment - * and finally passes it to the provider that initiated capturing. + * 1. User visits `https://kibana.com/app/kibana#/management/elasticsearch` that initiates authentication. + * 2. Provider redirect user to `/internal/security/capture-url?next=%2Fapp%2Fkibana&auth_provider_hint=saml1`. + * 3. Browser preserves hash segment and users ends up at `/internal/security/capture-url?next=%2Fapp%2Fkibana&auth_provider_hint=saml1#/management/elasticsearch`. + * 4. The app reconstructs original URL, adds `auth_url_hash` query string parameter with the captured hash fragment and redirects user to: + * https://kibana.com/app/kibana?auth_provider_hint=saml1&auth_url_hash=%23%2Fmanagement%2Felasticsearch#/management/elasticsearch + * 5. Once Kibana receives this request, it immediately picks exactly the same provider to handle authentication (based on `auth_provider_hint=saml1`), + * and, since it has full URL now (original request path, query string and hash extracted from `auth_url_hash=%23%2Fmanagement%2Felasticsearch`), + * it can proceed to a proper authentication handshake. */ export const captureURLApp = Object.freeze({ id: 'security_capture_url', @@ -48,19 +46,14 @@ export const captureURLApp = Object.freeze({ appRoute: '/internal/security/capture-url', async mount() { try { - const { providerName, providerType } = parse(window.location.href, true).query ?? {}; - if (!providerName || !providerType) { - fatalErrors.add(new Error('Provider to capture URL for is not specified.')); - return () => {}; - } - - const { location } = await http.post<{ location: string }>('/internal/security/login', { - body: JSON.stringify({ providerType, providerName, currentURL: window.location.href }), - }); - - window.location.href = location; + const url = new URL( + parseNext(window.location.href, http.basePath.serverBasePath), + window.location.origin + ); + url.searchParams.append(AUTH_URL_HASH_QUERY_STRING_PARAMETER, window.location.hash); + window.location.replace(url.toString()); } catch (err) { - fatalErrors.add(new Error('Cannot login with captured URL.')); + fatalErrors.add(new Error(`Cannot parse current URL: ${err && err.message}.`)); } return () => {}; diff --git a/x-pack/plugins/security/public/authentication/login/components/index.ts b/x-pack/plugins/security/public/authentication/login/components/index.ts index 66e91a390784a..63928e4e82e37 100644 --- a/x-pack/plugins/security/public/authentication/login/components/index.ts +++ b/x-pack/plugins/security/public/authentication/login/components/index.ts @@ -5,5 +5,5 @@ * 2.0. */ -export { LoginForm } from './login_form'; +export { LoginForm, LoginFormMessageType } from './login_form'; export { DisabledLoginForm } from './disabled_login_form'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts b/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts index 6215f4e1e5b7a..d12ea30c784cb 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { LoginForm } from './login_form'; +export { LoginForm, MessageType as LoginFormMessageType } from './login_form'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx index f58150d4580b8..e816fa032a0e5 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -14,7 +14,7 @@ import ReactMarkdown from 'react-markdown'; import { findTestSubject, mountWithIntl, nextTick, shallowWithIntl } from '@kbn/test/jest'; import { coreMock } from 'src/core/public/mocks'; -import { LoginForm, PageMode } from './login_form'; +import { LoginForm, MessageType, PageMode } from './login_form'; function expectPageMode(wrapper: ReactWrapper, mode: PageMode) { const assertions: Array<[string, boolean]> = @@ -90,7 +90,7 @@ describe('LoginForm', () => { <LoginForm http={coreStartMock.http} notifications={coreStartMock.notifications} - infoMessage={'Hey this is an info message'} + message={{ type: MessageType.Info, content: 'Hey this is an info message' }} loginAssistanceMessage="" selector={{ enabled: false, @@ -152,7 +152,7 @@ describe('LoginForm', () => { }); expect(wrapper.find(EuiCallOut).props().title).toEqual( - `Invalid username or password. Please try again.` + `Username or password is incorrect. Please try again.` ); }); diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx index ca573ada36d22..df131e2eac133 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx @@ -40,7 +40,7 @@ interface Props { http: HttpStart; notifications: NotificationsStart; selector: LoginSelector; - infoMessage?: string; + message?: { type: MessageType.Danger | MessageType.Info; content: string }; loginAssistanceMessage: string; loginHelp?: string; authProviderHint?: string; @@ -66,7 +66,7 @@ enum LoadingStateType { AutoLogin, } -enum MessageType { +export enum MessageType { None, Info, Danger, @@ -106,9 +106,7 @@ export class LoginForm extends Component<Props, State> { loadingState: { type: LoadingStateType.None }, username: '', password: '', - message: this.props.infoMessage - ? { type: MessageType.Info, content: this.props.infoMessage } - : { type: MessageType.None }, + message: this.props.message || { type: MessageType.None }, mode, previousMode: mode, }; @@ -206,7 +204,7 @@ export class LoginForm extends Component<Props, State> { > <FormattedMessage id="xpack.security.loginPage.loginSelectorLinkText" - defaultMessage="See more login options" + defaultMessage="More login options" /> </EuiButtonEmpty> </EuiFlexItem> @@ -480,8 +478,8 @@ export class LoginForm extends Component<Props, State> { const message = (error as IHttpFetchError).response?.status === 401 ? i18n.translate( - 'xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage', - { defaultMessage: 'Invalid username or password. Please try again.' } + 'xpack.security.login.basicLoginForm.usernameOrPasswordIsIncorrectErrorMessage', + { defaultMessage: 'Username or password is incorrect. Please try again.' } ) : i18n.translate('xpack.security.login.basicLoginForm.unknownErrorMessage', { defaultMessage: 'Oops! Error. Try again.', diff --git a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx index a9596aff3bf0e..b3e2fac3ea2cc 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx @@ -14,7 +14,7 @@ import { coreMock } from 'src/core/public/mocks'; import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../../common/constants'; import type { LoginState } from '../../../common/login_state'; -import { DisabledLoginForm, LoginForm } from './components'; +import { DisabledLoginForm, LoginForm, LoginFormMessageType } from './components'; import { LoginPage } from './login_page'; const createLoginState = (options?: Partial<LoginState>) => { @@ -228,9 +228,12 @@ describe('LoginPage', () => { resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot }); - const { authProviderHint, infoMessage } = wrapper.find(LoginForm).props(); + const { authProviderHint, message } = wrapper.find(LoginForm).props(); expect(authProviderHint).toBe('basic1'); - expect(infoMessage).toBe('Your session has timed out. Please log in again.'); + expect(message).toEqual({ + type: LoginFormMessageType.Info, + content: 'Your session has timed out. Please log in again.', + }); }); it('renders as expected when loginAssistanceMessage is set', async () => { diff --git a/x-pack/plugins/security/public/authentication/login/login_page.tsx b/x-pack/plugins/security/public/authentication/login/login_page.tsx index 562adec7918d3..40438ac1c78f3 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.tsx @@ -23,7 +23,7 @@ import { LOGOUT_REASON_QUERY_STRING_PARAMETER, } from '../../../common/constants'; import type { LoginState } from '../../../common/login_state'; -import { DisabledLoginForm, LoginForm } from './components'; +import { DisabledLoginForm, LoginForm, LoginFormMessageType } from './components'; interface Props { http: HttpStart; @@ -36,18 +36,34 @@ interface State { loginState: LoginState | null; } -const infoMessageMap = new Map([ +const messageMap = new Map([ [ 'SESSION_EXPIRED', - i18n.translate('xpack.security.login.sessionExpiredDescription', { - defaultMessage: 'Your session has timed out. Please log in again.', - }), + { + type: LoginFormMessageType.Info, + content: i18n.translate('xpack.security.login.sessionExpiredDescription', { + defaultMessage: 'Your session has timed out. Please log in again.', + }), + }, ], [ 'LOGGED_OUT', - i18n.translate('xpack.security.login.loggedOutDescription', { - defaultMessage: 'You have logged out of Elastic.', - }), + { + type: LoginFormMessageType.Info, + content: i18n.translate('xpack.security.login.loggedOutDescription', { + defaultMessage: 'You have logged out of Elastic.', + }), + }, + ], + [ + 'UNAUTHENTICATED', + { + type: LoginFormMessageType.Danger, + content: i18n.translate('xpack.security.unauthenticated.errorDescription', { + defaultMessage: + "We hit an authentication error. Please check your credentials and try again. If you still can't log in, contact your system administrator.", + }), + }, ], ]); @@ -226,7 +242,7 @@ export class LoginPage extends Component<Props, State> { notifications={this.props.notifications} selector={selector} // @ts-expect-error Map.get is ok with getting `undefined` - infoMessage={infoMessageMap.get(query[LOGOUT_REASON_QUERY_STRING_PARAMETER]?.toString())} + message={messageMap.get(query[LOGOUT_REASON_QUERY_STRING_PARAMETER]?.toString())} loginAssistanceMessage={this.props.loginAssistanceMessage} loginHelp={loginHelp} authProviderHint={query[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]?.toString()} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx index 4c657294c965c..6f00df3a4ee7b 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx @@ -742,6 +742,21 @@ describe('global base read', () => { }); }); +describe('global and reserved', () => { + it('base all, reserved_foo', () => { + const props = buildProps([ + { spaces: ['*'], base: ['all'], feature: {} }, + { spaces: ['*'], base: [], feature: {}, _reserved: ['foo'] }, + ]); + const component = mountWithIntl(<PrivilegeSpaceTable {...props} />); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Foo', overridden: false } }, + { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, + ]); + }); +}); + describe('global normal feature privilege all', () => { describe('default and marketing space', () => { it('base all', () => { diff --git a/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap b/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap new file mode 100644 index 0000000000000..bcb97538b4f05 --- /dev/null +++ b/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PromptPage renders as expected with additional scripts 1`] = `"<html lang=\\"en\\"><head><title>ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; + +exports[`PromptPage renders as expected without additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; diff --git a/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap b/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap new file mode 100644 index 0000000000000..55168401992f7 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UnauthenticatedPage renders as expected 1`] = `"ElasticMockedFonts

We couldn't log you in

We hit an authentication error. Please check your credentials and try again. If you still can't log in, contact your system administrator.

"`; diff --git a/x-pack/plugins/security/server/authentication/authentication_service.test.mocks.ts b/x-pack/plugins/security/server/authentication/authentication_service.test.mocks.ts new file mode 100644 index 0000000000000..12a63134f4ef2 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.mocks.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mockCanRedirectRequest = jest.fn(); +jest.mock('./can_redirect_request', () => ({ canRedirectRequest: mockCanRedirectRequest })); diff --git a/x-pack/plugins/security/server/authentication/authentication_service.test.ts b/x-pack/plugins/security/server/authentication/authentication_service.test.ts index b0be9445c3fc3..d38f963a60c33 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.ts @@ -6,6 +6,9 @@ */ jest.mock('./authenticator'); +jest.mock('./unauthenticated_page'); + +import { mockCanRedirectRequest } from './authentication_service.test.mocks'; import Boom from '@hapi/boom'; @@ -18,6 +21,7 @@ import type { KibanaRequest, Logger, LoggerFactory, + OnPreResponseToolkit, } from 'src/core/server'; import { coreMock, @@ -37,6 +41,7 @@ import type { ConfigType } from '../config'; import { ConfigSchema, createConfig } from '../config'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; +import { ROUTE_TAG_AUTH_FLOW } from '../routes/tags'; import type { Session } from '../session_management'; import { sessionMock } from '../session_management/session.mock'; import { AuthenticationResult } from './authentication_result'; @@ -47,15 +52,60 @@ describe('AuthenticationService', () => { let logger: jest.Mocked; let mockSetupAuthenticationParams: { http: jest.Mocked; + config: ConfigType; license: jest.Mocked; + buildNumber: number; + }; + let mockStartAuthenticationParams: { + legacyAuditLogger: jest.Mocked; + audit: jest.Mocked; + config: ConfigType; + loggers: LoggerFactory; + http: jest.Mocked; + clusterClient: ReturnType; + featureUsageService: jest.Mocked; + session: jest.Mocked>; }; beforeEach(() => { logger = loggingSystemMock.createLogger(); + const httpMock = coreMock.createSetup().http; + (httpMock.basePath.prepend as jest.Mock).mockImplementation( + (path) => `${httpMock.basePath.serverBasePath}${path}` + ); + (httpMock.basePath.get as jest.Mock).mockImplementation(() => httpMock.basePath.serverBasePath); mockSetupAuthenticationParams = { - http: coreMock.createSetup().http, + http: httpMock, + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.create().get(), { + isTLSEnabled: false, + }), license: licenseMock.create(), + buildNumber: 100500, }; + mockCanRedirectRequest.mockReturnValue(false); + + const coreStart = coreMock.createStart(); + mockStartAuthenticationParams = { + legacyAuditLogger: securityAuditLoggerMock.create(), + audit: auditServiceMock.create(), + config: createConfig( + ConfigSchema.validate({ + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + cookieName: 'my-sid-cookie', + }), + loggingSystemMock.create().get(), + { isTLSEnabled: false } + ), + http: coreStart.http, + clusterClient: elasticsearchServiceMock.createClusterClient(), + loggers: loggingSystemMock.create(), + featureUsageService: securityFeatureUsageServiceMock.createStartContract(), + session: sessionMock.create(), + }; + (mockStartAuthenticationParams.http.basePath.get as jest.Mock).mockImplementation( + () => mockStartAuthenticationParams.http.basePath.serverBasePath + ); service = new AuthenticationService(logger); }); @@ -71,40 +121,19 @@ describe('AuthenticationService', () => { expect.any(Function) ); }); + + it('properly registers onPreResponse handler', () => { + service.setup(mockSetupAuthenticationParams); + + expect(mockSetupAuthenticationParams.http.registerOnPreResponse).toHaveBeenCalledTimes(1); + expect(mockSetupAuthenticationParams.http.registerOnPreResponse).toHaveBeenCalledWith( + expect.any(Function) + ); + }); }); describe('#start()', () => { - let mockStartAuthenticationParams: { - legacyAuditLogger: jest.Mocked; - audit: jest.Mocked; - config: ConfigType; - loggers: LoggerFactory; - http: jest.Mocked; - clusterClient: ReturnType; - featureUsageService: jest.Mocked; - session: jest.Mocked>; - }; beforeEach(() => { - const coreStart = coreMock.createStart(); - mockStartAuthenticationParams = { - legacyAuditLogger: securityAuditLoggerMock.create(), - audit: auditServiceMock.create(), - config: createConfig( - ConfigSchema.validate({ - encryptionKey: 'ab'.repeat(16), - secureCookies: true, - cookieName: 'my-sid-cookie', - }), - loggingSystemMock.create().get(), - { isTLSEnabled: false } - ), - http: coreStart.http, - clusterClient: elasticsearchServiceMock.createClusterClient(), - loggers: loggingSystemMock.create(), - featureUsageService: securityFeatureUsageServiceMock.createStartContract(), - session: sessionMock.create(), - }; - service.setup(mockSetupAuthenticationParams); }); @@ -318,4 +347,371 @@ describe('AuthenticationService', () => { }); }); }); + + describe('onPreResponse handler', () => { + function getService({ runStart = true }: { runStart?: boolean } = {}) { + service.setup(mockSetupAuthenticationParams); + + if (runStart) { + service.start(mockStartAuthenticationParams); + } + + const onPreResponseHandler = + mockSetupAuthenticationParams.http.registerOnPreResponse.mock.calls[0][0]; + const [authenticator] = jest.requireMock('./authenticator').Authenticator.mock.instances; + + return { authenticator, onPreResponseHandler }; + } + + it('ignores responses with non-401 status code', async () => { + const mockReturnedValue = { type: 'next' as any }; + const mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.next.mockReturnValue(mockReturnedValue); + + const { onPreResponseHandler } = getService(); + for (const statusCode of [200, 400, 403, 404]) { + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest(), + { statusCode }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + } + }); + + it('ignores responses to requests that cannot handle redirects', async () => { + const mockReturnedValue = { type: 'next' as any }; + const mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.next.mockReturnValue(mockReturnedValue); + mockCanRedirectRequest.mockReturnValue(false); + + const { onPreResponseHandler } = getService(); + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest(), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + }); + + it('ignores responses if authenticator is not initialized', async () => { + // Run `setup`, but not `start` to simulate non-initialized `Authenticator`. + const { onPreResponseHandler } = getService({ runStart: false }); + + const mockReturnedValue = { type: 'next' as any }; + const mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.next.mockReturnValue(mockReturnedValue); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest(), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + }); + + describe('when login form is available', () => { + let mockReturnedValue: { type: any; body: string }; + let mockOnPreResponseToolkit: jest.Mocked; + beforeEach(() => { + mockReturnedValue = { type: 'render' as any, body: 'body' }; + mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.render.mockReturnValue(mockReturnedValue); + }); + + it('redirects to the login page when user does not have an active session', async () => { + mockCanRedirectRequest.mockReturnValue(true); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome', + }, + }); + }); + + it('performs logout if user has an active session', async () => { + mockStartAuthenticationParams.session.getSID.mockResolvedValue('some-sid'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/logout?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome', + }, + }); + }); + + it('does not preserve path for the authentication flow paths', async () => { + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ + path: '/api/security/saml/callback', + query: { param: 'one two' }, + routeTags: [ROUTE_TAG_AUTH_FLOW], + }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2F', + }, + }); + }); + }); + + describe('when login selector is available', () => { + let mockReturnedValue: { type: any; body: string }; + let mockOnPreResponseToolkit: jest.Mocked; + beforeEach(() => { + mockReturnedValue = { type: 'render' as any, body: 'body' }; + mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.render.mockReturnValue(mockReturnedValue); + + mockSetupAuthenticationParams.config = createConfig( + ConfigSchema.validate({ + authc: { + providers: { + saml: { saml1: { order: 0, realm: 'saml1' } }, + basic: { basic1: { order: 1 } }, + }, + }, + }), + loggingSystemMock.create().get(), + { isTLSEnabled: false } + ); + }); + + it('redirects to the login page when user does not have an active session', async () => { + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome', + }, + }); + }); + + it('performs logout if user has an active session', async () => { + mockStartAuthenticationParams.session.getSID.mockResolvedValue('some-sid'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/logout?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome', + }, + }); + }); + + it('does not preserve path for the authentication flow paths', async () => { + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ + path: '/api/security/saml/callback', + query: { param: 'one two' }, + routeTags: [ROUTE_TAG_AUTH_FLOW], + }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2F', + }, + }); + }); + }); + + describe('when neither login selector nor login form is available', () => { + let mockReturnedValue: { type: any; body: string }; + let mockOnPreResponseToolkit: jest.Mocked; + beforeEach(() => { + mockReturnedValue = { type: 'render' as any, body: 'body' }; + mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.render.mockReturnValue(mockReturnedValue); + + mockSetupAuthenticationParams.config = createConfig( + ConfigSchema.validate({ + authc: { providers: { saml: { saml1: { order: 0, realm: 'saml1' } } } }, + }), + loggingSystemMock.create().get(), + { isTLSEnabled: false } + ); + }); + + it('renders unauthenticated page if user does not have an active session', async () => { + const mockRenderUnauthorizedPage = jest + .requireMock('./unauthenticated_page') + .renderUnauthenticatedPage.mockReturnValue('rendered-view'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: 'rendered-view', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + }, + }); + + expect(mockRenderUnauthorizedPage).toHaveBeenCalledWith({ + basePath: mockSetupAuthenticationParams.http.basePath, + buildNumber: 100500, + originalURL: '/mock-server-basepath/app/some', + }); + }); + + it('renders unauthenticated page if user has an active session', async () => { + const mockRenderUnauthorizedPage = jest + .requireMock('./unauthenticated_page') + .renderUnauthenticatedPage.mockReturnValue('rendered-view'); + mockStartAuthenticationParams.session.getSID.mockResolvedValue('some-sid'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: 'rendered-view', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + }, + }); + + expect(mockRenderUnauthorizedPage).toHaveBeenCalledWith({ + basePath: mockSetupAuthenticationParams.http.basePath, + buildNumber: 100500, + originalURL: '/mock-server-basepath/app/some', + }); + }); + + it('does not preserve path for the authentication flow paths', async () => { + const mockRenderUnauthorizedPage = jest + .requireMock('./unauthenticated_page') + .renderUnauthenticatedPage.mockReturnValue('rendered-view'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ + path: '/api/security/saml/callback', + query: { param: 'one two' }, + routeTags: [ROUTE_TAG_AUTH_FLOW], + }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: 'rendered-view', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + }, + }); + + expect(mockRenderUnauthorizedPage).toHaveBeenCalledWith({ + basePath: mockSetupAuthenticationParams.http.basePath, + buildNumber: 100500, + originalURL: '/mock-server-basepath/', + }); + }); + }); + }); }); diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts index 7feeff7a5d8ed..e5895422e7a74 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -15,22 +15,29 @@ import type { LoggerFactory, } from 'src/core/server'; +import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../common/constants'; import type { SecurityLicense } from '../../common/licensing'; import type { AuthenticatedUser } from '../../common/model'; +import { shouldProviderUseLoginForm } from '../../common/model'; import type { AuditServiceSetup, SecurityAuditLogger } from '../audit'; import type { ConfigType } from '../config'; -import { getErrorStatusCode } from '../errors'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; +import { ROUTE_TAG_AUTH_FLOW } from '../routes/tags'; import type { Session } from '../session_management'; import { APIKeys } from './api_keys'; import type { AuthenticationResult } from './authentication_result'; import type { ProviderLoginAttempt } from './authenticator'; import { Authenticator } from './authenticator'; +import { canRedirectRequest } from './can_redirect_request'; import type { DeauthenticationResult } from './deauthentication_result'; +import { renderUnauthenticatedPage } from './unauthenticated_page'; interface AuthenticationServiceSetupParams { - http: Pick; + http: Pick; + config: ConfigType; license: SecurityLicense; + buildNumber: number; } interface AuthenticationServiceStartParams { @@ -62,12 +69,23 @@ export interface AuthenticationServiceStart { export class AuthenticationService { private license!: SecurityLicense; private authenticator?: Authenticator; + private session?: PublicMethodsOf; constructor(private readonly logger: Logger) {} - setup({ http, license }: AuthenticationServiceSetupParams) { + setup({ config, http, license, buildNumber }: AuthenticationServiceSetupParams) { this.license = license; + // If we cannot automatically authenticate users we should redirect them straight to the login + // page if possible, so that they can try other methods to log in. If not possible, we should + // render a dedicated `Unauthenticated` page from which users can explicitly trigger a new + // login attempt. There are two cases when we can redirect to the login page: + // 1. Login selector is enabled + // 2. Login selector is disabled, but the provider with the lowest `order` uses login form + const isLoginPageAvailable = + config.authc.selector.enabled || + shouldProviderUseLoginForm(config.authc.sortedProviders[0].type); + http.registerAuth(async (request, response, t) => { if (!license.isLicenseAvailable()) { this.logger.error('License is not available, authentication is not possible.'); @@ -118,8 +136,9 @@ export class AuthenticationService { } if (authenticationResult.failed()) { - this.logger.info(`Authentication attempt failed: ${authenticationResult.error!.message}`); const error = authenticationResult.error!; + this.logger.info(`Authentication attempt failed: ${getDetailedErrorMessage(error)}`); + // proxy Elasticsearch "native" errors const statusCode = getErrorStatusCode(error); if (typeof statusCode === 'number') { @@ -139,7 +158,49 @@ export class AuthenticationService { return t.notHandled(); }); - this.logger.debug('Successfully registered core authentication handler.'); + http.registerOnPreResponse(async (request, preResponse, toolkit) => { + if (preResponse.statusCode !== 401 || !canRedirectRequest(request)) { + return toolkit.next(); + } + + if (!this.authenticator) { + // Core doesn't allow returning error here. + this.logger.error('Authentication sub-system is not fully initialized yet.'); + return toolkit.next(); + } + + // If users can eventually re-login we want to redirect them directly to the page they tried + // to access initially, but we only want to do that for routes that aren't part of the various + // authentication flows that wouldn't make any sense after successful authentication. + const originalURL = !request.route.options.tags.includes(ROUTE_TAG_AUTH_FLOW) + ? this.authenticator.getRequestOriginalURL(request) + : `${http.basePath.get(request)}/`; + if (!isLoginPageAvailable) { + return toolkit.render({ + body: renderUnauthenticatedPage({ buildNumber, basePath: http.basePath, originalURL }), + headers: { 'Content-Security-Policy': http.csp.header }, + }); + } + + const needsToLogout = (await this.session?.getSID(request)) !== undefined; + if (needsToLogout) { + this.logger.warn('Could not authenticate user with the existing session. Forcing logout.'); + } + + return toolkit.render({ + body: '
', + headers: { + 'Content-Security-Policy': http.csp.header, + Refresh: `0;url=${http.basePath.prepend( + `${ + needsToLogout ? '/logout' : '/login' + }?msg=UNAUTHENTICATED&${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent( + originalURL + )}` + )}`, + }, + }); + }); } start({ @@ -161,6 +222,7 @@ export class AuthenticationService { const getCurrentUser = (request: KibanaRequest) => http.auth.get(request).state ?? null; + this.session = session; this.authenticator = new Authenticator({ legacyAuditLogger, audit, diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 1bd430d0c5c98..ca33be92e9e99 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -20,6 +20,10 @@ import { loggingSystemMock, } from 'src/core/server/mocks'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, +} from '../../common/constants'; import type { SecurityLicenseFeatures } from '../../common/licensing'; import { licenseMock } from '../../common/licensing/index.mock'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; @@ -1780,13 +1784,13 @@ describe('Authenticator', () => { ); }); - it('returns `notHandled` if session does not exist.', async () => { + it('redirects to login form if session does not exist.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.session.get.mockResolvedValue(null); mockBasicAuthenticationProvider.logout.mockResolvedValue(DeauthenticationResult.notHandled()); await expect(authenticator.logout(request)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT') ); expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); @@ -1843,12 +1847,12 @@ describe('Authenticator', () => { expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); }); - it('returns `notHandled` if session does not exist and provider name is invalid', async () => { + it('redirects to login form if session does not exist and provider name is invalid', async () => { const request = httpServerMock.createKibanaRequest({ query: { provider: 'foo' } }); mockOptions.session.get.mockResolvedValue(null); await expect(authenticator.logout(request)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT') ); expect(mockBasicAuthenticationProvider.logout).not.toHaveBeenCalled(); @@ -1937,4 +1941,64 @@ describe('Authenticator', () => { ); }); }); + + describe('`getRequestOriginalURL` method', () => { + let authenticator: Authenticator; + let mockOptions: ReturnType; + beforeEach(() => { + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); + authenticator = new Authenticator(mockOptions); + }); + + it('filters out auth specific query parameters', () => { + expect(authenticator.getRequestOriginalURL(httpServerMock.createKibanaRequest())).toBe( + '/mock-server-basepath/path' + ); + + expect( + authenticator.getRequestOriginalURL( + httpServerMock.createKibanaRequest({ + query: { + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]: 'saml1', + }, + }) + ) + ).toBe('/mock-server-basepath/path'); + + expect( + authenticator.getRequestOriginalURL( + httpServerMock.createKibanaRequest({ + query: { + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]: 'saml1', + [AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-hash', + }, + }) + ) + ).toBe('/mock-server-basepath/path'); + }); + + it('allows to include additional query parameters', () => { + expect( + authenticator.getRequestOriginalURL(httpServerMock.createKibanaRequest(), [ + ['some-param', 'some-value'], + ['some-param2', 'some-value2'], + ]) + ).toBe('/mock-server-basepath/path?some-param=some-value&some-param2=some-value2'); + + expect( + authenticator.getRequestOriginalURL( + httpServerMock.createKibanaRequest({ + query: { + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]: 'saml1', + [AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-hash', + }, + }), + [ + ['some-param', 'some-value'], + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'oidc1'], + ] + ) + ).toBe('/mock-server-basepath/path?some-param=some-value&auth_provider_hint=oidc1'); + }); + }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index f86ff54963da9..4eeadf23c50b2 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -11,6 +11,7 @@ import type { IBasePath, IClusterClient, LoggerFactory } from 'src/core/server'; import { KibanaRequest } from '../../../../../src/core/server'; import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, LOGOUT_PROVIDER_QUERY_STRING_PARAMETER, LOGOUT_REASON_QUERY_STRING_PARAMETER, NEXT_URL_QUERY_STRING_PARAMETER, @@ -45,6 +46,15 @@ import { } from './providers'; import { Tokens } from './tokens'; +/** + * List of query string parameters used to pass various authentication related metadata that should + * be stripped away from URL as soon as they are no longer needed. + */ +const AUTH_METADATA_QUERY_STRING_PARAMETERS = [ + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, +]; + /** * The shape of the login attempt. */ @@ -201,6 +211,7 @@ export class Authenticator { const providerCommonOptions = { client: this.options.clusterClient, basePath: this.options.basePath, + getRequestOriginalURL: this.getRequestOriginalURL.bind(this), tokens: new Tokens({ client: this.options.clusterClient.asInternalUser, logger: this.options.loggers.get('tokens'), @@ -419,7 +430,9 @@ export class Authenticator { } } - return DeauthenticationResult.notHandled(); + // If none of the configured providers could perform a logout, we should redirect user to the + // default logout location. + return DeauthenticationResult.redirectTo(this.getLoggedOutURL(request)); } /** @@ -452,6 +465,24 @@ export class Authenticator { this.options.featureUsageService.recordPreAccessAgreementUsage(); } + getRequestOriginalURL( + request: KibanaRequest, + additionalQueryStringParameters?: Array<[string, string]> + ) { + const originalURLSearchParams = [ + ...[...request.url.searchParams.entries()].filter( + ([key]) => !AUTH_METADATA_QUERY_STRING_PARAMETERS.includes(key) + ), + ...(additionalQueryStringParameters ?? []), + ]; + + return `${this.options.basePath.get(request)}${request.url.pathname}${ + originalURLSearchParams.length > 0 + ? `?${new URLSearchParams(originalURLSearchParams).toString()}` + : '' + }`; + } + /** * Initializes HTTP Authentication provider and appends it to the end of the list of enabled * authentication providers. @@ -762,9 +793,13 @@ export class Authenticator { /** * Creates a logged out URL for the specified request and provider. * @param request Request that initiated logout. - * @param providerType Type of the provider that handles logout. + * @param providerType Type of the provider that handles logout. If not specified, then the first + * provider in the chain (default) is assumed. */ - private getLoggedOutURL(request: KibanaRequest, providerType: string) { + private getLoggedOutURL( + request: KibanaRequest, + providerType: string = this.options.config.authc.sortedProviders[0].type + ) { // The app that handles logout needs to know the reason of the logout and the URL we may need to // redirect user to once they log in again (e.g. when session expires). const searchParams = new URLSearchParams(); diff --git a/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts index 1507cd2d3a50a..805d647757ca5 100644 --- a/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts +++ b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts @@ -7,6 +7,7 @@ import { httpServerMock } from 'src/core/server/mocks'; +import { ROUTE_TAG_API, ROUTE_TAG_CAN_REDIRECT } from '../routes/tags'; import { canRedirectRequest } from './can_redirect_request'; describe('can_redirect_request', () => { @@ -24,4 +25,33 @@ describe('can_redirect_request', () => { expect(canRedirectRequest(request)).toBe(false); }); + + it('returns false for api routes', () => { + expect( + canRedirectRequest(httpServerMock.createKibanaRequest({ path: '/api/security/some' })) + ).toBe(false); + }); + + it('returns false for internal routes', () => { + expect( + canRedirectRequest(httpServerMock.createKibanaRequest({ path: '/internal/security/some' })) + ).toBe(false); + }); + + it('returns true for the routes with the `security:canRedirect` tag', () => { + for (const request of [ + httpServerMock.createKibanaRequest({ routeTags: [ROUTE_TAG_CAN_REDIRECT] }), + httpServerMock.createKibanaRequest({ routeTags: [ROUTE_TAG_API, ROUTE_TAG_CAN_REDIRECT] }), + httpServerMock.createKibanaRequest({ + path: '/api/security/some', + routeTags: [ROUTE_TAG_CAN_REDIRECT], + }), + httpServerMock.createKibanaRequest({ + path: '/internal/security/some', + routeTags: [ROUTE_TAG_CAN_REDIRECT], + }), + ]) { + expect(canRedirectRequest(request)).toBe(true); + } + }); }); diff --git a/x-pack/plugins/security/server/authentication/can_redirect_request.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.ts index 71c6365d9aea4..5a3a09f17eb86 100644 --- a/x-pack/plugins/security/server/authentication/can_redirect_request.ts +++ b/x-pack/plugins/security/server/authentication/can_redirect_request.ts @@ -7,7 +7,8 @@ import type { KibanaRequest } from 'src/core/server'; -const ROUTE_TAG_API = 'api'; +import { ROUTE_TAG_API, ROUTE_TAG_CAN_REDIRECT } from '../routes/tags'; + const KIBANA_XSRF_HEADER = 'kbn-xsrf'; const KIBANA_VERSION_HEADER = 'kbn-version'; @@ -24,9 +25,9 @@ export function canRedirectRequest(request: KibanaRequest) { const isApiRoute = route.options.tags.includes(ROUTE_TAG_API) || - (route.path.startsWith('/api/') && route.path !== '/api/security/logout') || + route.path.startsWith('/api/') || route.path.startsWith('/internal/'); const isAjaxRequest = hasVersionHeader || hasXsrfHeader; - return !isApiRoute && !isAjaxRequest; + return !isAjaxRequest && (!isApiRoute || route.options.tags.includes(ROUTE_TAG_CAN_REDIRECT)); } diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts index bb78b6e963763..5d3417ae9db11 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -20,6 +20,7 @@ export function mockAuthenticationProviderOptions(options?: { name: string }) { client: elasticsearchServiceMock.createClusterClient(), logger: loggingSystemMock.create().get(), basePath: httpServiceMock.createBasePath(), + getRequestOriginalURL: jest.fn(), tokens: { refresh: jest.fn(), invalidate: jest.fn() }, name: options?.name ?? 'basic1', urls: { diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index 18d567a143fee..c7c0edcf1e9e1 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -27,6 +27,10 @@ import type { Tokens } from '../tokens'; export interface AuthenticationProviderOptions { name: string; basePath: HttpServiceSetup['basePath']; + getRequestOriginalURL: ( + request: KibanaRequest, + additionalQueryStringParameters?: Array<[string, string]> + ) => string; client: IClusterClient; logger: Logger; tokens: PublicMethodsOf; diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index ebeca42682eb9..444a7f3e50a25 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -11,6 +11,10 @@ import Boom from '@hapi/boom'; import type { KibanaRequest } from 'src/core/server'; import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, +} from '../../../common/constants'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { securityMock } from '../../mocks'; import { AuthenticationResult } from '../authentication_result'; @@ -376,18 +380,78 @@ describe('OIDCAuthenticationProvider', () => { }); it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue( + '/mock-server-basepath/s/foo/some-path?auth_provider_hint=oidc' + ); const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); await expect(provider.authenticate(request, null)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=oidc&providerName=oidc', + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Doidc', { state: null } ) ); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'oidc'], + ]); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); + it('initiates OIDC handshake for non-AJAX request that can not be authenticated, but includes URL hash fragment.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/s/foo/some-path'); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + state: 'statevalue', + nonce: 'noncevalue', + redirect: + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + }, + }) + ); + + const request = httpServerMock.createKibanaRequest({ + path: '/s/foo/some-path', + query: { [AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-fragment' }, + }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + { + state: { + state: 'statevalue', + nonce: 'noncevalue', + redirectURL: '/mock-server-basepath/s/foo/some-path#some-fragment', + realm: 'oidc1', + }, + } + ) + ); + + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request); + + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/prepare', + body: { realm: 'oidc1' }, + }); + }); + it('succeeds if state contains a valid token.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const tokenPair = { @@ -520,6 +584,9 @@ describe('OIDCAuthenticationProvider', () => { }); it('redirects non-AJAX requests to the "capture URL" page if refresh token is expired or already refreshed.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue( + '/mock-server-basepath/s/foo/some-path?auth_provider_hint=oidc' + ); const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; @@ -534,11 +601,16 @@ describe('OIDCAuthenticationProvider', () => { provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) ).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=oidc&providerName=oidc', + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Doidc', { state: null } ) ); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'oidc'], + ]); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index 2afa49fe6e082..83f0ec50abb0d 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -10,8 +10,13 @@ import type from 'type-detect'; import type { KibanaRequest } from 'src/core/server'; -import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, + NEXT_URL_QUERY_STRING_PARAMETER, +} from '../../../common/constants'; import type { AuthenticationInfo } from '../../elasticsearch'; +import { getDetailedErrorMessage } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -201,7 +206,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) return authenticationResult.notHandled() && canStartNewSession(request) - ? await this.captureRedirectURL(request) + ? await this.initiateAuthenticationHandshake(request) : authenticationResult; } @@ -264,7 +269,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { }) ).body as any; } catch (err) { - this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); + this.logger.debug( + `Failed to authenticate request via OpenID Connect: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } @@ -313,7 +320,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { { state: { state, nonce, redirectURL, realm: this.realm } } ); } catch (err) { - this.logger.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); + this.logger.debug( + `Failed to initiate OpenID Connect authentication: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } } @@ -341,7 +350,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Request has been authenticated via state.'); return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.logger.debug(`Failed to authenticate request via state: ${err.message}`); + this.logger.debug( + `Failed to authenticate request via state: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } } @@ -379,7 +390,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug( 'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.' ); - return this.captureRedirectURL(request); + return this.initiateAuthenticationHandshake(request); } return AuthenticationResult.failed( @@ -440,7 +451,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.redirectTo(redirect); } } catch (err) { - this.logger.debug(`Failed to deauthenticate user: ${err.message}`); + this.logger.debug(`Failed to deauthenticate user: ${getDetailedErrorMessage(err)}`); return DeauthenticationResult.failed(err); } } @@ -457,22 +468,29 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Tries to capture full redirect URL (both path and fragment) and initiate OIDC handshake. + * Tries to initiate OIDC authentication handshake. If the request already includes user URL hash fragment, we will + * initiate handshake right away, otherwise we'll redirect user to a dedicated page where we capture URL hash fragment + * first and only then initiate SAML handshake. * @param request Request instance. */ - private captureRedirectURL(request: KibanaRequest) { - const searchParams = new URLSearchParams([ - [ - NEXT_URL_QUERY_STRING_PARAMETER, - `${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}`, - ], - ['providerType', this.type], - ['providerName', this.options.name], - ]); + private initiateAuthenticationHandshake(request: KibanaRequest) { + const originalURLHash = request.url.searchParams.get(AUTH_URL_HASH_QUERY_STRING_PARAMETER); + if (originalURLHash != null) { + return this.initiateOIDCAuthentication( + request, + { realm: this.realm }, + `${this.options.getRequestOriginalURL(request)}${originalURLHash}` + ); + } + return AuthenticationResult.redirectTo( `${ this.options.basePath.serverBasePath - }/internal/security/capture-url?${searchParams.toString()}`, + }/internal/security/capture-url?${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent( + this.options.getRequestOriginalURL(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, this.options.name], + ]) + )}`, // Here we indicate that current session, if any, should be invalidated. It is a no-op for the // initial handshake, but is essential when both access and refresh tokens are expired. { state: null } diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index bd51a0f815329..dfcdb66e61c35 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -10,6 +10,10 @@ import Boom from '@hapi/boom'; import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, +} from '../../../common/constants'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { securityMock } from '../../mocks'; import { AuthenticationResult } from '../authentication_result'; @@ -848,18 +852,63 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue( + '/mock-server-basepath/s/foo/some-path?auth_provider_hint=saml' + ); const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); - await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=saml&providerName=saml', + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Dsaml', { state: null } ) ); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'saml'], + ]); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); + it('initiates SAML handshake for non-AJAX request that can not be authenticated, but includes URL hash fragment.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/s/foo/some-path'); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + }, + }) + ); + + const request = httpServerMock.createKibanaRequest({ + path: '/s/foo/some-path', + query: { [AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-fragment' }, + }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { + state: { + requestId: 'some-request-id', + redirectURL: '/mock-server-basepath/s/foo/some-path#some-fragment', + realm: 'test-realm', + }, + } + ) + ); + + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request); + + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/prepare', + body: { realm: 'test-realm' }, + }); + }); + it('succeeds if state contains a valid token.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { @@ -1024,6 +1073,9 @@ describe('SAMLAuthenticationProvider', () => { }); it('re-capture URL for non-AJAX requests if refresh token is expired.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue( + '/mock-server-basepath/s/foo/some-path?auth_provider_hint=saml' + ); const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); const state = { accessToken: 'expired-token', @@ -1040,11 +1092,16 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=saml&providerName=saml', + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Dsaml', { state: null } ) ); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'saml'], + ]); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index 7c27e2ebeff10..ea818e5df6e12 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -9,9 +9,14 @@ import Boom from '@hapi/boom'; import type { KibanaRequest } from 'src/core/server'; -import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, + NEXT_URL_QUERY_STRING_PARAMETER, +} from '../../../common/constants'; import { isInternalURL } from '../../../common/is_internal_url'; import type { AuthenticationInfo } from '../../elasticsearch'; +import { getDetailedErrorMessage } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -185,7 +190,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } else { this.logger.debug( `Failed to perform a login: ${ - authenticationResult.error && authenticationResult.error.message + authenticationResult.error && getDetailedErrorMessage(authenticationResult.error) }` ); } @@ -230,7 +235,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // If we couldn't authenticate by means of all methods above, let's try to capture user URL and // initiate SAML handshake, otherwise just return authentication result we have. return authenticationResult.notHandled() && canStartNewSession(request) - ? this.captureRedirectURL(request) + ? this.initiateAuthenticationHandshake(request) : authenticationResult; } @@ -283,7 +288,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.redirectTo(redirect); } } catch (err) { - this.logger.debug(`Failed to deauthenticate user: ${err.message}`); + this.logger.debug(`Failed to deauthenticate user: ${getDetailedErrorMessage(err)}`); return DeauthenticationResult.failed(err); } } @@ -362,7 +367,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { }) ).body as any; } catch (err) { - this.logger.debug(`Failed to log in with SAML response: ${err.message}`); + this.logger.debug(`Failed to log in with SAML response: ${getDetailedErrorMessage(err)}`); // Since we don't know upfront what realm is targeted by the Identity Provider initiated login // there is a chance that it failed because of realm mismatch and hence we should return @@ -452,7 +457,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { refreshToken: existingState.refreshToken!, }); } catch (err) { - this.logger.debug(`Failed to perform IdP initiated local logout: ${err.message}`); + this.logger.debug( + `Failed to perform IdP initiated local logout: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } @@ -483,7 +490,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Request has been authenticated via state.'); return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.logger.debug(`Failed to authenticate request via state: ${err.message}`); + this.logger.debug( + `Failed to authenticate request via state: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } } @@ -520,7 +529,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug( 'Both access and refresh tokens are expired. Capturing redirect URL and re-initiating SAML handshake.' ); - return this.captureRedirectURL(request); + return this.initiateAuthenticationHandshake(request); } return AuthenticationResult.failed( @@ -569,7 +578,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { state: { requestId, redirectURL, realm: this.realm }, }); } catch (err) { - this.logger.debug(`Failed to initiate SAML handshake: ${err.message}`); + this.logger.debug(`Failed to initiate SAML handshake: ${getDetailedErrorMessage(err)}`); return AuthenticationResult.failed(err); } } @@ -629,22 +638,28 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Tries to capture full redirect URL (both path and fragment) and initiate SAML handshake. + * Tries to initiate SAML authentication handshake. If the request already includes user URL hash fragment, we will + * initiate handshake right away, otherwise we'll redirect user to a dedicated page where we capture URL hash fragment + * first and only then initiate SAML handshake. * @param request Request instance. */ - private captureRedirectURL(request: KibanaRequest) { - const searchParams = new URLSearchParams([ - [ - NEXT_URL_QUERY_STRING_PARAMETER, - `${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}`, - ], - ['providerType', this.type], - ['providerName', this.options.name], - ]); + private initiateAuthenticationHandshake(request: KibanaRequest) { + const originalURLHash = request.url.searchParams.get(AUTH_URL_HASH_QUERY_STRING_PARAMETER); + if (originalURLHash != null) { + return this.authenticateViaHandshake( + request, + `${this.options.getRequestOriginalURL(request)}${originalURLHash}` + ); + } + return AuthenticationResult.redirectTo( `${ this.options.basePath.serverBasePath - }/internal/security/capture-url?${searchParams.toString()}`, + }/internal/security/capture-url?${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent( + this.options.getRequestOriginalURL(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, this.options.name], + ]) + )}`, // Here we indicate that current session, if any, should be invalidated. It is a no-op for the // initial handshake, but is essential when both access and refresh tokens are expired. { state: null } diff --git a/x-pack/plugins/security/server/authentication/tokens.ts b/x-pack/plugins/security/server/authentication/tokens.ts index 8f6dd9275e59c..1adbb2dc66533 100644 --- a/x-pack/plugins/security/server/authentication/tokens.ts +++ b/x-pack/plugins/security/server/authentication/tokens.ts @@ -8,7 +8,7 @@ import type { ElasticsearchClient, Logger } from 'src/core/server'; import type { AuthenticationInfo } from '../elasticsearch'; -import { getErrorStatusCode } from '../errors'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; /** * Represents a pair of access and refresh tokens. @@ -73,11 +73,11 @@ export class Tokens { return { accessToken, refreshToken, - // @ts-expect-error @elastic/elasticsearch decalred GetUserAccessTokenResponse.authentication: string + // @ts-expect-error @elastic/elasticsearch declared GetUserAccessTokenResponse.authentication: string authenticationInfo: authenticationInfo as AuthenticationInfo, }; } catch (err) { - this.logger.debug(`Failed to refresh access token: ${err.message}`); + this.logger.debug(`Failed to refresh access token: ${getDetailedErrorMessage(err)}`); // There are at least two common cases when refresh token request can fail: // 1. Refresh token is valid only for 24 hours and if it hasn't been used it expires. @@ -123,7 +123,7 @@ export class Tokens { }) ).body.invalidated_tokens; } catch (err) { - this.logger.debug(`Failed to invalidate refresh token: ${err.message}`); + this.logger.debug(`Failed to invalidate refresh token: ${getDetailedErrorMessage(err)}`); // When using already deleted refresh token, Elasticsearch responds with 404 and a body that // shows that no tokens were invalidated. @@ -155,7 +155,7 @@ export class Tokens { }) ).body.invalidated_tokens; } catch (err) { - this.logger.debug(`Failed to invalidate access token: ${err.message}`); + this.logger.debug(`Failed to invalidate access token: ${getDetailedErrorMessage(err)}`); // When using already deleted access token, Elasticsearch responds with 404 and a body that // shows that no tokens were invalidated. diff --git a/x-pack/plugins/security/server/authentication/unauthenticated_page.test.tsx b/x-pack/plugins/security/server/authentication/unauthenticated_page.test.tsx new file mode 100644 index 0000000000000..5cb6c899d7560 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/unauthenticated_page.test.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 from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { coreMock } from '../../../../../src/core/server/mocks'; +import { UnauthenticatedPage } from './unauthenticated_page'; + +jest.mock('src/core/server/rendering/views/fonts', () => ({ + Fonts: () => <>MockedFonts, +})); + +describe('UnauthenticatedPage', () => { + it('renders as expected', async () => { + const mockCoreSetup = coreMock.createSetup(); + (mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation( + (path) => `/mock-basepath${path}` + ); + + const body = renderToStaticMarkup( + + ); + + expect(body).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/server/authentication/unauthenticated_page.tsx b/x-pack/plugins/security/server/authentication/unauthenticated_page.tsx new file mode 100644 index 0000000000000..48d61a72e085d --- /dev/null +++ b/x-pack/plugins/security/server/authentication/unauthenticated_page.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. + */ + +// @ts-expect-error no definitions in component folder +import { EuiButton } from '@elastic/eui/lib/components/button'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { IBasePath } from 'src/core/server'; + +import { PromptPage } from '../prompt_page'; + +interface Props { + originalURL: string; + buildNumber: number; + basePath: IBasePath; +} + +export function UnauthenticatedPage({ basePath, originalURL, buildNumber }: Props) { + return ( + + +

+ } + actions={[ + + + , + ]} + /> + ); +} + +export function renderUnauthenticatedPage(props: Props) { + return renderToStaticMarkup(); +} diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap index 785c57490e8ef..1011d82eb1f73 100644 --- a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap +++ b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ResetSessionPage renders as expected 1`] = `"MockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; +exports[`ResetSessionPage renders as expected 1`] = `"ElasticMockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; diff --git a/x-pack/plugins/security/server/authorization/authorization_service.tsx b/x-pack/plugins/security/server/authorization/authorization_service.tsx index db3c84477ffb1..144a8bc5fd0c4 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.tsx +++ b/x-pack/plugins/security/server/authorization/authorization_service.tsx @@ -10,7 +10,6 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import type { Observable, Subscription } from 'rxjs'; -import * as UiSharedDeps from '@kbn/ui-shared-deps'; import type { CapabilitiesSetup, HttpServiceSetup, @@ -163,25 +162,14 @@ export class AuthorizationService { http.registerOnPreResponse((request, preResponse, toolkit) => { if (preResponse.statusCode === 403 && canRedirectRequest(request)) { - const basePath = http.basePath.get(request); - const next = `${basePath}${request.url.pathname}${request.url.search}`; - const regularBundlePath = `${basePath}/${buildNumber}/bundles`; - - const logoutUrl = http.basePath.prepend( - `/api/security/logout?${querystring.stringify({ next })}` - ); - const styleSheetPaths = [ - `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`, - `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, - `${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, - `${basePath}/ui/legacy_light_theme.css`, - ]; - + const next = `${http.basePath.get(request)}${request.url.pathname}${request.url.search}`; const body = renderToStaticMarkup( ); diff --git a/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx b/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx index e76c8ff138fcb..d5e27c9d39ffd 100644 --- a/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx +++ b/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; +import { coreMock } from '../../../../../src/core/server/mocks'; import { ResetSessionPage } from './reset_session_page'; jest.mock('src/core/server/rendering/views/fonts', () => ({ @@ -16,11 +17,16 @@ jest.mock('src/core/server/rendering/views/fonts', () => ({ describe('ResetSessionPage', () => { it('renders as expected', async () => { + const mockCoreSetup = coreMock.createSetup(); + (mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation( + (path) => `/mock-basepath${path}` + ); + const body = renderToStaticMarkup( ); diff --git a/x-pack/plugins/security/server/authorization/reset_session_page.tsx b/x-pack/plugins/security/server/authorization/reset_session_page.tsx index c2d43cd3dd030..4e2e6f4631287 100644 --- a/x-pack/plugins/security/server/authorization/reset_session_page.tsx +++ b/x-pack/plugins/security/server/authorization/reset_session_page.tsx @@ -7,101 +7,53 @@ // @ts-expect-error no definitions in component folder import { EuiButton, EuiButtonEmpty } from '@elastic/eui/lib/components/button'; -// @ts-expect-error no definitions in component folder -import { EuiEmptyPrompt } from '@elastic/eui/lib/components/empty_prompt'; -// @ts-expect-error no definitions in component folder -import { icon as EuiIconAlert } from '@elastic/eui/lib/components/icon/assets/alert'; -// @ts-expect-error no definitions in component folder -import { appendIconComponentCache } from '@elastic/eui/lib/components/icon/icon'; -// @ts-expect-error no definitions in component folder -import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui/lib/components/page'; import React from 'react'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; - -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Fonts } from '../../../../../src/core/server/rendering/views/fonts'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { IBasePath } from 'src/core/server'; -// Preload the alert icon used by `EuiEmptyPrompt` to ensure that it's loaded -// in advance the first time this page is rendered server-side. If not, the -// icon svg wouldn't contain any paths the first time the page was rendered. -appendIconComponentCache({ - alert: EuiIconAlert, -}); +import { PromptPage } from '../prompt_page'; export function ResetSessionPage({ logoutUrl, - styleSheetPaths, + buildNumber, basePath, }: { logoutUrl: string; - styleSheetPaths: string[]; - basePath: string; + buildNumber: number; + basePath: IBasePath; }) { - const uiPublicUrl = `${basePath}/ui`; return ( - - - {styleSheetPaths.map((path) => ( - - ))} - - {/* The alternate icon is a fallback for Safari which does not yet support SVG favicons */} - - - - - - - - - - - - - - } - body={ -

- -

- } - actions={[ - - - , - - - , - ]} - /> -
-
-
-
- - + + +

+ } + actions={[ + + + , + + + , + ]} + /> ); } diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index b66ed6e9eb7ca..087cf8f4f8ee8 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -30,6 +30,7 @@ export type { CheckPrivilegesPayload } from './authorization'; export { LegacyAuditLogger, AuditLogger, AuditEvent } from './audit'; export type { SecurityPluginSetup, SecurityPluginStart }; export type { AuthenticatedUser } from '../common/model'; +export { ROUTE_TAG_CAN_REDIRECT } from './routes/tags'; export const config: PluginConfigDescriptor> = { schema: ConfigSchema, diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 586707dd8c9aa..57be308525fdd 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -246,7 +246,12 @@ export class SecurityPlugin this.elasticsearchService.setup({ license, status: core.status }); this.featureUsageService.setup({ featureUsage: licensing.featureUsage }); this.sessionManagementService.setup({ config, http: core.http, taskManager }); - this.authenticationService.setup({ http: core.http, license }); + this.authenticationService.setup({ + http: core.http, + config, + license, + buildNumber: this.initializerContext.env.packageInfo.buildNum, + }); registerSecurityUsageCollector({ usageCollection, config, license }); diff --git a/x-pack/plugins/security/server/prompt_page.test.tsx b/x-pack/plugins/security/server/prompt_page.test.tsx new file mode 100644 index 0000000000000..01c4488576f57 --- /dev/null +++ b/x-pack/plugins/security/server/prompt_page.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { renderToStaticMarkup } from 'react-dom/server'; + +import { coreMock } from '../../../../src/core/server/mocks'; +import { PromptPage } from './prompt_page'; + +jest.mock('src/core/server/rendering/views/fonts', () => ({ + Fonts: () => <>MockedFonts, +})); + +describe('PromptPage', () => { + it('renders as expected without additional scripts', async () => { + const mockCoreSetup = coreMock.createSetup(); + (mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation( + (path) => `/mock-basepath${path}` + ); + + const body = renderToStaticMarkup( + Some Body
} + actions={[Action#1, Action#2]} + /> + ); + + expect(body).toMatchSnapshot(); + }); + + it('renders as expected with additional scripts', async () => { + const mockCoreSetup = coreMock.createSetup(); + (mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation( + (path) => `/mock-basepath${path}` + ); + + const body = renderToStaticMarkup( + Some Body
} + actions={[Action#1, Action#2]} + /> + ); + + expect(body).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/server/prompt_page.tsx b/x-pack/plugins/security/server/prompt_page.tsx new file mode 100644 index 0000000000000..338d39b29e534 --- /dev/null +++ b/x-pack/plugins/security/server/prompt_page.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. + */ + +// @ts-expect-error no definitions in component folder +import { EuiEmptyPrompt } from '@elastic/eui/lib/components/empty_prompt'; +// @ts-expect-error no definitions in component folder +import { icon as EuiIconAlert } from '@elastic/eui/lib/components/icon/assets/alert'; +// @ts-expect-error no definitions in component folder +import { appendIconComponentCache } from '@elastic/eui/lib/components/icon/icon'; +// @ts-expect-error no definitions in component folder +import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui/lib/components/page'; +import type { ReactNode } from 'react'; +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; +import type { IBasePath } from 'src/core/server'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { Fonts } from '../../../../src/core/server/rendering/views/fonts'; + +// Preload the alert icon used by `EuiEmptyPrompt` to ensure that it's loaded +// in advance the first time this page is rendered server-side. If not, the +// icon svg wouldn't contain any paths the first time the page was rendered. +appendIconComponentCache({ + alert: EuiIconAlert, +}); + +interface Props { + buildNumber: number; + basePath: IBasePath; + scriptPaths?: string[]; + title: ReactNode; + body: ReactNode; + actions: ReactNode; +} + +export function PromptPage({ + basePath, + buildNumber, + scriptPaths = [], + title, + body, + actions, +}: Props) { + const uiPublicURL = `${basePath.serverBasePath}/ui`; + const regularBundlePath = `${basePath.serverBasePath}/${buildNumber}/bundles`; + const styleSheetPaths = [ + `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`, + `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, + `${basePath.serverBasePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, + `${basePath.serverBasePath}/ui/legacy_light_theme.css`, + ]; + + return ( + + + Elastic + {styleSheetPaths.map((path) => ( + + ))} + + {/* The alternate icon is a fallback for Safari which does not yet support SVG favicons */} + + + {scriptPaths.map((path) => ( +