diff --git a/docs/api/saved-objects/find.asciidoc b/docs/api/saved-objects/find.asciidoc index ad07112567479..6f293fba2d0ef 100644 --- a/docs/api/saved-objects/find.asciidoc +++ b/docs/api/saved-objects/find.asciidoc @@ -44,7 +44,12 @@ experimental[] Retrieve a paginated set of {kib} saved objects by various condit (Optional, array|string) The fields to return in the `attributes` key of the response. `sort_field`:: - (Optional, string) The field that sorts the response. + (Optional, string) The field that sorts the response. There are two kinds of fields: "root" fields that exist for all saved objects (such + as "updated_at"), and "type" fields that are specific to a given object type (e.g. those fields that are returned in the `attributes` key + of the response). + * If a single type is defined in the `type` parameter, both "type" fields and "root" fields are allowed, and validity checks are made in + that order. + * If multiple types are defined in the `type` parameter, only "root" fields are allowed. `has_reference`:: (Optional, object) Filters to objects that have a relationship with the type and ID combination. diff --git a/docs/api/saved-objects/import.asciidoc b/docs/api/saved-objects/import.asciidoc index 634b800a60428..6bdc94879bf15 100644 --- a/docs/api/saved-objects/import.asciidoc +++ b/docs/api/saved-objects/import.asciidoc @@ -54,12 +54,15 @@ The request body must include the multipart/form-data type. `errors`:: (array) Indicates the import was unsuccessful and specifies the objects that failed to import. ++ +NOTE: One object may result in multiple errors which require separate steps to resolve (for instance, a `missing_references` error and a +`conflict` error). `successResults`:: (array) Indicates the objects that were imported successfully, with any metadata if applicable. + NOTE: No objects are actually created until all resolvable errors have been addressed! This includes conflict errors and missing references -errors. See the <> documentation for more information. +errors. See the examples below for how to resolve these errors. [[saved-objects-api-import-codes]] ==== Response code @@ -100,12 +103,20 @@ The API returns the following: { "id": "my-pattern", "type": "index-pattern", - "destinationId": "4aba3770-0d04-45e1-9e34-4cf0fd2165ae" + "destinationId": "4aba3770-0d04-45e1-9e34-4cf0fd2165ae", + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } }, { "id": "my-dashboard", "type": "dashboard", - "destinationId": "c31d1eca-9bc0-4a81-b5f9-30c442824c48" + "destinationId": "c31d1eca-9bc0-4a81-b5f9-30c442824c48", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } } ] } @@ -143,11 +154,19 @@ The API returns the following: "successResults": [ { "id": "my-pattern", - "type": "index-pattern" + "type": "index-pattern", + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } }, { "id": "my-dashboard", - "type": "dashboard" + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } } ] } @@ -190,6 +209,10 @@ The API returns the following: "title": "my-pattern-*", "error": { "type": "conflict" + }, + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" } }, { @@ -199,6 +222,10 @@ The API returns the following: "error": { "type": "conflict", "destinationId": "another-vis" + }, + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" } }, { @@ -219,13 +246,21 @@ The API returns the following: "updatedAt": "2020-07-05T12:29:54.849Z" } ] + }, + "meta": { + "icon": "canvasApp", + "title": "Look at my canvas" } } ], "successResults": [ { "id": "my-dashboard", - "type": "dashboard" + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } } ] } @@ -267,15 +302,17 @@ The `file.ndjson` file contains the following: [source,sh] -------------------------------------------------- {"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern-*"}]} -{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]} +{"type":"search","id":"my-search","attributes":{"title":"Look at my search"},"references":[{"name":"ref_0","type":"index-pattern","id":"another-pattern-*"}]} +{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"},{"name":"ref_1","type":"search","id":"my-search"}]} -------------------------------------------------- The API returns the following: [source,sh] -------------------------------------------------- +{ "success": false, - "successCount": 0, + "successCount": 1, "errors": [ { "id": "my-vis", @@ -288,17 +325,46 @@ The API returns the following: "type": "index-pattern", "id": "my-pattern-*" } - ], - "blocking": [ + ] + }, + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } + }, + { + "id": "my-search", + "type": "search", + "title": "Look at my search", + "error": { + "type": "missing_references", + "references": [ { - "type": "dashboard", - "id": "my-dashboard" + "type": "index-pattern", + "id": "another-pattern-*" } ] + }, + "meta": { + "icon": "searchApp", + "title": "Look at my search" + } + } + ], + "successResults": [ + { + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" } } ] +} -------------------------------------------------- -This result indicates that the import was not successful because the visualization resulted in a missing references error. No objects will -be imported until this error is resolved using the <>. +This result indicates that the import was not successful because the visualization and search each resulted in a missing references error. + +No objects will be imported until these errors are resolved using the <>. diff --git a/docs/api/saved-objects/resolve_import_errors.asciidoc b/docs/api/saved-objects/resolve_import_errors.asciidoc index 7aa3e37756905..f5319f4db70d0 100644 --- a/docs/api/saved-objects/resolve_import_errors.asciidoc +++ b/docs/api/saved-objects/resolve_import_errors.asciidoc @@ -59,6 +59,8 @@ The request body must include the multipart/form-data type. (Optional, string) Specifies which destination ID the imported object should have (if different from the current ID). `replaceReferences`::: (Optional, array) A list of `type`, `from`, and `to` used to change the object references. + `ignoreMissingReferences`::: + (Optional, boolean) When set to `true`, any missing references errors are ignored. When set to `false`, this does nothing. ===== [[saved-objects-api-resolve-import-errors-response-body]] @@ -73,6 +75,9 @@ The request body must include the multipart/form-data type. `errors`:: (Optional, array) Specifies the objects that failed to resolve. ++ +NOTE: One object may result in multiple errors which require separate steps to resolve (for instance, a `missing_references` error and a +`conflict` error). `successResults`:: (Optional, array) Indicates the objects that were imported successfully, with any metadata if applicable. @@ -122,21 +127,37 @@ The API returns the following: "successResults": [ { "id": "my-pattern", - "type": "index-pattern" + "type": "index-pattern", + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } }, { "id": "my-vis", "type": "visualization", - "destinationId": "another-vis" + "destinationId": "another-vis", + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } }, { "id": "my-canvas", "type": "canvas-workpad", - "destinationId": "yet-another-canvas" + "destinationId": "yet-another-canvas", + "meta": { + "icon": "canvasApp", + "title": "Look at my canvas" + } }, { "id": "my-dashboard", - "type": "dashboard" + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } } ] } @@ -152,11 +173,12 @@ that were returned in the `successResults` array. In this example, we retried im This example builds upon the <>. -Resolve a missing reference error for a visualization by replacing the index pattern with another: +Resolve a missing reference error for a visualization by replacing the index pattern with another, and resolve a missing reference error for +a search by ignoring it: [source,sh] -------------------------------------------------- -$ curl -X POST api/saved_objects/_resolve_import_errors -H "kbn-xsrf: true" --form file=@file.ndjson --form retries='[{"type":"visualization","id":"my-vis","replaceReferences":[{"type":"index-pattern","from":"my-pattern-*","to":"existing-pattern"}]},{"type":"dashboard","id":"my-dashboard"}]' +$ curl -X POST api/saved_objects/_resolve_import_errors -H "kbn-xsrf: true" --form file=@file.ndjson --form retries='[{"type":"visualization","id":"my-vis","replaceReferences":[{"type":"index-pattern","from":"my-pattern-*","to":"existing-pattern"}]},{"type":"search","id":"my-search","ignoreMissingReferences":true},{"type":"dashboard","id":"my-dashboard"}]' -------------------------------------------------- // KIBANA @@ -165,6 +187,7 @@ The `file.ndjson` file contains the following: [source,sh] -------------------------------------------------- {"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern-*"}]} +{"type":"search","id":"my-search","attributes":{"title":"Look at my search"},"references":[{"name":"ref_0","type":"index-pattern","id":"another-pattern-*"}]} {"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]} -------------------------------------------------- @@ -174,21 +197,37 @@ The API returns the following: -------------------------------------------------- { "success": true, - "successCount": 2, + "successCount": 3, "successResults": [ { - "id": "my-pattern", - "type": "index-pattern" + "id": "my-vis", + "type": "visualization", + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } + }, + { + "id": "my-search", + "type": "search", + "meta": { + "icon": "searchApp", + "title": "Look at my search" + } }, { "id": "my-dashboard", - "type": "dashboard" + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } } ] } -------------------------------------------------- -This result indicates that the import was successful, and both objects were created. +This result indicates that the import was successful, and all three objects were created. TIP: If a prior import attempt resulted in resolvable errors, you must include a retry for each object you want to import, including any -that were described in the missing error object's `blocked` array. In this example, we retried importing the dashboard accordingly. +that were returned in the `successResults` array. In this example, we retried importing the dashboard accordingly. diff --git a/docs/api/spaces-management/copy_saved_objects.asciidoc b/docs/api/spaces-management/copy_saved_objects.asciidoc index 323f0f1805575..bb6e95c32fb88 100644 --- a/docs/api/spaces-management/copy_saved_objects.asciidoc +++ b/docs/api/spaces-management/copy_saved_objects.asciidoc @@ -75,6 +75,9 @@ You can request to overwrite any objects that already exist in the target space `errors`::: (Optional, array) The errors that occurred during the copy operation. When errors are reported, the `success` flag is set to `false`. + +NOTE: One object may result in multiple errors which require separate steps to resolve (for instance, a `missing_references` error and a +`conflict` error). ++ .Properties of `errors` [%collapsible%open] ====== @@ -103,8 +106,8 @@ You can request to overwrite any objects that already exist in the target space `successResults`::: (Optional, array) Indicates the objects that were copied successfully, with any metadata if applicable. + -NOTE: No objects are actually created until all resolvable errors have been addressed! This includes conflict errors. See the examples below -for how to resolve these errors. +NOTE: No objects are actually created until all resolvable errors have been addressed! This includes conflict errors and missing references +errors. See the examples below for how to resolve these errors. ===== [[spaces-api-copy-saved-objects-example]] @@ -143,17 +146,29 @@ The API returns the following: { "id": "my-dashboard", "type": "dashboard", - "destinationId": "1e127098-5b80-417f-b0f1-c60c8395358f" + "destinationId": "1e127098-5b80-417f-b0f1-c60c8395358f", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } }, { "id": "my-vis", "type": "visualization", - "destinationId": "a610ed80-1c73-4507-9e13-d3af736c8e04" + "destinationId": "a610ed80-1c73-4507-9e13-d3af736c8e04", + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } }, { "id": "my-index-pattern", "type": "index-pattern", - "destinationId": "bc3c9c70-bf6f-4bec-b4ce-f4189aa9e26b" + "destinationId": "bc3c9c70-bf6f-4bec-b4ce-f4189aa9e26b", + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } } ] } @@ -194,22 +209,34 @@ The API returns the following: "successResults": [ { "id": "my-dashboard", - "type": "dashboard" + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } }, { "id": "my-vis", - "type": "visualization" + "type": "visualization", + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } }, { "id": "my-index-pattern", - "type": "index-pattern" + "type": "index-pattern", + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } } ] } } ---- -This result indicates that the import was successful, and all three objects were created. +This result indicates that the copy was successful, and all three objects were created. [[spaces-api-copy-saved-objects-example-3]] ===== 3. Failed copy (with conflict errors) @@ -241,20 +268,36 @@ The API returns the following: "successCount": 4, "successResults": [ { - "id": "other-dashboard", - "type": "dashboard" + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } }, { "id": "my-vis", - "type": "visualization" + "type": "visualization", + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } }, { "id": "my-canvas", - "type": "canvas-workpad" + "type": "canvas-workpad", + "meta": { + "icon": "canvasApp", + "title": "Look at my canvas" + } }, { "id": "my-index-pattern", - "type": "index-pattern" + "type": "index-pattern", + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } } ] }, @@ -268,6 +311,10 @@ The API returns the following: "title": "my-pattern-*", "error": { "type": "conflict" + }, + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" } }, { @@ -277,6 +324,10 @@ The API returns the following: "error": { "type": "conflict", "destinationId": "another-vis" + }, + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" } }, { @@ -297,13 +348,21 @@ The API returns the following: "updatedAt": "2020-07-05T12:29:54.849Z" } ] + }, + "meta": { + "icon": "canvasApp", + "title": "Look at my canvas" } } ], "successResults": [ { "id": "my-dashboard", - "type": "dashboard" + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } } ] } @@ -327,4 +386,80 @@ array describes to the other canvases which caused this conflict. When a shareab to another space where an object of the same origin exists, this situation may occur. This can be resolved by picking one of the destination objects to overwrite, or skipping this object entirely. -These errors need to be addressed using the <>. +No objects will be copied until these errors are resolved using the <>. + +[[spaces-api-copy-saved-objects-example-4]] +===== 4. Failed copy (with missing reference errors) + +Copy a dashboard with the `my-dashboard` ID, including all references from the `default` space to the `marketing` space. In this example, +the dashboard has a reference to a visualization and a canvas, and the visualization has a reference to an index pattern: + +[source,sh] +---- +$ curl -X POST api/spaces/_copy_saved_objects +{ + "objects": [{ + "type": "dashboard", + "id": "my-dashboard" + }], + "spaces": ["marketing"], + "includeReferences": true +} +---- +// KIBANA + +The API returns the following: + +[source,sh] +---- +{ + "marketing": { + "success": false, + "successCount": 2, + "errors": [ + { + "id": "my-vis", + "type": "visualization", + "title": "Look at my visualization", + "error": { + "type": "missing_references", + "references": [ + { + "type": "index-pattern", + "id": "my-pattern-*" + } + ] + }, + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } + }, + ] + "successResults": [ + { + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } + }, + { + "id": "my-canvas", + "type": "canvas-workpad", + "meta": { + "icon": "canvasApp", + "title": "Look at my canvas" + } + } + ], + } +} +---- + +This result indicates that the copy was not successful because the visualization resulted in a missing references error. + +No objects will be copied until this error is resolved using the <>. diff --git a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc index c1498f1b99878..d2c76c2bccc36 100644 --- a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc +++ b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc @@ -67,6 +67,8 @@ Execute the <>, w (Required, boolean) When set to `true`, the saved object from the source space (desigated by the <>) overwrites the conflicting object in the destination space. When set to `false`, this does nothing. `destinationId`:::: (Optional, string) Specifies which destination ID the copied object should have (if different from the current ID). + `ignoreMissingReferences`::: + (Optional, boolean) When set to `true`, any missing references errors are ignored. When set to `false`, this does nothing. ====== ===== @@ -89,6 +91,9 @@ Execute the <>, w `errors`::: (Optional, array) The errors that occurred during the copy operation. When errors are reported, the `success` flag is set to `false`. + +NOTE: One object may result in multiple errors which require separate steps to resolve (for instance, a `missing_references` error and a +`conflict` error). ++ .Properties of `errors` [%collapsible%open] @@ -119,8 +124,8 @@ Execute the <>, w `successResults`::: (Optional, array) Indicates the objects that were copied successfully, with any metadata if applicable. + -NOTE: No objects are actually created until all resolvable errors have been addressed! This includes conflict errors. See the examples below -for how to resolve these errors. +NOTE: No objects are actually created until all resolvable errors have been addressed! This includes conflict errors and missing references +errors. See the examples below for how to resolve these errors. ===== @@ -183,21 +188,37 @@ The API returns the following: "successResults": [ { "id": "my-pattern", - "type": "index-pattern" + "type": "index-pattern", + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } }, { "id": "my-vis", "type": "visualization", - "destinationId": "another-vis" + "destinationId": "another-vis", + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } }, { "id": "my-canvas", "type": "canvas-workpad", - "destinationId": "yet-another-canvas" + "destinationId": "yet-another-canvas", + "meta": { + "icon": "canvasApp", + "title": "Look at my canvas" + } }, { "id": "my-dashboard", - "type": "dashboard" + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } } ] } @@ -208,3 +229,83 @@ This result indicates that the copy was successful, and all four objects were cr TIP: If a prior copy attempt resulted in resolvable errors, you must include a retry for each object you want to copy, including any that were returned in the `successResults` array. In this example, we retried copying the dashboard accordingly. + +[[spaces-api-resolve-copy-saved-objects-conflicts-example-2]] +===== 2. Resolve missing reference errors + +This example builds upon the <>. + +Resolve missing reference errors for a visualization by ignoring the error: + +[source,sh] +---- +$ curl -X POST api/spaces/_resolve_copy_saved_objects_errors +{ + "objects": [{ + "type": "dashboard", + "id": "my-dashboard" + }], + "includeReferences": true, + "retries": { + "marketing": [ + { + "type": "visualization", + "id": "my-vis", + "ignoreMissingReferences": true + }, + { + "type": "canvas", + "id": "my-canvas" + }, + { + "type": "dashboard", + "id": "my-dashboard" + } + ] + } +} +---- +// KIBANA + +The API returns the following: + +[source,sh] +---- +{ + "marketing": { + "success": true, + "successCount": 3, + "successResults": [ + { + "id": "my-vis", + "type": "visualization", + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } + }, + { + "id": "my-canvas", + "type": "canvas-workpad", + "meta": { + "icon": "canvasApp", + "title": "Look at my canvas" + } + }, + { + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } + } + ] + } +} +---- + +This result indicates that the copy was successful, and all three objects were created. + +TIP: If a prior copy attempt resulted in resolvable errors, you must include a retry for each object you want to copy, including any that +were returned in the `successResults` array. In this example, we retried copying the dashboard and canvas accordingly. diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index b9533a66fa7b5..c931ce544f5d5 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -178,6 +178,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value | | [SavedObjectAttributeSingle](./kibana-plugin-core-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | | [SavedObjectsClientContract](./kibana-plugin-core-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) | +| [SavedObjectsNamespaceType](./kibana-plugin-core-public.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. | | [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed start. | | [StringValidation](./kibana-plugin-core-public.stringvalidation.md) | Allows regex objects or a regex string | | [Toast](./kibana-plugin-core-public.toast.md) | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.md index 8e7f9d6ef347e..e12396e9fa7b9 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.md @@ -18,6 +18,8 @@ export interface SavedObjectsImportError | --- | --- | --- | | [error](./kibana-plugin-core-public.savedobjectsimporterror.error.md) | SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError | | | [id](./kibana-plugin-core-public.savedobjectsimporterror.id.md) | string | | +| [meta](./kibana-plugin-core-public.savedobjectsimporterror.meta.md) | {
title?: string;
icon?: string;
} | | +| [overwrite](./kibana-plugin-core-public.savedobjectsimporterror.overwrite.md) | boolean | If overwrite is specified, an attempt was made to overwrite an existing object. | | [title](./kibana-plugin-core-public.savedobjectsimporterror.title.md) | string | | | [type](./kibana-plugin-core-public.savedobjectsimporterror.type.md) | string | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.meta.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.meta.md new file mode 100644 index 0000000000000..97bf3c4cff8eb --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.meta.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportError](./kibana-plugin-core-public.savedobjectsimporterror.md) > [meta](./kibana-plugin-core-public.savedobjectsimporterror.meta.md) + +## SavedObjectsImportError.meta property + +Signature: + +```typescript +meta: { + title?: string; + icon?: string; + }; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.overwrite.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.overwrite.md new file mode 100644 index 0000000000000..69a8726b0588a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.overwrite.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportError](./kibana-plugin-core-public.savedobjectsimporterror.md) > [overwrite](./kibana-plugin-core-public.savedobjectsimporterror.overwrite.md) + +## SavedObjectsImportError.overwrite property + +If `overwrite` is specified, an attempt was made to overwrite an existing object. + +Signature: + +```typescript +overwrite?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.title.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.title.md index 40e5814d30fb3..95eeaaedf94c5 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.title.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.title.md @@ -4,6 +4,11 @@ ## SavedObjectsImportError.title property +> Warning: This API is now obsolete. +> +> Use `meta.title` instead +> + Signature: ```typescript diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.blocking.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.blocking.md deleted file mode 100644 index 5b6862fa21bbc..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.blocking.md +++ /dev/null @@ -1,14 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportMissingReferencesError](./kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.md) > [blocking](./kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.blocking.md) - -## SavedObjectsImportMissingReferencesError.blocking property - -Signature: - -```typescript -blocking: Array<{ - type: string; - id: string; - }>; -``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.md index 4417a19b28792..1fea85ea239d5 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.md @@ -16,7 +16,6 @@ export interface SavedObjectsImportMissingReferencesError | Property | Type | Description | | --- | --- | --- | -| [blocking](./kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.blocking.md) | Array<{
type: string;
id: string;
}> | | | [references](./kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.references.md) | Array<{
type: string;
id: string;
}> | | | [type](./kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.type.md) | 'missing_references' | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.ignoremissingreferences.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.ignoremissingreferences.md new file mode 100644 index 0000000000000..4ce833f2966cc --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.ignoremissingreferences.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportRetry](./kibana-plugin-core-public.savedobjectsimportretry.md) > [ignoreMissingReferences](./kibana-plugin-core-public.savedobjectsimportretry.ignoremissingreferences.md) + +## SavedObjectsImportRetry.ignoreMissingReferences property + +If `ignoreMissingReferences` is specified, reference validation will be skipped for this object. + +Signature: + +```typescript +ignoreMissingReferences?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.md index a785438c55fd4..b0bda93ef8b72 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.md @@ -19,6 +19,7 @@ export interface SavedObjectsImportRetry | [createNewCopy](./kibana-plugin-core-public.savedobjectsimportretry.createnewcopy.md) | boolean | If createNewCopy is specified, the new object has a new (undefined) origin ID. This is only needed for the case where createNewCopies mode is disabled and ambiguous source conflicts are detected. | | [destinationId](./kibana-plugin-core-public.savedobjectsimportretry.destinationid.md) | string | The object ID that will be created or overwritten. If not specified, the id field will be used. | | [id](./kibana-plugin-core-public.savedobjectsimportretry.id.md) | string | | +| [ignoreMissingReferences](./kibana-plugin-core-public.savedobjectsimportretry.ignoremissingreferences.md) | boolean | If ignoreMissingReferences is specified, reference validation will be skipped for this object. | | [overwrite](./kibana-plugin-core-public.savedobjectsimportretry.overwrite.md) | boolean | | | [replaceReferences](./kibana-plugin-core-public.savedobjectsimportretry.replacereferences.md) | Array<{
type: string;
from: string;
to: string;
}> | | | [type](./kibana-plugin-core-public.savedobjectsimportretry.type.md) | string | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.md index 9eb39c96a1b78..4872deb5ee0db 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.md @@ -19,5 +19,7 @@ export interface SavedObjectsImportSuccess | [createNewCopy](./kibana-plugin-core-public.savedobjectsimportsuccess.createnewcopy.md) | boolean | | | [destinationId](./kibana-plugin-core-public.savedobjectsimportsuccess.destinationid.md) | string | If destinationId is specified, the new object has a new ID that is different from the import ID. | | [id](./kibana-plugin-core-public.savedobjectsimportsuccess.id.md) | string | | +| [meta](./kibana-plugin-core-public.savedobjectsimportsuccess.meta.md) | {
title?: string;
icon?: string;
} | | +| [overwrite](./kibana-plugin-core-public.savedobjectsimportsuccess.overwrite.md) | boolean | If overwrite is specified, this object overwrote an existing one (or will do so, in the case of a pending resolution). | | [type](./kibana-plugin-core-public.savedobjectsimportsuccess.type.md) | string | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.meta.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.meta.md new file mode 100644 index 0000000000000..d1c7bc92b5cbf --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.meta.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) > [meta](./kibana-plugin-core-public.savedobjectsimportsuccess.meta.md) + +## SavedObjectsImportSuccess.meta property + +Signature: + +```typescript +meta: { + title?: string; + icon?: string; + }; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.overwrite.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.overwrite.md new file mode 100644 index 0000000000000..18ae2ca9bee3d --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.overwrite.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) > [overwrite](./kibana-plugin-core-public.savedobjectsimportsuccess.overwrite.md) + +## SavedObjectsImportSuccess.overwrite property + +If `overwrite` is specified, this object overwrote an existing one (or will do so, in the case of a pending resolution). + +Signature: + +```typescript +overwrite?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md new file mode 100644 index 0000000000000..f2205d2cee424 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsNamespaceType](./kibana-plugin-core-public.savedobjectsnamespacetype.md) + +## SavedObjectsNamespaceType type + +The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. + +Signature: + +```typescript +export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 74aa9bad89861..2a2a6f5592717 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -304,7 +304,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsClientFactoryProvider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) | Provider to invoke to retrieve a [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md). | | [SavedObjectsClientWrapperFactory](./kibana-plugin-core-server.savedobjectsclientwrapperfactory.md) | Describes the factory used to create instances of Saved Objects Client Wrappers. | | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | -| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global.Note: do not write logic that uses this value directly; instead, use the appropriate accessors in the [type registry](./kibana-plugin-core-server.savedobjecttyperegistry.md). | +| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. | | [SavedObjectUnsanitizedDoc](./kibana-plugin-core-server.savedobjectunsanitizeddoc.md) | Describes Saved Object documents from Kibana < 7.0.0 which don't have a references root property defined. This type should only be used in migrations. | | [ScopeableRequest](./kibana-plugin-core-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md). | | [ServiceStatusLevel](./kibana-plugin-core-server.servicestatuslevel.md) | A convenience type that represents the union of each value in [ServiceStatusLevels](./kibana-plugin-core-server.servicestatuslevels.md). | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.md index 7383ebdb8192b..713e23edef081 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.md @@ -18,6 +18,8 @@ export interface SavedObjectsImportError | --- | --- | --- | | [error](./kibana-plugin-core-server.savedobjectsimporterror.error.md) | SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError | | | [id](./kibana-plugin-core-server.savedobjectsimporterror.id.md) | string | | +| [meta](./kibana-plugin-core-server.savedobjectsimporterror.meta.md) | {
title?: string;
icon?: string;
} | | +| [overwrite](./kibana-plugin-core-server.savedobjectsimporterror.overwrite.md) | boolean | If overwrite is specified, an attempt was made to overwrite an existing object. | | [title](./kibana-plugin-core-server.savedobjectsimporterror.title.md) | string | | | [type](./kibana-plugin-core-server.savedobjectsimporterror.type.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.meta.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.meta.md new file mode 100644 index 0000000000000..8d88bf1e375d4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.meta.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportError](./kibana-plugin-core-server.savedobjectsimporterror.md) > [meta](./kibana-plugin-core-server.savedobjectsimporterror.meta.md) + +## SavedObjectsImportError.meta property + +Signature: + +```typescript +meta: { + title?: string; + icon?: string; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.overwrite.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.overwrite.md new file mode 100644 index 0000000000000..f706f921cf052 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.overwrite.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportError](./kibana-plugin-core-server.savedobjectsimporterror.md) > [overwrite](./kibana-plugin-core-server.savedobjectsimporterror.overwrite.md) + +## SavedObjectsImportError.overwrite property + +If `overwrite` is specified, an attempt was made to overwrite an existing object. + +Signature: + +```typescript +overwrite?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.title.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.title.md index bfa20bb963acb..3d787cbe20bb4 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.title.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.title.md @@ -4,6 +4,11 @@ ## SavedObjectsImportError.title property +> Warning: This API is now obsolete. +> +> Use `meta.title` instead +> + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.blocking.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.blocking.md deleted file mode 100644 index 7ab5662003d8f..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.blocking.md +++ /dev/null @@ -1,14 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportMissingReferencesError](./kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.md) > [blocking](./kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.blocking.md) - -## SavedObjectsImportMissingReferencesError.blocking property - -Signature: - -```typescript -blocking: Array<{ - type: string; - id: string; - }>; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.md index b489b1bec26c3..01557eff549f6 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.md @@ -16,7 +16,6 @@ export interface SavedObjectsImportMissingReferencesError | Property | Type | Description | | --- | --- | --- | -| [blocking](./kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.blocking.md) | Array<{
type: string;
id: string;
}> | | | [references](./kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.references.md) | Array<{
type: string;
id: string;
}> | | | [type](./kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.type.md) | 'missing_references' | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.ignoremissingreferences.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.ignoremissingreferences.md new file mode 100644 index 0000000000000..a23bec3c5341f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.ignoremissingreferences.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportRetry](./kibana-plugin-core-server.savedobjectsimportretry.md) > [ignoreMissingReferences](./kibana-plugin-core-server.savedobjectsimportretry.ignoremissingreferences.md) + +## SavedObjectsImportRetry.ignoreMissingReferences property + +If `ignoreMissingReferences` is specified, reference validation will be skipped for this object. + +Signature: + +```typescript +ignoreMissingReferences?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.md index 258f18aebffc8..70693e6f43a39 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.md @@ -19,6 +19,7 @@ export interface SavedObjectsImportRetry | [createNewCopy](./kibana-plugin-core-server.savedobjectsimportretry.createnewcopy.md) | boolean | If createNewCopy is specified, the new object has a new (undefined) origin ID. This is only needed for the case where createNewCopies mode is disabled and ambiguous source conflicts are detected. | | [destinationId](./kibana-plugin-core-server.savedobjectsimportretry.destinationid.md) | string | The object ID that will be created or overwritten. If not specified, the id field will be used. | | [id](./kibana-plugin-core-server.savedobjectsimportretry.id.md) | string | | +| [ignoreMissingReferences](./kibana-plugin-core-server.savedobjectsimportretry.ignoremissingreferences.md) | boolean | If ignoreMissingReferences is specified, reference validation will be skipped for this object. | | [overwrite](./kibana-plugin-core-server.savedobjectsimportretry.overwrite.md) | boolean | | | [replaceReferences](./kibana-plugin-core-server.savedobjectsimportretry.replacereferences.md) | Array<{
type: string;
from: string;
to: string;
}> | | | [type](./kibana-plugin-core-server.savedobjectsimportretry.type.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.md index 8887dbe33d019..18a226f636b1d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.md @@ -19,5 +19,7 @@ export interface SavedObjectsImportSuccess | [createNewCopy](./kibana-plugin-core-server.savedobjectsimportsuccess.createnewcopy.md) | boolean | | | [destinationId](./kibana-plugin-core-server.savedobjectsimportsuccess.destinationid.md) | string | If destinationId is specified, the new object has a new ID that is different from the import ID. | | [id](./kibana-plugin-core-server.savedobjectsimportsuccess.id.md) | string | | +| [meta](./kibana-plugin-core-server.savedobjectsimportsuccess.meta.md) | {
title?: string;
icon?: string;
} | | +| [overwrite](./kibana-plugin-core-server.savedobjectsimportsuccess.overwrite.md) | boolean | If overwrite is specified, this object overwrote an existing one (or will do so, in the case of a pending resolution). | | [type](./kibana-plugin-core-server.savedobjectsimportsuccess.type.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.meta.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.meta.md new file mode 100644 index 0000000000000..de6057b4729ec --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.meta.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) > [meta](./kibana-plugin-core-server.savedobjectsimportsuccess.meta.md) + +## SavedObjectsImportSuccess.meta property + +Signature: + +```typescript +meta: { + title?: string; + icon?: string; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.overwrite.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.overwrite.md new file mode 100644 index 0000000000000..80cb659ef2cd2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.overwrite.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) > [overwrite](./kibana-plugin-core-server.savedobjectsimportsuccess.overwrite.md) + +## SavedObjectsImportSuccess.overwrite property + +If `overwrite` is specified, this object overwrote an existing one (or will do so, in the case of a pending resolution). + +Signature: + +```typescript +overwrite?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md index 173b9e19321d0..9075a780bd2c7 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md @@ -6,8 +6,6 @@ The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. -Note: do not write logic that uses this value directly; instead, use the appropriate accessors in the [type registry](./kibana-plugin-core-server.savedobjecttyperegistry.md). - Signature: ```typescript diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 77553d76050b2..9176a277b3f43 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -157,6 +157,7 @@ export { SavedObjectsImportUnknownError, SavedObjectsImportError, SavedObjectsImportRetry, + SavedObjectsNamespaceType, } from './saved_objects'; export { diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 8dd4744034cab..29015f3cf747f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1249,6 +1249,12 @@ export interface SavedObjectsImportError { // (undocumented) id: string; // (undocumented) + meta: { + title?: string; + icon?: string; + }; + overwrite?: boolean; + // @deprecated (undocumented) title?: string; // (undocumented) type: string; @@ -1256,11 +1262,6 @@ export interface SavedObjectsImportError { // @public export interface SavedObjectsImportMissingReferencesError { - // (undocumented) - blocking: Array<{ - type: string; - id: string; - }>; // (undocumented) references: Array<{ type: string; @@ -1288,6 +1289,7 @@ export interface SavedObjectsImportRetry { destinationId?: string; // (undocumented) id: string; + ignoreMissingReferences?: boolean; // (undocumented) overwrite: boolean; // (undocumented) @@ -1308,6 +1310,12 @@ export interface SavedObjectsImportSuccess { // (undocumented) id: string; // (undocumented) + meta: { + title?: string; + icon?: string; + }; + overwrite?: boolean; + // (undocumented) type: string; } @@ -1333,6 +1341,9 @@ export interface SavedObjectsMigrationVersion { [pluginName: string]: string; } +// @public +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; + // @public (undocumented) export interface SavedObjectsStart { // (undocumented) diff --git a/src/core/public/saved_objects/index.ts b/src/core/public/saved_objects/index.ts index f49a5e0a8a5c4..ef7b23448ad6f 100644 --- a/src/core/public/saved_objects/index.ts +++ b/src/core/public/saved_objects/index.ts @@ -44,6 +44,7 @@ export { SavedObjectsImportUnknownError, SavedObjectsImportError, SavedObjectsImportRetry, + SavedObjectsNamespaceType, } from '../../server/types'; export { diff --git a/src/core/server/saved_objects/import/check_conflicts.test.ts b/src/core/server/saved_objects/import/check_conflicts.test.ts index a0b34af272fbf..0d58970eee2cc 100644 --- a/src/core/server/saved_objects/import/check_conflicts.test.ts +++ b/src/core/server/saved_objects/import/check_conflicts.test.ts @@ -19,7 +19,7 @@ import { mockUuidv4 } from './__mocks__'; import { savedObjectsClientMock } from '../../mocks'; -import { SavedObjectReference } from 'kibana/public'; +import { SavedObjectReference, SavedObjectsImportRetry } from 'kibana/public'; import { SavedObjectsClientContract, SavedObject } from '../types'; import { SavedObjectsErrorHelpers } from '..'; import { checkConflicts } from './check_conflicts'; @@ -73,6 +73,7 @@ describe('#checkConflicts', () => { objects: SavedObjectType[]; namespace?: string; ignoreRegularConflicts?: boolean; + retries?: SavedObjectsImportRetry[]; createNewCopies?: boolean; }): CheckConflictsParams => { savedObjectsClient = savedObjectsClientMock.create(); @@ -96,6 +97,7 @@ describe('#checkConflicts', () => { filteredObjects: [], errors: [], importIdMap: new Map(), + pendingOverwrites: new Set(), }); }); @@ -117,14 +119,21 @@ describe('#checkConflicts', () => { expect(checkConflictsResult).toEqual({ filteredObjects: [obj1, obj3], errors: [ - { ...obj2Error, title: obj2.attributes.title, error: { type: 'conflict' } }, + { + ...obj2Error, + title: obj2.attributes.title, + meta: { title: obj2.attributes.title }, + error: { type: 'conflict' }, + }, { ...obj4Error, title: obj4.attributes.title, + meta: { title: obj4.attributes.title }, error: { ...obj4Error.error, type: 'unknown' }, }, ], importIdMap: new Map([[`${obj3.type}:${obj3.id}`, { id: `new-object-id` }]]), + pendingOverwrites: new Set(), }); }); @@ -141,13 +150,61 @@ describe('#checkConflicts', () => { { ...obj4Error, title: obj4.attributes.title, + meta: { title: obj4.attributes.title }, error: { ...obj4Error.error, type: 'unknown' }, }, ], + pendingOverwrites: new Set([`${obj2.type}:${obj2.id}`]), }) ); }); + it('handles retries', async () => { + const namespace = 'foo-namespace'; + const obj5 = createObject('type-5', 'id-5'); + const _objects = [...objects, obj5]; + const retries = [ + { id: obj1.id, type: obj1.type }, // find no conflict for obj1 + { id: obj2.id, type: obj2.type, destinationId: 'some-object-id' }, // find a conflict for obj2, and return it with the specified destinationId + { id: obj3.id, type: obj3.type, destinationId: 'another-object-id', createNewCopy: true }, // find an unresolvable conflict for obj3, regenerate the destinationId, and then omit originId because of the createNewCopy flag + { id: obj4.id, type: obj4.type }, // get an unknown error for obj4 + { id: obj5.id, type: obj5.type, overwrite: true }, // find a conflict for obj5, but ignore it because of the overwrite flag + ] as SavedObjectsImportRetry[]; + const params = setupParams({ objects: _objects, namespace, retries }); + const obj5Error = getResultMock.conflict(obj5.type, obj5.id); + socCheckConflicts.mockResolvedValue({ + errors: [ + { ...obj2Error, id: 'some-object-id' }, + { ...obj3Error, id: 'another-object-id' }, + obj4Error, + obj5Error, + ], + }); + + const checkConflictsResult = await checkConflicts(params); + expect(checkConflictsResult).toEqual({ + filteredObjects: [obj1, obj3, obj5], + errors: [ + { + ...obj2Error, + title: obj2.attributes.title, + meta: { title: obj2.attributes.title }, + error: { type: 'conflict', destinationId: 'some-object-id' }, + }, + { + ...obj4Error, + title: obj4.attributes.title, + meta: { title: obj4.attributes.title }, + error: { ...obj4Error.error, type: 'unknown' }, + }, + ], + importIdMap: new Map([ + [`${obj3.type}:${obj3.id}`, { id: `new-object-id`, omitOriginId: true }], + ]), + pendingOverwrites: new Set([`${obj5.type}:${obj5.id}`]), + }); + }); + it('adds `omitOriginId` field to `importIdMap` entries when createNewCopies=true', async () => { const namespace = 'foo-namespace'; const params = setupParams({ objects, namespace, createNewCopies: true }); diff --git a/src/core/server/saved_objects/import/check_conflicts.ts b/src/core/server/saved_objects/import/check_conflicts.ts index eec902494ec41..88ef1bf0e0236 100644 --- a/src/core/server/saved_objects/import/check_conflicts.ts +++ b/src/core/server/saved_objects/import/check_conflicts.ts @@ -23,6 +23,7 @@ import { SavedObjectsClientContract, SavedObjectsImportError, SavedObjectError, + SavedObjectsImportRetry, } from '../types'; interface CheckConflictsParams { @@ -30,6 +31,7 @@ interface CheckConflictsParams { savedObjectsClient: SavedObjectsClientContract; namespace?: string; ignoreRegularConflicts?: boolean; + retries?: SavedObjectsImportRetry[]; createNewCopies?: boolean; } @@ -41,18 +43,30 @@ export async function checkConflicts({ savedObjectsClient, namespace, ignoreRegularConflicts, + retries = [], createNewCopies, }: CheckConflictsParams) { const filteredObjects: Array> = []; const errors: SavedObjectsImportError[] = []; const importIdMap = new Map(); + const pendingOverwrites = new Set(); // exit early if there are no objects to check if (objects.length === 0) { - return { filteredObjects, errors, importIdMap }; + return { filteredObjects, errors, importIdMap, pendingOverwrites }; } - const checkConflictsResult = await savedObjectsClient.checkConflicts(objects, { namespace }); + const retryMap = retries.reduce( + (acc, cur) => acc.set(`${cur.type}:${cur.id}`, cur), + new Map() + ); + const objectsToCheck = objects.map((x) => { + const id = retryMap.get(`${x.type}:${x.id}`)?.destinationId ?? x.id; + return { ...x, id }; + }); + const checkConflictsResult = await savedObjectsClient.checkConflicts(objectsToCheck, { + namespace, + }); const errorMap = checkConflictsResult.errors.reduce( (acc, { type, id, error }) => acc.set(`${type}:${id}`, error), new Map() @@ -64,21 +78,28 @@ export async function checkConflicts({ id, attributes: { title }, } = object; - const errorObj = errorMap.get(`${type}:${id}`); + const { destinationId, overwrite, createNewCopy } = retryMap.get(`${type}:${id}`) || {}; + const errorObj = errorMap.get(`${type}:${destinationId ?? id}`); if (errorObj && isUnresolvableConflict(errorObj)) { // Any object create attempt that would result in an unresolvable conflict should have its ID regenerated. This way, when an object // with a "multi-namespace" type is exported from one namespace and imported to another, it does not result in an error, but instead a // new object is created. - const destinationId = uuidv4(); - importIdMap.set(`${type}:${id}`, { id: destinationId, omitOriginId: createNewCopies }); + // This code path should not be triggered for a retry, but in case the consumer is using the import APIs incorrectly and attempting to + // retry an object with a destinationId that would result in an unresolvable conflict, we regenerate the ID here as a fail-safe. + const omitOriginId = createNewCopies || createNewCopy; + importIdMap.set(`${type}:${id}`, { id: uuidv4(), omitOriginId }); filteredObjects.push(object); } else if (errorObj && errorObj.statusCode !== 409) { - errors.push({ type, id, title, error: { ...errorObj, type: 'unknown' } }); - } else if (errorObj?.statusCode === 409 && !ignoreRegularConflicts) { - errors.push({ type, id, title, error: { type: 'conflict' } }); + errors.push({ type, id, title, meta: { title }, error: { ...errorObj, type: 'unknown' } }); + } else if (errorObj?.statusCode === 409 && !ignoreRegularConflicts && !overwrite) { + const error = { type: 'conflict' as 'conflict', ...(destinationId && { destinationId }) }; + errors.push({ type, id, title, meta: { title }, error }); } else { filteredObjects.push(object); + if (errorObj?.statusCode === 409) { + pendingOverwrites.add(`${type}:${id}`); + } } }); - return { filteredObjects, errors, importIdMap }; + return { filteredObjects, errors, importIdMap, pendingOverwrites }; } diff --git a/src/core/server/saved_objects/import/check_origin_conflicts.test.ts b/src/core/server/saved_objects/import/check_origin_conflicts.test.ts index 05bc9ae0af004..ba5576bd05b73 100644 --- a/src/core/server/saved_objects/import/check_origin_conflicts.test.ts +++ b/src/core/server/saved_objects/import/check_origin_conflicts.test.ts @@ -156,20 +156,19 @@ describe('#checkOriginConflicts', () => { describe('results', () => { const getAmbiguousConflicts = (objects: SavedObjectType[]) => - objects - .map(({ id, attributes, updated_at: updatedAt }) => ({ - id, - title: attributes?.title, - updatedAt, - })) - .sort((a: { id: string }, b: { id: string }) => (a.id > b.id ? 1 : b.id > a.id ? -1 : 0)); + objects.map(({ id, attributes, updated_at: updatedAt }) => ({ + id, + title: attributes?.title, + updatedAt, + })); const createAmbiguousConflictError = ( object: SavedObjectType, destinations: SavedObjectType[] ): SavedObjectsImportError => ({ type: object.type, id: object.id, - title: object.attributes?.title, + title: object.attributes.title, + meta: { title: object.attributes.title }, error: { type: 'ambiguous_conflict', destinations: getAmbiguousConflicts(destinations), @@ -182,6 +181,7 @@ describe('#checkOriginConflicts', () => { type: object.type, id: object.id, title: object.attributes?.title, + meta: { title: object.attributes.title }, error: { type: 'conflict', ...(destinationId && { destinationId }), @@ -203,9 +203,9 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - filteredObjects: objects, importIdMap: new Map(), errors: [], + pendingOverwrites: new Set(), }; expect(checkOriginConflictsResult).toEqual(expectedResult); }); @@ -232,9 +232,9 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - filteredObjects: objects, importIdMap: new Map(), errors: [], + pendingOverwrites: new Set(), }; expect(checkOriginConflictsResult).toEqual(expectedResult); }); @@ -258,9 +258,9 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - filteredObjects: objects, importIdMap: new Map(), errors: [], + pendingOverwrites: new Set(), }; expect(checkOriginConflictsResult).toEqual(expectedResult); }); @@ -287,9 +287,9 @@ describe('#checkOriginConflicts', () => { const params = setup(false); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - filteredObjects: [], importIdMap: new Map(), errors: [createConflictError(obj1, objA.id), createConflictError(obj2, objB.id)], + pendingOverwrites: new Set(), }; expect(checkOriginConflictsResult).toEqual(expectedResult); }); @@ -298,12 +298,12 @@ describe('#checkOriginConflicts', () => { const params = setup(true); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - filteredObjects: objects, importIdMap: new Map([ [`${obj1.type}:${obj1.id}`, { id: objA.id }], [`${obj2.type}:${obj2.id}`, { id: objB.id }], ]), errors: [], + pendingOverwrites: new Set([`${obj1.type}:${obj1.id}`, `${obj2.type}:${obj2.id}`]), }; expect(checkOriginConflictsResult).toEqual(expectedResult); }); @@ -340,9 +340,9 @@ describe('#checkOriginConflicts', () => { const params = setup(false); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - filteredObjects: [], importIdMap: new Map(), errors: [createConflictError(obj2, objA.id), createConflictError(obj4, objB.id)], + pendingOverwrites: new Set(), }; expect(checkOriginConflictsResult).toEqual(expectedResult); }); @@ -351,12 +351,12 @@ describe('#checkOriginConflicts', () => { const params = setup(true); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - filteredObjects: objects, importIdMap: new Map([ [`${obj2.type}:${obj2.id}`, { id: objA.id }], [`${obj4.type}:${obj4.id}`, { id: objB.id }], ]), errors: [], + pendingOverwrites: new Set([`${obj2.type}:${obj2.id}`, `${obj4.type}:${obj4.id}`]), }; expect(checkOriginConflictsResult).toEqual(expectedResult); }); @@ -382,7 +382,6 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - filteredObjects: objects, importIdMap: new Map([ [`${obj1.type}:${obj1.id}`, { id: 'uuidv4', omitOriginId: true }], [`${obj2.type}:${obj2.id}`, { id: 'uuidv4', omitOriginId: true }], @@ -390,6 +389,7 @@ describe('#checkOriginConflicts', () => { [`${obj4.type}:${obj4.id}`, { id: 'uuidv4', omitOriginId: true }], ]), errors: [], + pendingOverwrites: new Set(), }; expect(mockUuidv4).toHaveBeenCalledTimes(4); expect(checkOriginConflictsResult).toEqual(expectedResult); @@ -411,12 +411,12 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - filteredObjects: [], importIdMap: new Map(), errors: [ createAmbiguousConflictError(obj1, [objA, objB]), createAmbiguousConflictError(obj2, [objC, objD]), ], + pendingOverwrites: new Set(), }; expect(mockUuidv4).not.toHaveBeenCalled(); expect(checkOriginConflictsResult).toEqual(expectedResult); @@ -442,7 +442,6 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - filteredObjects: objects, importIdMap: new Map([ [`${obj1.type}:${obj1.id}`, { id: 'uuidv4', omitOriginId: true }], [`${obj2.type}:${obj2.id}`, { id: 'uuidv4', omitOriginId: true }], @@ -450,6 +449,7 @@ describe('#checkOriginConflicts', () => { [`${obj4.type}:${obj4.id}`, { id: 'uuidv4', omitOriginId: true }], ]), errors: [], + pendingOverwrites: new Set(), }; expect(mockUuidv4).toHaveBeenCalledTimes(4); expect(checkOriginConflictsResult).toEqual(expectedResult); @@ -493,7 +493,6 @@ describe('#checkOriginConflicts', () => { const params = setup(false); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - filteredObjects: [obj1, obj2, obj4, obj7, obj8], importIdMap: new Map([ [`${obj7.type}:${obj7.id}`, { id: 'uuidv4', omitOriginId: true }], [`${obj8.type}:${obj8.id}`, { id: 'uuidv4', omitOriginId: true }], @@ -502,6 +501,7 @@ describe('#checkOriginConflicts', () => { createConflictError(obj5, objA.id), createAmbiguousConflictError(obj6, [objB, objC]), ], + pendingOverwrites: new Set(), }; expect(mockUuidv4).toHaveBeenCalledTimes(2); expect(checkOriginConflictsResult).toEqual(expectedResult); @@ -511,13 +511,13 @@ describe('#checkOriginConflicts', () => { const params = setup(true); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - filteredObjects: [obj1, obj2, obj4, obj5, obj7, obj8], importIdMap: new Map([ [`${obj5.type}:${obj5.id}`, { id: objA.id }], [`${obj7.type}:${obj7.id}`, { id: 'uuidv4', omitOriginId: true }], [`${obj8.type}:${obj8.id}`, { id: 'uuidv4', omitOriginId: true }], ]), errors: [createAmbiguousConflictError(obj6, [objB, objC])], + pendingOverwrites: new Set([`${obj5.type}:${obj5.id}`]), }; expect(mockUuidv4).toHaveBeenCalledTimes(2); expect(checkOriginConflictsResult).toEqual(expectedResult); diff --git a/src/core/server/saved_objects/import/check_origin_conflicts.ts b/src/core/server/saved_objects/import/check_origin_conflicts.ts index 5dcaf1f09eb48..433574fbdbf4c 100644 --- a/src/core/server/saved_objects/import/check_origin_conflicts.ts +++ b/src/core/server/saved_objects/import/check_origin_conflicts.ts @@ -69,16 +69,11 @@ const createQuery = (type: string, id: string, rawIdPrefix: string) => const transformObjectsToAmbiguousConflictFields = ( objects: Array> ) => - objects - .map(({ id, attributes, updated_at: updatedAt }) => ({ - id, - title: attributes?.title, - updatedAt, - })) - // Sort for two reasons: 1. consumers may want to identify multiple errors that have the same sources (by stringifying the `sources` - // array of each object they can be compared), and 2. it will be a less confusing experience for end-users if several ambiguous - // conflicts that share the same destinations all show those destinations in the same order. - .sort((a, b) => (a.id > b.id ? 1 : b.id > a.id ? -1 : 0)); + objects.map(({ id, attributes, updated_at: updatedAt }) => ({ + id, + title: attributes?.title, + updatedAt, + })); const getAmbiguousConflictSourceKey = ({ object }: InexactMatch) => `${object.type}:${object.originId || object.id}`; @@ -110,6 +105,8 @@ const checkOriginConflict = async ( page: 1, perPage: 10, fields: ['title'], + sortField: 'updated_at', + sortOrder: 'desc', ...(namespace && { namespaces: [namespace] }), }; const findResult = await savedObjectsClient.find<{ title?: string }>(findOptions); @@ -163,11 +160,10 @@ export async function checkOriginConflicts({ objects, ...params }: CheckOriginCo }, new Map>>()); const errors: SavedObjectsImportError[] = []; - const filteredObjects: Array> = []; const importIdMap = new Map(); + const pendingOverwrites = new Set(); checkOriginConflictResults.forEach((result) => { if (!isLeft(result)) { - filteredObjects.push(result.value); return; } const key = getAmbiguousConflictSourceKey(result.value); @@ -180,12 +176,14 @@ export async function checkOriginConflicts({ objects, ...params }: CheckOriginCo // This is a simple "inexact match" result -- a single import object has a single destination conflict. if (params.ignoreRegularConflicts) { importIdMap.set(`${type}:${id}`, { id: destinations[0].id }); - filteredObjects.push(object); + pendingOverwrites.add(`${type}:${id}`); } else { + const { title } = attributes; errors.push({ type, id, - title: attributes?.title, + title, + meta: { title }, error: { type: 'conflict', destinationId: destinations[0].id, @@ -202,13 +200,14 @@ export async function checkOriginConflicts({ objects, ...params }: CheckOriginCo // In the case of ambiguous source conflicts, don't treat them as errors; instead, regenerate the object ID and reset its origin // (e.g., the same outcome as if `createNewCopies` was enabled for the entire import operation). importIdMap.set(`${type}:${id}`, { id: uuidv4(), omitOriginId: true }); - filteredObjects.push(object); return; } + const { title } = attributes; errors.push({ type, id, - title: attributes?.title, + title, + meta: { title }, error: { type: 'ambiguous_conflict', destinations, @@ -216,11 +215,7 @@ export async function checkOriginConflicts({ objects, ...params }: CheckOriginCo }); }); - return { - errors, - filteredObjects, - importIdMap, - }; + return { errors, importIdMap, pendingOverwrites }; } /** diff --git a/src/core/server/saved_objects/import/collect_saved_objects.test.ts b/src/core/server/saved_objects/import/collect_saved_objects.test.ts index f2be46ce960d2..f54130be326ad 100644 --- a/src/core/server/saved_objects/import/collect_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/collect_saved_objects.test.ts @@ -149,7 +149,8 @@ describe('collectSavedObjects()', () => { const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); const error = { type: 'unsupported_type' }; - const errors = [{ error, type: obj1.type, id: obj1.id, title: obj1.attributes.title }]; + const { title } = obj1.attributes; + const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; expect(result).toEqual({ collectedObjects: [], errors, importIdMap: new Map() }); }); @@ -161,7 +162,8 @@ describe('collectSavedObjects()', () => { const collectedObjects = [{ ...obj2, migrationVersion: {} }]; const importIdMap = new Map([[`${obj2.type}:${obj2.id}`, {}]]); const error = { type: 'unsupported_type' }; - const errors = [{ error, type: obj1.type, id: obj1.id, title: obj1.attributes.title }]; + const { title } = obj1.attributes; + const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; expect(result).toEqual({ collectedObjects, errors, importIdMap }); }); @@ -178,7 +180,8 @@ describe('collectSavedObjects()', () => { }); const error = { type: 'unsupported_type' }; - const errors = [{ error, type: obj1.type, id: obj1.id, title: obj1.attributes.title }]; + const { title } = obj1.attributes; + const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; expect(result).toEqual({ collectedObjects: [], errors, importIdMap: new Map() }); }); @@ -196,7 +199,8 @@ describe('collectSavedObjects()', () => { const collectedObjects = [{ ...obj2, migrationVersion: {} }]; const importIdMap = new Map([[`${obj2.type}:${obj2.id}`, {}]]); const error = { type: 'unsupported_type' }; - const errors = [{ error, type: obj1.type, id: obj1.id, title: obj1.attributes.title }]; + const { title } = obj1.attributes; + const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; expect(result).toEqual({ collectedObjects, errors, importIdMap }); }); }); diff --git a/src/core/server/saved_objects/import/collect_saved_objects.ts b/src/core/server/saved_objects/import/collect_saved_objects.ts index c3b581dc6478f..f55e6bf0d2af4 100644 --- a/src/core/server/saved_objects/import/collect_saved_objects.ts +++ b/src/core/server/saved_objects/import/collect_saved_objects.ts @@ -46,7 +46,7 @@ export async function collectSavedObjects({ const errors: SavedObjectsImportError[] = []; const entries: Array<{ type: string; id: string }> = []; const importIdMap = new Map(); - const collectedObjects: Array> = await createPromiseFromStreams([ + const collectedObjects: Array> = await createPromiseFromStreams([ readStream, createLimitStream(objectLimit), createFilterStream>((obj) => { @@ -54,10 +54,12 @@ export async function collectSavedObjects({ if (supportedTypes.includes(obj.type)) { return true; } + const { title } = obj.attributes; errors.push({ id: obj.id, type: obj.type, - title: obj.attributes.title, + title, + meta: { title }, error: { type: 'unsupported_type', }, diff --git a/src/core/server/saved_objects/import/create_saved_objects.test.ts b/src/core/server/saved_objects/import/create_saved_objects.test.ts index 192714390f365..57acb78a7531b 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.test.ts @@ -157,6 +157,15 @@ describe('#createSavedObjects', () => { }; }; + test('filters out objects that have errors present', async () => { + const error = { type: obj1.type, id: obj1.id } as SavedObjectsImportError; + const options = setupParams({ objects: [obj1], accumulatedErrors: [error] }); + + const createSavedObjectsResult = await createSavedObjects(options); + expect(bulkCreate).not.toHaveBeenCalled(); + expect(createSavedObjectsResult).toEqual({ createdObjects: [], errors: [] }); + }); + test('exits early if there are no objects to create', async () => { const options = setupParams({ objects: [] }); @@ -189,17 +198,21 @@ describe('#createSavedObjects', () => { describe('handles accumulated errors as expected', () => { const resolvableErrors: SavedObjectsImportError[] = [ - { type: 'foo', id: 'foo-id', error: { type: 'conflict' } }, - { type: 'bar', id: 'bar-id', error: { type: 'ambiguous_conflict', destinations: [] } }, + { type: 'foo', id: 'foo-id', error: { type: 'conflict' } } as SavedObjectsImportError, + { + type: 'bar', + id: 'bar-id', + error: { type: 'ambiguous_conflict' }, + } as SavedObjectsImportError, { type: 'baz', id: 'baz-id', - error: { type: 'missing_references', references: [], blocking: [] }, - }, + error: { type: 'missing_references' }, + } as SavedObjectsImportError, ]; const unresolvableErrors: SavedObjectsImportError[] = [ - { type: 'qux', id: 'qux-id', error: { type: 'unsupported_type' } }, - { type: 'quux', id: 'quux-id', error: { type: 'unknown', message: '', statusCode: 400 } }, + { type: 'qux', id: 'qux-id', error: { type: 'unsupported_type' } } as SavedObjectsImportError, + { type: 'quux', id: 'quux-id', error: { type: 'unknown' } } as SavedObjectsImportError, ]; test('does not call bulkCreate when resolvable errors are present', async () => { diff --git a/src/core/server/saved_objects/import/create_saved_objects.ts b/src/core/server/saved_objects/import/create_saved_objects.ts index 3fce2b39c148a..2be267da5d81e 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.ts @@ -46,18 +46,25 @@ export const createSavedObjects = async ({ namespace, overwrite, }: CreateSavedObjectsParams): Promise> => { + // filter out any objects that resulted in errors + const errorSet = accumulatedErrors.reduce( + (acc, { type, id }) => acc.add(`${type}:${id}`), + new Set() + ); + const filteredObjects = objects.filter(({ type, id }) => !errorSet.has(`${type}:${id}`)); + // exit early if there are no objects to create - if (objects.length === 0) { + if (filteredObjects.length === 0) { return { createdObjects: [], errors: [] }; } // generate a map of the raw object IDs - const objectIdMap = objects.reduce( + const objectIdMap = filteredObjects.reduce( (map, object) => map.set(`${object.type}:${object.id}`, object), new Map>() ); - const objectsToCreate = objects.map((object) => { + const objectsToCreate = filteredObjects.map((object) => { // use the import ID map to ensure that each reference is being created with the correct ID const references = object.references?.map((reference) => { const { type, id } = reference; diff --git a/src/core/server/saved_objects/import/extract_errors.test.ts b/src/core/server/saved_objects/import/extract_errors.test.ts index f0c9e4ac292b8..047c4ae36266f 100644 --- a/src/core/server/saved_objects/import/extract_errors.test.ts +++ b/src/core/server/saved_objects/import/extract_errors.test.ts @@ -34,35 +34,27 @@ describe('extractErrors()', () => { { id: '1', type: 'dashboard', - attributes: { - title: 'My Dashboard 1', - }, + attributes: { title: 'My Dashboard 1' }, references: [], }, { id: '2', type: 'dashboard', - attributes: { - title: 'My Dashboard 2', - }, + attributes: { title: 'My Dashboard 2' }, references: [], error: SavedObjectsErrorHelpers.createConflictError('dashboard', '2').output.payload, }, { id: '3', type: 'dashboard', - attributes: { - title: 'My Dashboard 3', - }, + attributes: { title: 'My Dashboard 3' }, references: [], error: SavedObjectsErrorHelpers.createBadRequestError().output.payload, }, { id: '4', type: 'dashboard', - attributes: { - title: 'My Dashboard 4', - }, + attributes: { title: 'My Dashboard 4' }, references: [], error: SavedObjectsErrorHelpers.createConflictError('dashboard', '4').output.payload, destinationId: 'foo', @@ -76,6 +68,9 @@ Array [ "type": "conflict", }, "id": "2", + "meta": Object { + "title": "My Dashboard 2", + }, "title": "My Dashboard 2", "type": "dashboard", }, @@ -87,6 +82,9 @@ Array [ "type": "unknown", }, "id": "3", + "meta": Object { + "title": "My Dashboard 3", + }, "title": "My Dashboard 3", "type": "dashboard", }, @@ -96,6 +94,9 @@ Array [ "type": "conflict", }, "id": "4", + "meta": Object { + "title": "My Dashboard 4", + }, "title": "My Dashboard 4", "type": "dashboard", }, diff --git a/src/core/server/saved_objects/import/extract_errors.ts b/src/core/server/saved_objects/import/extract_errors.ts index b3223ade4b59d..6a7e5d4d9dfa4 100644 --- a/src/core/server/saved_objects/import/extract_errors.ts +++ b/src/core/server/saved_objects/import/extract_errors.ts @@ -41,6 +41,7 @@ export function extractErrors( id: savedObject.id, type: savedObject.type, title, + meta: { title }, error: { type: 'conflict', ...(destinationId && { destinationId }), @@ -52,6 +53,7 @@ export function extractErrors( id: savedObject.id, type: savedObject.type, title, + meta: { title }, error: { ...savedObject.error, type: 'unknown', diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index 8d0ea687e9252..77f49e336a7b9 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -57,16 +57,17 @@ describe('#importSavedObjectsFromStream', () => { importIdMap: new Map(), }); getMockFn(regenerateIds).mockReturnValue(new Map()); - getMockFn(validateReferences).mockResolvedValue({ errors: [], filteredObjects: [] }); + getMockFn(validateReferences).mockResolvedValue([]); getMockFn(checkConflicts).mockResolvedValue({ errors: [], filteredObjects: [], importIdMap: new Map(), + pendingOverwrites: new Set(), }); getMockFn(checkOriginConflicts).mockResolvedValue({ errors: [], - filteredObjects: [], importIdMap: new Map(), + pendingOverwrites: new Set(), }); getMockFn(createSavedObjects).mockResolvedValue({ errors: [], createdObjects: [] }); }); @@ -82,6 +83,13 @@ describe('#importSavedObjectsFromStream', () => { readStream = new Readable(); savedObjectsClient = savedObjectsClientMock.create(); typeRegistry = typeRegistryMock.create(); + typeRegistry.getType.mockImplementation( + (type: string) => + ({ + // other attributes aren't needed for the purposes of injecting metadata + management: { icon: `${type}-icon` }, + } as any) + ); return { readStream, objectLimit, @@ -92,11 +100,25 @@ describe('#importSavedObjectsFromStream', () => { createNewCopies, }; }; - const createObject = () => { - return ({ type: 'foo-type', id: uuidv4() } as unknown) as SavedObject<{ title: string }>; + const createObject = (): SavedObject<{ + title: string; + }> => { + return { + type: 'foo-type', + id: uuidv4(), + references: [], + attributes: { title: 'some-title' }, + }; }; - const createError = () => { - return ({ type: 'foo-type', id: uuidv4() } as unknown) as SavedObjectsImportError; + const createError = (): SavedObjectsImportError => { + const title = 'some-title'; + return { + type: 'foo-type', + id: uuidv4(), + title: 'some-title', + meta: { title }, + error: { type: 'conflict' }, + }; }; /** @@ -152,12 +174,16 @@ describe('#importSavedObjectsFromStream', () => { test('checks conflicts', async () => { const options = setupOptions(); - const filteredObjects = [createObject()]; - getMockFn(validateReferences).mockResolvedValue({ errors: [], filteredObjects }); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), + }); await importSavedObjectsFromStream(options); const checkConflictsParams = { - objects: filteredObjects, + objects: collectedObjects, savedObjectsClient, namespace, ignoreRegularConflicts: overwrite, @@ -173,6 +199,7 @@ describe('#importSavedObjectsFromStream', () => { errors: [], filteredObjects, importIdMap, + pendingOverwrites: new Set(), }); await importSavedObjectsFromStream(options); @@ -189,30 +216,29 @@ describe('#importSavedObjectsFromStream', () => { test('creates saved objects', async () => { const options = setupOptions(); + const collectedObjects = [createObject()]; const filteredObjects = [createObject()]; const errors = [createError(), createError(), createError(), createError()]; getMockFn(collectSavedObjects).mockResolvedValue({ errors: [errors[0]], - collectedObjects: [], // doesn't matter + collectedObjects, importIdMap: new Map([ ['foo', {}], ['bar', {}], ['baz', {}], ]), }); - getMockFn(validateReferences).mockResolvedValue({ - errors: [errors[1]], - filteredObjects: [], // doesn't matter - }); + getMockFn(validateReferences).mockResolvedValue([errors[1]]); getMockFn(checkConflicts).mockResolvedValue({ errors: [errors[2]], filteredObjects, importIdMap: new Map([['bar', { id: 'newId1' }]]), + pendingOverwrites: new Set(), }); getMockFn(checkOriginConflicts).mockResolvedValue({ errors: [errors[3]], - filteredObjects, importIdMap: new Map([['baz', { id: 'newId2' }]]), + pendingOverwrites: new Set(), }); await importSavedObjectsFromStream(options); @@ -222,7 +248,7 @@ describe('#importSavedObjectsFromStream', () => { ['baz', { id: 'newId2' }], ]); const createSavedObjectsParams = { - objects: filteredObjects, + objects: collectedObjects, accumulatedErrors: errors, savedObjectsClient, importIdMap, @@ -249,8 +275,7 @@ describe('#importSavedObjectsFromStream', () => { test('does not check conflicts or check origin conflicts', async () => { const options = setupOptions(true); - const filteredObjects = [createObject()]; - getMockFn(validateReferences).mockResolvedValue({ errors: [], filteredObjects }); + getMockFn(validateReferences).mockResolvedValue([]); await importSavedObjectsFromStream(options); expect(checkConflicts).not.toHaveBeenCalled(); @@ -259,24 +284,24 @@ describe('#importSavedObjectsFromStream', () => { test('creates saved objects', async () => { const options = setupOptions(true); - const filteredObjects = [createObject()]; + const collectedObjects = [createObject()]; const errors = [createError(), createError()]; getMockFn(collectSavedObjects).mockResolvedValue({ errors: [errors[0]], - collectedObjects: [], // doesn't matter + collectedObjects, importIdMap: new Map([ ['foo', {}], ['bar', {}], ]), }); - getMockFn(validateReferences).mockResolvedValue({ errors: [errors[1]], filteredObjects }); + getMockFn(validateReferences).mockResolvedValue([errors[1]]); // this importIdMap is not composed with the one obtained from `collectSavedObjects` const importIdMap = new Map().set(`id1`, { id: `newId1` }); getMockFn(regenerateIds).mockReturnValue(importIdMap); await importSavedObjectsFromStream(options); const createSavedObjectsParams = { - objects: filteredObjects, + objects: collectedObjects, accumulatedErrors: errors, savedObjectsClient, importIdMap, @@ -298,46 +323,78 @@ describe('#importSavedObjectsFromStream', () => { test('returns success=false if an error occurred', async () => { const options = setupOptions(); - const errors = [createError()]; getMockFn(collectSavedObjects).mockResolvedValue({ - errors, + errors: [createError()], collectedObjects: [], importIdMap: new Map(), // doesn't matter }); const result = await importSavedObjectsFromStream(options); - expect(result).toEqual({ success: false, successCount: 0, errors }); + expect(result).toEqual({ success: false, successCount: 0, errors: [expect.any(Object)] }); }); - describe('handles a mix of successes and errors', () => { + describe('handles a mix of successes and errors and injects metadata', () => { const obj1 = createObject(); const tmp = createObject(); const obj2 = { ...tmp, destinationId: 'some-destinationId', originId: tmp.id }; const obj3 = { ...createObject(), destinationId: 'another-destinationId' }; // empty originId const createdObjects = [obj1, obj2, obj3]; - const errors = [createError()]; + const error1 = createError(); + const error2 = createError(); + // results + const success1 = { + type: obj1.type, + id: obj1.id, + meta: { title: obj1.attributes.title, icon: `${obj1.type}-icon` }, + }; + const success2 = { + type: obj2.type, + id: obj2.id, + meta: { title: obj2.attributes.title, icon: `${obj2.type}-icon` }, + destinationId: obj2.destinationId, + }; + const success3 = { + type: obj3.type, + id: obj3.id, + meta: { title: obj3.attributes.title, icon: `${obj3.type}-icon` }, + destinationId: obj3.destinationId, + }; + const errors = [error1, error2]; test('with createNewCopies disabled', async () => { const options = setupOptions(); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects: [], + importIdMap: new Map(), + pendingOverwrites: new Set([ + `${success2.type}:${success2.id}`, // the success2 object was overwritten + `${error2.type}:${error2.id}`, // an attempt was made to overwrite the error2 object + ]), + }); getMockFn(createSavedObjects).mockResolvedValue({ errors, createdObjects }); const result = await importSavedObjectsFromStream(options); // successResults only includes the imported object's type, id, and destinationId (if a new one was generated) const successResults = [ - { type: obj1.type, id: obj1.id }, - { type: obj2.type, id: obj2.id, destinationId: obj2.destinationId }, + success1, + { ...success2, overwrite: true }, // `createNewCopies` mode is not enabled, but obj3 ran into an ambiguous source conflict and it was created with an empty // originId; hence, this specific object is a new copy -- we would need this information for rendering the appropriate originId // in the client UI, and we would need it to construct a retry for this object if other objects had errors that needed to be // resolved - { - type: obj3.type, - id: obj3.id, - destinationId: obj3.destinationId, - createNewCopy: true, - }, + { ...success3, createNewCopy: true }, + ]; + const errorResults = [ + { ...error1, meta: { ...error1.meta, icon: `${error1.type}-icon` } }, + { ...error2, meta: { ...error2.meta, icon: `${error2.type}-icon` }, overwrite: true }, ]; - expect(result).toEqual({ success: false, successCount: 3, successResults, errors }); + expect(result).toEqual({ + success: false, + successCount: 3, + successResults, + errors: errorResults, + }); }); test('with createNewCopies enabled', async () => { @@ -347,13 +404,18 @@ describe('#importSavedObjectsFromStream', () => { const result = await importSavedObjectsFromStream(options); // successResults only includes the imported object's type, id, and destinationId (if a new one was generated) - const successResults = [ - { type: obj1.type, id: obj1.id }, - // obj2 being created with createNewCopies mode enabled isn't a realistic test case (all objects would have originId omitted) - { type: obj2.type, id: obj2.id, destinationId: obj2.destinationId }, - { type: obj3.type, id: obj3.id, destinationId: obj3.destinationId }, + // obj2 being created with createNewCopies mode enabled isn't a realistic test case (all objects would have originId omitted) + const successResults = [success1, success2, success3]; + const errorResults = [ + { ...error1, meta: { ...error1.meta, icon: `${error1.type}-icon` } }, + { ...error2, meta: { ...error2.meta, icon: `${error2.type}-icon` } }, ]; - expect(result).toEqual({ success: false, successCount: 3, successResults, errors }); + expect(result).toEqual({ + success: false, + successCount: 3, + successResults, + errors: errorResults, + }); }); }); @@ -365,21 +427,23 @@ describe('#importSavedObjectsFromStream', () => { collectedObjects: [], importIdMap: new Map(), // doesn't matter }); - getMockFn(validateReferences).mockResolvedValue({ errors: [errors[1]], filteredObjects: [] }); + getMockFn(validateReferences).mockResolvedValue([errors[1]]); getMockFn(checkConflicts).mockResolvedValue({ errors: [errors[2]], filteredObjects: [], - importIdMap: new Map(), // doesn't matters + importIdMap: new Map(), // doesn't matter + pendingOverwrites: new Set(), }); getMockFn(checkOriginConflicts).mockResolvedValue({ errors: [errors[3]], - filteredObjects: [], - importIdMap: new Map(), // doesn't matters + importIdMap: new Map(), // doesn't matter + pendingOverwrites: new Set(), }); getMockFn(createSavedObjects).mockResolvedValue({ errors: [errors[4]], createdObjects: [] }); const result = await importSavedObjectsFromStream(options); - expect(result).toEqual({ success: false, successCount: 0, errors }); + const expectedErrors = errors.map(({ type, id }) => expect.objectContaining({ type, id })); + expect(result).toEqual({ success: false, successCount: 0, errors: expectedErrors }); }); }); }); diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index 08a3c80ba947c..4530c7ff427da 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -56,6 +56,7 @@ export async function importSavedObjectsFromStream({ errorAccumulator = [...errorAccumulator, ...collectSavedObjectsResult.errors]; /** Map of all IDs for objects that we are attempting to import; each value is empty by default */ let importIdMap = collectSavedObjectsResult.importIdMap; + let pendingOverwrites = new Set(); // Validate references const validateReferencesResult = await validateReferences( @@ -63,15 +64,14 @@ export async function importSavedObjectsFromStream({ savedObjectsClient, namespace ); - errorAccumulator = [...errorAccumulator, ...validateReferencesResult.errors]; + errorAccumulator = [...errorAccumulator, ...validateReferencesResult]; - let objectsToCreate = validateReferencesResult.filteredObjects; if (createNewCopies) { importIdMap = regenerateIds(collectSavedObjectsResult.collectedObjects); } else { // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces const checkConflictsParams = { - objects: validateReferencesResult.filteredObjects, + objects: collectSavedObjectsResult.collectedObjects, savedObjectsClient, namespace, ignoreRegularConflicts: overwrite, @@ -79,6 +79,7 @@ export async function importSavedObjectsFromStream({ const checkConflictsResult = await checkConflicts(checkConflictsParams); errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; importIdMap = new Map([...importIdMap, ...checkConflictsResult.importIdMap]); + pendingOverwrites = checkConflictsResult.pendingOverwrites; // Check multi-namespace object types for origin conflicts in this namespace const checkOriginConflictsParams = { @@ -92,12 +93,15 @@ export async function importSavedObjectsFromStream({ const checkOriginConflictsResult = await checkOriginConflicts(checkOriginConflictsParams); errorAccumulator = [...errorAccumulator, ...checkOriginConflictsResult.errors]; importIdMap = new Map([...importIdMap, ...checkOriginConflictsResult.importIdMap]); - objectsToCreate = checkOriginConflictsResult.filteredObjects; + pendingOverwrites = new Set([ + ...pendingOverwrites, + ...checkOriginConflictsResult.pendingOverwrites, + ]); } // Create objects in bulk const createSavedObjectsParams = { - objects: objectsToCreate, + objects: collectSavedObjectsResult.collectedObjects, accumulatedErrors: errorAccumulator, savedObjectsClient, importIdMap, @@ -108,20 +112,33 @@ export async function importSavedObjectsFromStream({ errorAccumulator = [...errorAccumulator, ...createSavedObjectsResult.errors]; const successResults = createSavedObjectsResult.createdObjects.map( - ({ type, id, destinationId, originId }) => { + ({ type, id, attributes: { title }, destinationId, originId }) => { + const meta = { title, icon: typeRegistry.getType(type)?.management?.icon }; + const attemptedOverwrite = pendingOverwrites.has(`${type}:${id}`); return { type, id, + meta, + ...(attemptedOverwrite && { overwrite: true }), ...(destinationId && { destinationId }), ...(destinationId && !originId && !createNewCopies && { createNewCopy: true }), }; } ); + const errorResults = errorAccumulator.map((error) => { + const icon = typeRegistry.getType(error.type)?.management?.icon; + const attemptedOverwrite = pendingOverwrites.has(`${error.type}:${error.id}`); + return { + ...error, + meta: { ...error.meta, icon }, + ...(attemptedOverwrite && { overwrite: true }), + }; + }); return { successCount: createSavedObjectsResult.createdObjects.length, success: errorAccumulator.length === 0, ...(successResults.length && { successResults }), - ...(errorAccumulator.length && { errors: errorAccumulator }), + ...(errorResults.length && { errors: errorResults }), }; } diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.ts index f5dba716ea348..51a48dc511e2a 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.ts @@ -66,11 +66,12 @@ describe('#importSavedObjectsFromStream', () => { importIdMap: new Map(), }); getMockFn(regenerateIds).mockReturnValue(new Map()); - getMockFn(validateReferences).mockResolvedValue({ errors: [], filteredObjects: [] }); + getMockFn(validateReferences).mockResolvedValue([]); getMockFn(checkConflicts).mockResolvedValue({ errors: [], filteredObjects: [], importIdMap: new Map(), + pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); getMockFn(getImportIdMapForRetries).mockReturnValue(new Map()); getMockFn(splitOverwrites).mockReturnValue({ @@ -93,6 +94,13 @@ describe('#importSavedObjectsFromStream', () => { readStream = new Readable(); savedObjectsClient = savedObjectsClientMock.create(); typeRegistry = typeRegistryMock.create(); + typeRegistry.getType.mockImplementation( + (type: string) => + ({ + // other attributes aren't needed for the purposes of injecting metadata + management: { icon: `${type}-icon` }, + } as any) + ); return { readStream, objectLimit, @@ -113,13 +121,27 @@ describe('#importSavedObjectsFromStream', () => { const { id = uuidv4(), overwrite = false, replaceReferences = [] } = options ?? {}; return { type: 'foo-type', id, overwrite, replaceReferences }; }; - const createObject = (references?: SavedObjectReference[]) => { - return ({ type: 'foo-type', id: uuidv4(), references } as unknown) as SavedObject<{ - title: string; - }>; + const createObject = ( + references?: SavedObjectReference[] + ): SavedObject<{ + title: string; + }> => { + return { + type: 'foo-type', + id: uuidv4(), + references: references || [], + attributes: { title: 'some-title' }, + }; }; - const createError = () => { - return ({ type: 'foo-type', id: uuidv4() } as unknown) as SavedObjectsImportError; + const createError = (): SavedObjectsImportError => { + const title = 'some-title'; + return { + type: 'foo-type', + id: uuidv4(), + title: 'some-title', + meta: { title }, + error: { type: 'conflict' }, + }; }; /** @@ -154,14 +176,14 @@ describe('#importSavedObjectsFromStream', () => { await resolveSavedObjectsImportErrors(options); expect(typeRegistry.getImportableAndExportableTypes).toHaveBeenCalled(); - // expect(createObjectsFilter).toHaveBeenCalled(); const filter = getMockFn(createObjectsFilter).mock.results[0].value; const collectSavedObjectsOptions = { readStream, objectLimit, filter, supportedTypes }; expect(collectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); }); test('validates references', async () => { - const options = setupOptions(); + const retries = [createRetry()]; + const options = setupOptions(retries); const collectedObjects = [createObject()]; getMockFn(collectSavedObjects).mockResolvedValue({ errors: [], @@ -173,17 +195,20 @@ describe('#importSavedObjectsFromStream', () => { expect(validateReferences).toHaveBeenCalledWith( collectedObjects, savedObjectsClient, - namespace + namespace, + retries ); }); test('uses `retries` to replace references of collected objects before validating', async () => { const object = createObject([{ type: 'bar-type', id: 'abc', name: 'some name' }]); - const retry = createRetry({ - id: object.id, - replaceReferences: [{ type: 'bar-type', from: 'abc', to: 'def' }], - }); - const options = setupOptions([retry]); + const retries = [ + createRetry({ + id: object.id, + replaceReferences: [{ type: 'bar-type', from: 'abc', to: 'def' }], + }), + ]; + const options = setupOptions(retries); getMockFn(collectSavedObjects).mockResolvedValue({ errors: [], collectedObjects: [object], @@ -198,22 +223,28 @@ describe('#importSavedObjectsFromStream', () => { expect(validateReferences).toHaveBeenCalledWith( [objectWithReplacedReferences], savedObjectsClient, - namespace + namespace, + retries ); }); test('checks conflicts', async () => { const createNewCopies = (Symbol() as unknown) as boolean; - const options = setupOptions([], createNewCopies); - const filteredObjects = [createObject()]; - getMockFn(validateReferences).mockResolvedValue({ errors: [], filteredObjects }); + const retries = [createRetry()]; + const options = setupOptions(retries, createNewCopies); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), // doesn't matter + }); await resolveSavedObjectsImportErrors(options); const checkConflictsParams = { - objects: filteredObjects, + objects: collectedObjects, savedObjectsClient, namespace, - ignoreRegularConflicts: true, + retries, createNewCopies, }; expect(checkConflicts).toHaveBeenCalledWith(checkConflictsParams); @@ -228,6 +259,7 @@ describe('#importSavedObjectsFromStream', () => { errors: [], filteredObjects, importIdMap: new Map(), + pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); await resolveSavedObjectsImportErrors(options); @@ -238,15 +270,15 @@ describe('#importSavedObjectsFromStream', () => { test('splits objects to ovewrite from those not to overwrite', async () => { const retries = [createRetry()]; const options = setupOptions(retries); - const filteredObjects = [createObject()]; - getMockFn(checkConflicts).mockResolvedValue({ + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ errors: [], - filteredObjects, - importIdMap: new Map(), + collectedObjects, + importIdMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); - expect(splitOverwrites).toHaveBeenCalledWith(filteredObjects, retries); + expect(splitOverwrites).toHaveBeenCalledWith(collectedObjects, retries); }); describe('with createNewCopies disabled', () => { @@ -271,22 +303,22 @@ describe('#importSavedObjectsFromStream', () => { collectedObjects: [], // doesn't matter importIdMap: new Map(), // doesn't matter }); - getMockFn(validateReferences).mockResolvedValue({ - errors: [errors[1]], - filteredObjects: [], // doesn't matter - }); + getMockFn(validateReferences).mockResolvedValue([errors[1]]); getMockFn(checkConflicts).mockResolvedValue({ errors: [errors[2]], filteredObjects: [], - importIdMap: new Map([ - ['foo', {}], - ['bar', {}], - ]), + importIdMap: new Map([['foo', { id: 'someId' }]]), + pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); - getMockFn(getImportIdMapForRetries).mockReturnValue(new Map([['bar', { id: 'newId' }]])); + getMockFn(getImportIdMapForRetries).mockReturnValue( + new Map([ + ['foo', { id: 'newId' }], + ['bar', { id: 'anotherNewId' }], + ]) + ); const importIdMap = new Map([ - ['foo', {}], - ['bar', { id: 'newId' }], + ['foo', { id: 'someId' }], + ['bar', { id: 'anotherNewId' }], ]); const objectsToOverwrite = [createObject()]; const objectsToNotOverwrite = [createObject()]; @@ -337,10 +369,7 @@ describe('#importSavedObjectsFromStream', () => { collectedObjects: [], // doesn't matter importIdMap: new Map(), // doesn't matter }); - getMockFn(validateReferences).mockResolvedValue({ - errors: [errors[1]], - filteredObjects: [], // doesn't matter - }); + getMockFn(validateReferences).mockResolvedValue([errors[1]]); getMockFn(regenerateIds).mockReturnValue( new Map([ ['foo', { id: 'randomId1' }], @@ -351,16 +380,19 @@ describe('#importSavedObjectsFromStream', () => { getMockFn(checkConflicts).mockResolvedValue({ errors: [errors[2]], filteredObjects: [], - importIdMap: new Map([ - ['bar', {}], - ['baz', {}], - ]), + importIdMap: new Map([['bar', { id: 'someId' }]]), + pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); - getMockFn(getImportIdMapForRetries).mockReturnValue(new Map([['baz', { id: 'newId' }]])); + getMockFn(getImportIdMapForRetries).mockReturnValue( + new Map([ + ['bar', { id: 'newId' }], + ['baz', { id: 'anotherNewId' }], + ]) + ); const importIdMap = new Map([ ['foo', { id: 'randomId1' }], - ['bar', {}], - ['baz', { id: 'newId' }], + ['bar', { id: 'someId' }], + ['baz', { id: 'anotherNewId' }], ]); const objectsToOverwrite = [createObject()]; const objectsToNotOverwrite = [createObject()]; @@ -400,39 +432,61 @@ describe('#importSavedObjectsFromStream', () => { test('returns success=false if an error occurred', async () => { const options = setupOptions(); - const errors = [createError()]; getMockFn(collectSavedObjects).mockResolvedValue({ - errors, + errors: [createError()], collectedObjects: [], importIdMap: new Map(), // doesn't matter }); const result = await resolveSavedObjectsImportErrors(options); - expect(result).toEqual({ success: false, successCount: 0, errors }); + expect(result).toEqual({ success: false, successCount: 0, errors: [expect.any(Object)] }); }); - test('handles a mix of successes and errors', async () => { - const options = setupOptions(); - const errors = [createError()]; + test('handles a mix of successes and errors and injects metadata', async () => { + const error1 = createError(); + const error2 = createError(); + const options = setupOptions([ + { type: error2.type, id: error2.id, overwrite: true, replaceReferences: [] }, + ]); const obj1 = createObject(); const tmp = createObject(); const obj2 = { ...tmp, destinationId: 'some-destinationId', originId: tmp.id }; const obj3 = { ...createObject(), destinationId: 'another-destinationId' }; // empty originId; this is a new copy getMockFn(createSavedObjects).mockResolvedValueOnce({ - errors, + errors: [error1], createdObjects: [obj1], }); getMockFn(createSavedObjects).mockResolvedValueOnce({ - errors: [], + errors: [error2], createdObjects: [obj2, obj3], }); const result = await resolveSavedObjectsImportErrors(options); // successResults only includes the imported object's type, id, and destinationId (if a new one was generated) const successResults = [ - { type: obj1.type, id: obj1.id }, - { type: obj2.type, id: obj2.id, destinationId: obj2.destinationId }, - { type: obj3.type, id: obj3.id, destinationId: obj3.destinationId, createNewCopy: true }, + { + type: obj1.type, + id: obj1.id, + meta: { title: obj1.attributes.title, icon: `${obj1.type}-icon` }, + overwrite: true, + }, + { + type: obj2.type, + id: obj2.id, + meta: { title: obj2.attributes.title, icon: `${obj2.type}-icon` }, + destinationId: obj2.destinationId, + }, + { + type: obj3.type, + id: obj3.id, + meta: { title: obj3.attributes.title, icon: `${obj3.type}-icon` }, + destinationId: obj3.destinationId, + createNewCopy: true, + }, + ]; + const errors = [ + { ...error1, meta: { ...error1.meta, icon: `${error1.type}-icon` } }, + { ...error2, meta: { ...error2.meta, icon: `${error2.type}-icon` }, overwrite: true }, ]; expect(result).toEqual({ success: false, successCount: 3, successResults, errors }); }); @@ -445,7 +499,7 @@ describe('#importSavedObjectsFromStream', () => { collectedObjects: [], importIdMap: new Map(), // doesn't matter }); - getMockFn(validateReferences).mockResolvedValue({ errors: [errors[1]], filteredObjects: [] }); + getMockFn(validateReferences).mockResolvedValue([errors[1]]); getMockFn(createSavedObjects).mockResolvedValueOnce({ errors: [errors[2]], createdObjects: [], @@ -456,7 +510,8 @@ describe('#importSavedObjectsFromStream', () => { }); const result = await resolveSavedObjectsImportErrors(options); - expect(result).toEqual({ success: false, successCount: 0, errors }); + const expectedErrors = errors.map(({ type, id }) => expect.objectContaining({ type, id })); + expect(result).toEqual({ success: false, successCount: 0, errors: expectedErrors }); }); }); }); diff --git a/src/core/server/saved_objects/import/resolve_import_errors.ts b/src/core/server/saved_objects/import/resolve_import_errors.ts index 7dc45a929fbe4..2182d9252cd51 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -23,6 +23,7 @@ import { SavedObjectsImportError, SavedObjectsImportResponse, SavedObjectsResolveImportErrorsOptions, + SavedObjectsImportSuccess, } from './types'; import { regenerateIds } from './regenerate_ids'; import { validateReferences } from './validate_references'; @@ -95,9 +96,10 @@ export async function resolveSavedObjectsImportErrors({ const validateReferencesResult = await validateReferences( objectsToResolve, savedObjectsClient, - namespace + namespace, + retries ); - errorAccumulator = [...errorAccumulator, ...validateReferencesResult.errors]; + errorAccumulator = [...errorAccumulator, ...validateReferencesResult]; if (createNewCopies) { // In case any missing reference errors were resolved, ensure that we regenerate those object IDs as well @@ -107,15 +109,14 @@ export async function resolveSavedObjectsImportErrors({ // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces const checkConflictsParams = { - objects: validateReferencesResult.filteredObjects, + objects: objectsToResolve, savedObjectsClient, namespace, - ignoreRegularConflicts: true, + retries, createNewCopies, }; const checkConflictsResult = await checkConflicts(checkConflictsParams); errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; - importIdMap = new Map([...importIdMap, ...checkConflictsResult.importIdMap]); // Check multi-namespace object types for regular conflicts and ambiguous conflicts const getImportIdMapForRetriesParams = { @@ -124,12 +125,19 @@ export async function resolveSavedObjectsImportErrors({ createNewCopies, }; const importIdMapForRetries = getImportIdMapForRetries(getImportIdMapForRetriesParams); - importIdMap = new Map([...importIdMap, ...importIdMapForRetries]); + importIdMap = new Map([ + ...importIdMap, + ...importIdMapForRetries, + ...checkConflictsResult.importIdMap, // this importIdMap takes precedence over the others + ]); // Bulk create in two batches, overwrites and non-overwrites - let successResults: Array<{ type: string; id: string; destinationId?: string }> = []; + let successResults: SavedObjectsImportSuccess[] = []; const accumulatedErrors = [...errorAccumulator]; - const bulkCreateObjects = async (objects: Array>, overwrite?: boolean) => { + const bulkCreateObjects = async ( + objects: Array>, + overwrite?: boolean + ) => { const createSavedObjectsParams = { objects, accumulatedErrors, @@ -145,25 +153,39 @@ export async function resolveSavedObjectsImportErrors({ successCount += createdObjects.length; successResults = [ ...successResults, - ...createdObjects.map(({ type, id, destinationId, originId }) => ({ - type, - id, - ...(destinationId && { destinationId }), - ...(destinationId && !originId && !createNewCopies && { createNewCopy: true }), - })), + ...createdObjects.map(({ type, id, attributes: { title }, destinationId, originId }) => { + const meta = { title, icon: typeRegistry.getType(type)?.management?.icon }; + return { + type, + id, + meta, + ...(overwrite && { overwrite }), + ...(destinationId && { destinationId }), + ...(destinationId && !originId && !createNewCopies && { createNewCopy: true }), + }; + }), ]; }; - const { objectsToOverwrite, objectsToNotOverwrite } = splitOverwrites( - checkConflictsResult.filteredObjects, - retries - ); + const { objectsToOverwrite, objectsToNotOverwrite } = splitOverwrites(objectsToResolve, retries); await bulkCreateObjects(objectsToOverwrite, true); await bulkCreateObjects(objectsToNotOverwrite); + const errorResults = errorAccumulator.map((error) => { + const icon = typeRegistry.getType(error.type)?.management?.icon; + const attemptedOverwrite = retries.some( + ({ type, id, overwrite }) => type === error.type && id === error.id && overwrite + ); + return { + ...error, + meta: { ...error.meta, icon }, + ...(attemptedOverwrite && { overwrite: true }), + }; + }); + return { successCount, success: errorAccumulator.length === 0, ...(successResults.length && { successResults }), - ...(errorAccumulator.length && { errors: errorAccumulator }), + ...(errorResults.length && { errors: errorResults }), }; } diff --git a/src/core/server/saved_objects/import/split_overwrites.ts b/src/core/server/saved_objects/import/split_overwrites.ts index be55e049a2bfc..03ae6b96e7823 100644 --- a/src/core/server/saved_objects/import/split_overwrites.ts +++ b/src/core/server/saved_objects/import/split_overwrites.ts @@ -20,9 +20,12 @@ import { SavedObject } from '../types'; import { SavedObjectsImportRetry } from './types'; -export function splitOverwrites(savedObjects: SavedObject[], retries: SavedObjectsImportRetry[]) { - const objectsToOverwrite: SavedObject[] = []; - const objectsToNotOverwrite: SavedObject[] = []; +export function splitOverwrites( + savedObjects: Array>, + retries: SavedObjectsImportRetry[] +) { + const objectsToOverwrite: Array> = []; + const objectsToNotOverwrite: Array> = []; const overwrites = retries .filter((retry) => retry.overwrite) .map((retry) => `${retry.type}:${retry.id}`); diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index e0abe23a70372..a242ffdf5b50f 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -43,6 +43,10 @@ export interface SavedObjectsImportRetry { * `createNewCopies` mode is disabled and ambiguous source conflicts are detected. */ createNewCopy?: boolean; + /** + * If `ignoreMissingReferences` is specified, reference validation will be skipped for this object. + */ + ignoreMissingReferences?: boolean; } /** @@ -87,14 +91,7 @@ export interface SavedObjectsImportUnknownError { */ export interface SavedObjectsImportMissingReferencesError { type: 'missing_references'; - references: Array<{ - type: string; - id: string; - }>; - blocking: Array<{ - type: string; - id: string; - }>; + references: Array<{ type: string; id: string }>; } /** @@ -104,7 +101,15 @@ export interface SavedObjectsImportMissingReferencesError { export interface SavedObjectsImportError { id: string; type: string; + /** + * @deprecated Use `meta.title` instead + */ title?: string; + meta: { title?: string; icon?: string }; + /** + * If `overwrite` is specified, an attempt was made to overwrite an existing object. + */ + overwrite?: boolean; error: | SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError @@ -131,6 +136,14 @@ export interface SavedObjectsImportSuccess { * this field will be redundant and can be removed. */ createNewCopy?: boolean; + meta: { + title?: string; + icon?: string; + }; + /** + * If `overwrite` is specified, this object overwrote an existing one (or will do so, in the case of a pending resolution). + */ + overwrite?: boolean; } /** diff --git a/src/core/server/saved_objects/import/validate_references.test.ts b/src/core/server/saved_objects/import/validate_references.test.ts index ec478895fedbb..6efd1b28b199d 100644 --- a/src/core/server/saved_objects/import/validate_references.test.ts +++ b/src/core/server/saved_objects/import/validate_references.test.ts @@ -34,6 +34,34 @@ describe('getNonExistingReferenceAsKeys()', () => { expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); + test('skips objects when ignoreMissingReferences is included in retry', async () => { + const savedObjects = [ + { + id: '2', + type: 'visualization', + attributes: {}, + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], + }, + ]; + const retries = [ + { + type: 'visualization', + id: '2', + overwrite: false, + replaceReferences: [], + ignoreMissingReferences: true, + }, + ]; + const result = await getNonExistingReferenceAsKeys( + savedObjects, + savedObjectsClient, + undefined, + retries + ); + expect(result).toEqual([]); + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + }); + test('removes references that exist within savedObjects', async () => { const savedObjects = [ { @@ -226,12 +254,7 @@ describe('validateReferences()', () => { test('returns empty when no objects are passed in', async () => { const result = await validateReferences([], savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [], - "filteredObjects": Array [], - } - `); + expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); @@ -330,56 +353,50 @@ describe('validateReferences()', () => { ]; const result = await validateReferences(savedObjects, savedObjectsClient); expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [ - Object { - "error": Object { - "blocking": Array [], - "references": Array [ - Object { - "id": "3", - "type": "index-pattern", - }, - ], - "type": "missing_references", - }, - "id": "2", + Array [ + Object { + "error": Object { + "references": Array [ + Object { + "id": "3", + "type": "index-pattern", + }, + ], + "type": "missing_references", + }, + "id": "2", + "meta": Object { "title": "My Visualization 2", - "type": "visualization", }, - Object { - "error": Object { - "blocking": Array [], - "references": Array [ - Object { - "id": "5", - "type": "index-pattern", - }, - Object { - "id": "6", - "type": "index-pattern", - }, - Object { - "id": "7", - "type": "search", - }, - ], - "type": "missing_references", - }, - "id": "4", - "title": "My Visualization 4", - "type": "visualization", + "title": "My Visualization 2", + "type": "visualization", + }, + Object { + "error": Object { + "references": Array [ + Object { + "id": "5", + "type": "index-pattern", + }, + Object { + "id": "6", + "type": "index-pattern", + }, + Object { + "id": "7", + "type": "search", + }, + ], + "type": "missing_references", }, - ], - "filteredObjects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "visualization", + "id": "4", + "meta": Object { + "title": "My Visualization 4", }, - ], - } + "title": "My Visualization 4", + "type": "visualization", + }, + ] `); expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` [MockFunction] { @@ -437,6 +454,29 @@ describe('validateReferences()', () => { `); }); + test(`doesn't return errors when ignoreMissingReferences is included in retry`, async () => { + const savedObjects = [ + { + id: '2', + type: 'visualization', + attributes: {}, + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], + }, + ]; + const retries = [ + { + type: 'visualization', + id: '2', + overwrite: false, + replaceReferences: [], + ignoreMissingReferences: true, + }, + ]; + const result = await validateReferences(savedObjects, savedObjectsClient, undefined, retries); + expect(result).toEqual([]); + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + }); + test(`doesn't return errors when references exist in Elasticsearch`, async () => { savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [ @@ -463,25 +503,7 @@ describe('validateReferences()', () => { }, ]; const result = await validateReferences(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [], - "filteredObjects": Array [ - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "visualization", - }, - ], - } - `); + expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); }); @@ -507,31 +529,7 @@ describe('validateReferences()', () => { }, ]; const result = await validateReferences(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [], - "filteredObjects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "visualization", - }, - ], - } - `); + expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); @@ -556,30 +554,7 @@ describe('validateReferences()', () => { }, ]; const result = await validateReferences(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [], - "filteredObjects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [ - Object { - "id": "2", - "name": "ref_0", - "type": "visualization", - }, - Object { - "id": "3", - "name": "ref_1", - "type": "other-type", - }, - ], - "type": "dashboard", - }, - ], - } - `); + expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); diff --git a/src/core/server/saved_objects/import/validate_references.ts b/src/core/server/saved_objects/import/validate_references.ts index 2a30dcc96c08a..89fe8ec8c0901 100644 --- a/src/core/server/saved_objects/import/validate_references.ts +++ b/src/core/server/saved_objects/import/validate_references.ts @@ -19,22 +19,34 @@ import Boom from 'boom'; import { SavedObject, SavedObjectsClientContract } from '../types'; -import { SavedObjectsImportError } from './types'; +import { SavedObjectsImportError, SavedObjectsImportRetry } from './types'; const REF_TYPES_TO_VLIDATE = ['index-pattern', 'search']; function filterReferencesToValidate({ type }: { type: string }) { return REF_TYPES_TO_VLIDATE.includes(type); } +const getObjectsToSkip = (retries: SavedObjectsImportRetry[] = []) => + retries.reduce( + (acc, { type, id, ignoreMissingReferences }) => + ignoreMissingReferences ? acc.add(`${type}:${id}`) : acc, + new Set() + ); export async function getNonExistingReferenceAsKeys( savedObjects: SavedObject[], savedObjectsClient: SavedObjectsClientContract, - namespace?: string + namespace?: string, + retries?: SavedObjectsImportRetry[] ) { + const objectsToSkip = getObjectsToSkip(retries); const collector = new Map(); // Collect all references within objects for (const savedObject of savedObjects) { + if (objectsToSkip.has(`${savedObject.type}:${savedObject.id}`)) { + // skip objects with retries that have specified `ignoreMissingReferences` + continue; + } const filteredReferences = (savedObject.references || []).filter(filterReferencesToValidate); for (const { type, id } of filteredReferences) { collector.set(`${type}:${id}`, { type, id }); @@ -79,62 +91,44 @@ export async function getNonExistingReferenceAsKeys( export async function validateReferences( savedObjects: Array>, savedObjectsClient: SavedObjectsClientContract, - namespace?: string + namespace?: string, + retries?: SavedObjectsImportRetry[] ) { + const objectsToSkip = getObjectsToSkip(retries); const errorMap: { [key: string]: SavedObjectsImportError } = {}; const nonExistingReferenceKeys = await getNonExistingReferenceAsKeys( savedObjects, savedObjectsClient, - namespace + namespace, + retries ); // Filter out objects with missing references, add to error object - let filteredObjects = savedObjects.filter((savedObject) => { + savedObjects.forEach(({ type, id, references, attributes }) => { + if (objectsToSkip.has(`${type}:${id}`)) { + // skip objects with retries that have specified `ignoreMissingReferences` + return; + } + const missingReferences = []; - const enforcedTypeReferences = (savedObject.references || []).filter( - filterReferencesToValidate - ); + const enforcedTypeReferences = (references || []).filter(filterReferencesToValidate); for (const { type: refType, id: refId } of enforcedTypeReferences) { if (nonExistingReferenceKeys.includes(`${refType}:${refId}`)) { missingReferences.push({ type: refType, id: refId }); } } if (missingReferences.length === 0) { - return true; + return; } - errorMap[`${savedObject.type}:${savedObject.id}`] = { - id: savedObject.id, - type: savedObject.type, - title: savedObject.attributes && savedObject.attributes.title, - error: { - type: 'missing_references', - references: missingReferences, - blocking: [], - }, + const { title } = attributes; + errorMap[`${type}:${id}`] = { + id, + type, + title, + meta: { title }, + error: { type: 'missing_references', references: missingReferences }, }; - return false; - }); - - // Filter out objects that reference objects within the import but are missing_references - // For example: visualization referencing a search that is missing an index pattern needs to be filtered out - filteredObjects = filteredObjects.filter((savedObject) => { - let isBlocked = false; - for (const reference of savedObject.references || []) { - const referencedObjectError = errorMap[`${reference.type}:${reference.id}`]; - if (!referencedObjectError || referencedObjectError.error.type !== 'missing_references') { - continue; - } - referencedObjectError.error.blocking.push({ - type: savedObject.type, - id: savedObject.id, - }); - isBlocked = true; - } - return !isBlocked; }); - return { - errors: Object.values(errorMap), - filteredObjects, - }; + return Object.values(errorMap); } diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index b53dbd6c811f4..821b1af5cbe7b 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -25,7 +25,6 @@ import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { SavedObjectConfig } from '../../saved_objects_config'; import { setupServer, createExportableType } from '../test_utils'; import { SavedObjectsErrorHelpers } from '../..'; -import { SavedObject } from '../../types'; type SetupServerReturn = UnwrapPromise>; @@ -60,6 +59,11 @@ describe(`POST ${URL}`, () => { handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) ); + handlerContext.savedObjects.typeRegistry.getType.mockImplementation( + (type: string) => + // other attributes aren't needed for the purposes of injecting metadata + ({ management: { icon: `${type}-icon` } } as any) + ); savedObjectsClient = handlerContext.savedObjects.client; savedObjectsClient.find.mockResolvedValue(emptyResponse); @@ -116,7 +120,13 @@ describe(`POST ${URL}`, () => { expect(result.body).toEqual({ success: true, successCount: 1, - successResults: [{ type: 'index-pattern', id: 'my-pattern' }], + successResults: [ + { + type: 'index-pattern', + id: 'my-pattern', + meta: { title: 'my-pattern-*', icon: 'index-pattern-icon' }, + }, + ], }); expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( @@ -155,8 +165,16 @@ describe(`POST ${URL}`, () => { success: true, successCount: 2, successResults: [ - { type: mockIndexPattern.type, id: mockIndexPattern.id }, - { type: mockDashboard.type, id: mockDashboard.id }, + { + type: mockIndexPattern.type, + id: mockIndexPattern.id, + meta: { title: mockIndexPattern.attributes.title, icon: 'index-pattern-icon' }, + }, + { + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, + }, ], }); expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present @@ -190,12 +208,19 @@ describe(`POST ${URL}`, () => { expect(result.body).toEqual({ success: false, successCount: 1, - successResults: [{ type: mockDashboard.type, id: mockDashboard.id }], + successResults: [ + { + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, + }, + ], errors: [ { id: mockIndexPattern.id, type: mockIndexPattern.type, title: mockIndexPattern.attributes.title, + meta: { title: mockIndexPattern.attributes.title, icon: 'index-pattern-icon' }, error: { type: 'conflict' }, }, ], @@ -203,6 +228,54 @@ describe(`POST ${URL}`, () => { expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // successResults objects were not created because resolvable errors are present }); + it('imports an index pattern and dashboard but has a conflict on the index pattern, with overwrite=true', async () => { + // NOTE: changes to this scenario should be reflected in the docs + + const error = SavedObjectsErrorHelpers.createConflictError('index-pattern', 'my-pattern').output + .payload; + savedObjectsClient.checkConflicts.mockResolvedValue({ + errors: [{ type: mockIndexPattern.type, id: mockIndexPattern.id, error }], + }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [mockIndexPattern, mockDashboard], + }); + + const result = await supertest(httpSetup.server.listener) + .post(`${URL}?overwrite=true`) + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: true, + successCount: 2, + successResults: [ + { + type: mockIndexPattern.type, + id: mockIndexPattern.id, + meta: { title: mockIndexPattern.attributes.title, icon: 'index-pattern-icon' }, + overwrite: true, + }, + { + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, + }, + ], + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + }); + it('imports a visualization with missing references', async () => { // NOTE: changes to this scenario should be reflected in the docs @@ -232,19 +305,160 @@ describe(`POST ${URL}`, () => { expect(result.body).toEqual({ success: false, - successCount: 0, + successCount: 1, + errors: [ + { + id: 'my-vis', + type: 'visualization', + title: 'my-vis', + meta: { title: 'my-vis', icon: 'visualization-icon' }, + error: { + type: 'missing_references', + references: [{ type: 'index-pattern', id: 'my-pattern' }], + }, + }, + ], + successResults: [ + { + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, + }, + ], + }); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [{ fields: ['id'], id: 'my-pattern', type: 'index-pattern' }], + expect.any(Object) // options + ); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created + }); + + it('imports a visualization with missing references and a conflict', async () => { + // NOTE: changes to this scenario should be reflected in the docs + + const error1 = SavedObjectsErrorHelpers.createGenericNotFoundError( + 'index-pattern', + 'my-pattern-*' + ).output.payload; + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [{ ...mockIndexPattern, error: error1 }], + }); + const error2 = SavedObjectsErrorHelpers.createConflictError('index-pattern', 'my-pattern') + .output.payload; + savedObjectsClient.checkConflicts.mockResolvedValue({ + errors: [{ type: 'visualization', id: 'my-vis', error: error2 }], + }); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: false, + successCount: 1, + errors: [ + { + id: 'my-vis', + type: 'visualization', + title: 'my-vis', + meta: { title: 'my-vis', icon: 'visualization-icon' }, + error: { + type: 'missing_references', + references: [{ type: 'index-pattern', id: 'my-pattern' }], + }, + }, + { + id: 'my-vis', + type: 'visualization', + title: 'my-vis', + meta: { title: 'my-vis', icon: 'visualization-icon' }, + error: { type: 'conflict' }, + }, + ], + successResults: [ + { + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, + }, + ], + }); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [{ fields: ['id'], id: 'my-pattern', type: 'index-pattern' }], + expect.any(Object) // options + ); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created + }); + + it('imports a visualization with missing references and a conflict, with overwrite=true', async () => { + // NOTE: changes to this scenario should be reflected in the docs + + const error1 = SavedObjectsErrorHelpers.createGenericNotFoundError( + 'index-pattern', + 'my-pattern-*' + ).output.payload; + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [{ ...mockIndexPattern, error: error1 }], + }); + const error2 = SavedObjectsErrorHelpers.createConflictError('index-pattern', 'my-pattern') + .output.payload; + savedObjectsClient.checkConflicts.mockResolvedValue({ + errors: [{ type: 'visualization', id: 'my-vis', error: error2 }], + }); + + const result = await supertest(httpSetup.server.listener) + .post(`${URL}?overwrite=true`) + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: false, + successCount: 1, errors: [ { id: 'my-vis', type: 'visualization', title: 'my-vis', + meta: { title: 'my-vis', icon: 'visualization-icon' }, + overwrite: true, error: { type: 'missing_references', references: [{ type: 'index-pattern', id: 'my-pattern' }], - blocking: [{ type: 'dashboard', id: 'my-dashboard' }], }, }, ], + successResults: [ + { + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, + }, + ], }); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( @@ -258,12 +472,19 @@ describe(`POST ${URL}`, () => { it('imports objects, regenerating all IDs/reference IDs present, and resetting all origin IDs', async () => { mockUuidv4.mockReturnValueOnce('new-id-1').mockReturnValueOnce('new-id-2'); savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] }); - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { type: 'visualization', id: 'new-id-1' } as SavedObject, - { type: 'dashboard', id: 'new-id-2' } as SavedObject, - ], - }); + const obj1 = { + type: 'visualization', + id: 'new-id-1', + attributes: { title: 'Look at my visualization' }, + references: [], + }; + const obj2 = { + type: 'dashboard', + id: 'new-id-2', + attributes: { title: 'Look at my dashboard' }, + references: [], + }; + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [obj1, obj2] }); const result = await supertest(httpSetup.server.listener) .post(`${URL}?createNewCopies=true`) @@ -274,7 +495,7 @@ describe(`POST ${URL}`, () => { 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', 'Content-Type: application/ndjson', '', - '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}', + '{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}', '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', '--EXAMPLE--', ].join('\r\n') @@ -285,8 +506,18 @@ describe(`POST ${URL}`, () => { success: true, successCount: 2, successResults: [ - { type: 'visualization', id: 'my-vis', destinationId: 'new-id-1' }, - { type: 'dashboard', id: 'my-dashboard', destinationId: 'new-id-2' }, + { + type: obj1.type, + id: 'my-vis', + meta: { title: obj1.attributes.title, icon: 'visualization-icon' }, + destinationId: obj1.id, + }, + { + type: obj2.type, + id: 'my-dashboard', + meta: { title: obj2.attributes.title, icon: 'dashboard-icon' }, + destinationId: obj2.id, + }, ], }); expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index 0af79dcf4db3f..370f226c84e56 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -24,7 +24,6 @@ import { registerResolveImportErrorsRoute } from '../resolve_import_errors'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer, createExportableType } from '../test_utils'; import { SavedObjectConfig } from '../../saved_objects_config'; -import { SavedObject } from '../../types'; type SetupServerReturn = UnwrapPromise>; @@ -64,6 +63,13 @@ describe(`POST ${URL}`, () => { handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) ); + handlerContext.savedObjects.typeRegistry.getType.mockImplementation( + (type: string) => + ({ + // other attributes aren't needed for the purposes of injecting metadata + management: { icon: `${type}-icon` }, + } as any) + ); savedObjectsClient = handlerContext.savedObjects.client; savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); @@ -124,8 +130,17 @@ describe(`POST ${URL}`, () => { ) .expect(200); - const { type, id } = mockDashboard; - expect(result.body).toEqual({ success: true, successCount: 1, successResults: [{ type, id }] }); + const { + type, + id, + attributes: { title }, + } = mockDashboard; + const meta = { title, icon: 'dashboard-icon' }; + expect(result.body).toEqual({ + success: true, + successCount: 1, + successResults: [{ type, id, meta }], + }); expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( [expect.objectContaining({ migrationVersion: {} })], @@ -157,7 +172,12 @@ describe(`POST ${URL}`, () => { .expect(200); const { type, id, attributes } = mockDashboard; - expect(result.body).toEqual({ success: true, successCount: 1, successResults: [{ type, id }] }); + const meta = { title: attributes.title, icon: 'dashboard-icon' }; + expect(result.body).toEqual({ + success: true, + successCount: 1, + successResults: [{ type, id, meta }], + }); expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( [{ type, id, attributes, migrationVersion: {} }], @@ -190,10 +210,11 @@ describe(`POST ${URL}`, () => { .expect(200); const { type, id, attributes } = mockDashboard; + const meta = { title: attributes.title, icon: 'dashboard-icon' }; expect(result.body).toEqual({ success: true, successCount: 1, - successResults: [{ type, id }], + successResults: [{ type, id, meta, overwrite: true }], }); expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( @@ -202,7 +223,7 @@ describe(`POST ${URL}`, () => { ); }); - it('resolves conflicts by replacing the visualization references', async () => { + it('resolves `missing_references` errors by replacing the missing references', async () => { // NOTE: changes to this scenario should be reflected in the docs savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [mockVisualization] }); savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] }); @@ -230,7 +251,13 @@ describe(`POST ${URL}`, () => { expect(result.body).toEqual({ success: true, successCount: 1, - successResults: [{ type: 'visualization', id: 'my-vis' }], + successResults: [ + { + type: 'visualization', + id: 'my-vis', + meta: { title: 'Look at my visualization', icon: 'visualization-icon' }, + }, + ], }); expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( @@ -244,16 +271,67 @@ describe(`POST ${URL}`, () => { ); }); + it('resolves `missing_references` errors by ignoring the missing references', async () => { + // NOTE: changes to this scenario should be reflected in the docs + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [mockVisualization] }); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"missing"}]}', + '--EXAMPLE', + 'Content-Disposition: form-data; name="retries"', + '', + '[{"type":"visualization","id":"my-vis","ignoreMissingReferences":true}]', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + const { type, id, attributes } = mockVisualization; + const references = [{ name: 'ref_0', type: 'index-pattern', id: 'missing' }]; + expect(result.body).toEqual({ + success: true, + successCount: 1, + successResults: [ + { + type: 'visualization', + id: 'my-vis', + meta: { title: 'Look at my visualization', icon: 'visualization-icon' }, + }, + ], + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [{ type, id, attributes, references, migrationVersion: {} }], + expect.objectContaining({ overwrite: undefined }) + ); + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + }); + describe('createNewCopies enabled', () => { it('imports objects, regenerating all IDs/reference IDs present, and resetting all origin IDs', async () => { mockUuidv4.mockReturnValue('new-id-1'); savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] }); - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { type: 'visualization', id: 'new-id-1' } as SavedObject, - { type: 'dashboard', id: 'new-id-2' } as SavedObject, - ], - }); + const obj1 = { + type: 'visualization', + id: 'new-id-1', + attributes: { title: 'Look at my visualization' }, + references: [], + }; + const obj2 = { + type: 'dashboard', + id: 'new-id-2', + attributes: { title: 'Look at my dashboard' }, + references: [], + }; + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [obj1, obj2] }); const result = await supertest(httpSetup.server.listener) .post(`${URL}?createNewCopies=true`) @@ -264,7 +342,7 @@ describe(`POST ${URL}`, () => { 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', 'Content-Type: application/ndjson', '', - '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}', + '{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}', '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', '--EXAMPLE', 'Content-Disposition: form-data; name="retries"', @@ -279,8 +357,18 @@ describe(`POST ${URL}`, () => { success: true, successCount: 2, successResults: [ - { type: 'visualization', id: 'my-vis', destinationId: 'new-id-1' }, - { type: 'dashboard', id: 'my-dashboard', destinationId: 'new-id-2' }, + { + type: obj1.type, + id: 'my-vis', + meta: { title: obj1.attributes.title, icon: 'visualization-icon' }, + destinationId: obj1.id, + }, + { + type: obj2.type, + id: 'my-dashboard', + meta: { title: obj2.attributes.title, icon: 'dashboard-icon' }, + destinationId: obj2.id, + }, ], }); expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index bdafdaaaf019a..93fcb6dbda0ac 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -65,6 +65,7 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO { defaultValue: [] } ), createNewCopy: schema.maybe(schema.boolean()), + ignoreMissingReferences: schema.maybe(schema.boolean()), }) ), }), diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts index c7a4b7df06547..a44b21aef5706 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts @@ -178,6 +178,20 @@ describe('searchDsl/getSortParams', () => { }); }); }); + describe('sortField is root simple property with single type', () => { + it('returns correct params', () => { + expect(getSortingParams(MAPPINGS, ['saved'], 'type', 'desc')).toEqual({ + sort: [ + { + type: { + order: 'desc', + unmapped_type: 'text', + }, + }, + ], + }); + }); + }); describe('sortField is root simple property with multiple type', () => { it('returns correct params', () => { expect(getSortingParams(MAPPINGS, ['saved', 'pending'], 'type', 'desc')).toEqual({ diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts index f850954e84323..ccf5ccd50bb75 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts @@ -67,10 +67,15 @@ export function getSortingParams( } const [typeField] = types; - const key = `${typeField}.${sortField}`; - const field = getProperty(mappings, key); + let key = `${typeField}.${sortField}`; + let field = getProperty(mappings, key); if (!field) { - throw Boom.badRequest(`Unknown sort field ${sortField}`); + // type field does not exist, try checking the root properties + key = sortField; + field = getProperty(mappings, sortField); + if (!field) { + throw Boom.badRequest(`Unknown sort field ${sortField}`); + } } return { diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 7b3697173ef0d..edbdbe4d16784 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -180,9 +180,6 @@ export type SavedObjectsClientContract = Pick; // (undocumented) references: Array<{ type: string; @@ -2415,6 +2416,7 @@ export interface SavedObjectsImportRetry { destinationId?: string; // (undocumented) id: string; + ignoreMissingReferences?: boolean; // (undocumented) overwrite: boolean; // (undocumented) @@ -2435,6 +2437,12 @@ export interface SavedObjectsImportSuccess { // (undocumented) id: string; // (undocumented) + meta: { + title?: string; + icon?: string; + }; + overwrite?: boolean; + // (undocumented) type: string; } diff --git a/src/plugins/saved_objects_management/common/types.ts b/src/plugins/saved_objects_management/common/types.ts index be52d8e6486e2..1f9f26b0ddb98 100644 --- a/src/plugins/saved_objects_management/common/types.ts +++ b/src/plugins/saved_objects_management/common/types.ts @@ -18,6 +18,7 @@ */ import { SavedObject } from 'src/core/types'; +import { SavedObjectsNamespaceType } from 'src/core/public'; /** * The metadata injected into a {@link SavedObject | saved object} when returning @@ -28,6 +29,7 @@ export interface SavedObjectMetadata { title?: string; editUrl?: string; inAppUrl?: { path: string; uiCapabilitiesPath: string }; + namespaceType?: SavedObjectsNamespaceType; } /** diff --git a/src/plugins/saved_objects_management/public/index.ts b/src/plugins/saved_objects_management/public/index.ts index 6663409c259d5..c85d66c0ac38b 100644 --- a/src/plugins/saved_objects_management/public/index.ts +++ b/src/plugins/saved_objects_management/public/index.ts @@ -25,11 +25,14 @@ export { SavedObjectsManagementActionServiceSetup, SavedObjectsManagementActionServiceStart, SavedObjectsManagementAction, + SavedObjectsManagementColumnServiceSetup, + SavedObjectsManagementColumnServiceStart, + SavedObjectsManagementColumn, SavedObjectsManagementRecord, ISavedObjectsManagementServiceRegistry, SavedObjectsManagementServiceRegistryEntry, } from './services'; -export { ProcessedImportResponse, processImportResponse } from './lib'; +export { ProcessedImportResponse, processImportResponse, FailedImport } from './lib'; export { SavedObjectRelation, SavedObjectWithMetadata, SavedObjectMetadata } from './types'; export function plugin(initializerContext: PluginInitializerContext) { diff --git a/src/plugins/saved_objects_management/public/lib/find_objects.ts b/src/plugins/saved_objects_management/public/lib/find_objects.ts index 5a77d3ae2f663..530dcc4648d8c 100644 --- a/src/plugins/saved_objects_management/public/lib/find_objects.ts +++ b/src/plugins/saved_objects_management/public/lib/find_objects.ts @@ -41,3 +41,13 @@ export async function findObjects( return keysToCamelCaseShallow(response) as SavedObjectsFindResponse; } + +export async function findObject( + http: HttpStart, + type: string, + id: string +): Promise { + return await http.get( + `/api/kibana/management/saved_objects/${encodeURIComponent(type)}/${encodeURIComponent(id)}` + ); +} diff --git a/src/plugins/saved_objects_management/public/lib/import_file.ts b/src/plugins/saved_objects_management/public/lib/import_file.ts index 96263452253ba..84177bda3eb43 100644 --- a/src/plugins/saved_objects_management/public/lib/import_file.ts +++ b/src/plugins/saved_objects_management/public/lib/import_file.ts @@ -18,6 +18,7 @@ */ import { HttpStart, SavedObjectsImportError } from 'src/core/public'; +import { ImportMode } from '../management_section/objects_table/components/import_mode_control'; interface ImportResponse { success: boolean; @@ -25,17 +26,20 @@ interface ImportResponse { errors?: SavedObjectsImportError[]; } -export async function importFile(http: HttpStart, file: File, overwriteAll: boolean = false) { +export async function importFile( + http: HttpStart, + file: File, + { createNewCopies, overwrite }: ImportMode +) { const formData = new FormData(); formData.append('file', file); + const query = createNewCopies ? { createNewCopies } : { overwrite }; return await http.post('/api/saved_objects/_import', { body: formData, headers: { // Important to be undefined, it forces proper headers to be set for FormData 'Content-Type': undefined, }, - query: { - overwrite: overwriteAll, - }, + query, }); } diff --git a/src/plugins/saved_objects_management/public/lib/index.ts b/src/plugins/saved_objects_management/public/lib/index.ts index 7021744095651..9ed5b1907cecb 100644 --- a/src/plugins/saved_objects_management/public/lib/index.ts +++ b/src/plugins/saved_objects_management/public/lib/index.ts @@ -41,7 +41,7 @@ export { FailedImport, } from './process_import_response'; export { getDefaultTitle } from './get_default_title'; -export { findObjects } from './find_objects'; +export { findObjects, findObject } from './find_objects'; export { extractExportDetails, SavedObjectsExportResultDetails } from './extract_export_details'; export { createFieldList } from './create_field_list'; export { getAllowedTypes } from './get_allowed_types'; diff --git a/src/plugins/saved_objects_management/public/lib/process_import_response.test.ts b/src/plugins/saved_objects_management/public/lib/process_import_response.test.ts index cd35d4d726400..4d7e74c2649cd 100644 --- a/src/plugins/saved_objects_management/public/lib/process_import_response.test.ts +++ b/src/plugins/saved_objects_management/public/lib/process_import_response.test.ts @@ -47,6 +47,7 @@ describe('processImportResponse()', () => { error: { type: 'conflict', } as SavedObjectsImportConflictError, + meta: {}, }, ], }; @@ -59,6 +60,7 @@ describe('processImportResponse()', () => { }, "obj": Object { "id": "1", + "meta": Object {}, "type": "a", }, }, @@ -78,6 +80,7 @@ describe('processImportResponse()', () => { error: { type: 'ambiguous_conflict', } as SavedObjectsImportAmbiguousConflictError, + meta: {}, }, ], }; @@ -90,6 +93,7 @@ describe('processImportResponse()', () => { }, "obj": Object { "id": "1", + "meta": Object {}, "type": "a", }, }, @@ -109,6 +113,7 @@ describe('processImportResponse()', () => { error: { type: 'unknown', } as SavedObjectsImportUnknownError, + meta: {}, }, ], }; @@ -121,6 +126,7 @@ describe('processImportResponse()', () => { }, "obj": Object { "id": "1", + "meta": Object {}, "type": "a", }, }, @@ -146,6 +152,7 @@ describe('processImportResponse()', () => { }, ], } as SavedObjectsImportMissingReferencesError, + meta: {}, }, ], }; @@ -164,6 +171,7 @@ describe('processImportResponse()', () => { }, "obj": Object { "id": "1", + "meta": Object {}, "type": "a", }, }, @@ -171,4 +179,50 @@ describe('processImportResponse()', () => { `); expect(result.status).toBe('idle'); }); + + test('missing references get added to unmatchedReferences, but are not duplicated', () => { + const response = { + success: false, + successCount: 0, + errors: [ + { + type: 'a', + id: '1', + error: { + type: 'missing_references', + references: [ + { type: 'index-pattern', id: '2' }, + { type: 'index-pattern', id: '3' }, + { type: 'index-pattern', id: '2' }, // duplicate that should not show in the result's unmatchedReferences + ], + } as SavedObjectsImportMissingReferencesError, + meta: {}, + }, + ], + }; + const result = processImportResponse(response); + expect(result.unmatchedReferences).toEqual([ + expect.objectContaining({ existingIndexPatternId: '2' }), + expect.objectContaining({ existingIndexPatternId: '3' }), + ]); + }); + + test('success results get added to successfulImports and result in success status', () => { + const response = { + success: true, + successCount: 1, + successResults: [{ type: 'a', id: '1', meta: {} }], + }; + const result = processImportResponse(response); + expect(result.successfulImports).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "meta": Object {}, + "type": "a", + }, + ] + `); + expect(result.status).toBe('success'); + }); }); diff --git a/src/plugins/saved_objects_management/public/lib/process_import_response.ts b/src/plugins/saved_objects_management/public/lib/process_import_response.ts index ece8c924f8885..bb7492bb9b3de 100644 --- a/src/plugins/saved_objects_management/public/lib/process_import_response.ts +++ b/src/plugins/saved_objects_management/public/lib/process_import_response.ts @@ -25,10 +25,11 @@ import { SavedObjectsImportMissingReferencesError, SavedObjectsImportUnknownError, SavedObjectsImportError, + SavedObjectsImportSuccess, } from 'src/core/public'; export interface FailedImport { - obj: Pick; + obj: Omit; error: | SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError @@ -37,20 +38,23 @@ export interface FailedImport { | SavedObjectsImportUnknownError; } +interface UnmatchedReference { + existingIndexPatternId: string; + list: Array>; + newIndexPatternId?: string; +} + export interface ProcessedImportResponse { failedImports: FailedImport[]; - unmatchedReferences: Array<{ - existingIndexPatternId: string; - list: Array>; - newIndexPatternId: string | undefined; - }>; + successfulImports: SavedObjectsImportSuccess[]; + unmatchedReferences: UnmatchedReference[]; status: 'success' | 'idle'; importCount: number; conflictedSavedObjectsLinkedToSavedSearches: undefined; conflictedSearchDocs: undefined; } -const isConflict = ({ type }: FailedImport['error']) => +const isAnyConflict = ({ type }: FailedImport['error']) => type === 'conflict' || type === 'ambiguous_conflict'; export function processImportResponse( @@ -58,7 +62,7 @@ export function processImportResponse( ): ProcessedImportResponse { // Go through the failures and split between unmatchedReferences and failedImports const failedImports = []; - const unmatchedReferences = new Map(); + const unmatchedReferences = new Map(); for (const { error, ...obj } of response.errors || []) { failedImports.push({ obj, error }); if (error.type !== 'missing_references') { @@ -74,18 +78,21 @@ export function processImportResponse( list: [], newIndexPatternId: undefined, }; - conflict.list.push(obj); - unmatchedReferences.set(`${missingReference.type}:${missingReference.id}`, conflict); + if (!conflict.list.some(({ type, id }) => type === obj.type && id === obj.id)) { + conflict.list.push(obj); + unmatchedReferences.set(`${missingReference.type}:${missingReference.id}`, conflict); + } } } return { failedImports, + successfulImports: response.successResults ?? [], unmatchedReferences: Array.from(unmatchedReferences.values()), // Import won't be successful in the scenario unmatched references exist, import API returned errors of type unknown or import API // returned errors of type missing_references. status: - unmatchedReferences.size === 0 && !failedImports.some((issue) => isConflict(issue.error)) + unmatchedReferences.size === 0 && !failedImports.some((issue) => isAnyConflict(issue.error)) ? 'success' : 'idle', importCount: response.successCount, diff --git a/src/plugins/saved_objects_management/public/lib/resolve_import_errors.test.ts b/src/plugins/saved_objects_management/public/lib/resolve_import_errors.test.ts index 86eebad7ae787..9aa9e3e664413 100644 --- a/src/plugins/saved_objects_management/public/lib/resolve_import_errors.test.ts +++ b/src/plugins/saved_objects_management/public/lib/resolve_import_errors.test.ts @@ -50,17 +50,16 @@ describe('resolveImportErrors', () => { const result = await resolveImportErrors({ http: httpMock, getConflictResolutions, - state: { - importCount: 0, - }, + state: { importCount: 0, importMode: { createNewCopies: false, overwrite: false } }, }); expect(result).toMatchInlineSnapshot(` -Object { - "failedImports": Array [], - "importCount": 0, - "status": "success", -} -`); + Object { + "failedImports": Array [], + "importCount": 0, + "status": "success", + "successfulImports": Array [], + } + `); }); test(`doesn't retry if only unknown failures are passed in`, async () => { @@ -74,41 +73,49 @@ Object { obj: { type: 'a', id: '1', + meta: {}, }, - error: { - type: 'unknown', - } as SavedObjectsImportUnknownError, + error: { type: 'unknown' } as SavedObjectsImportUnknownError, }, ], + importMode: { createNewCopies: false, overwrite: false }, }, }); + expect(httpMock.post).not.toHaveBeenCalled(); expect(result).toMatchInlineSnapshot(` -Object { - "failedImports": Array [ - Object { - "error": Object { - "type": "unknown", - }, - "obj": Object { - "id": "1", - "type": "a", - }, - }, - ], - "importCount": 0, - "status": "success", -} -`); + Object { + "failedImports": Array [ + Object { + "error": Object { + "type": "unknown", + }, + "obj": Object { + "id": "1", + "meta": Object {}, + "type": "a", + }, + }, + ], + "importCount": 0, + "status": "success", + "successfulImports": Array [], + } + `); }); test('resolves conflicts', async () => { httpMock.post.mockResolvedValueOnce({ success: true, - successCount: 1, + successCount: 2, + successResults: [ + { type: 'a', id: '1' }, + { type: 'a', id: '2', destinationId: 'x' }, + ], }); getConflictResolutions.mockReturnValueOnce({ - 'a:1': true, - 'a:2': false, + 'a:1': { retry: true, options: { overwrite: true } }, + 'a:2': { retry: true, options: { overwrite: true, destinationId: 'x' } }, + 'a:3': { retry: false }, }); const result = await resolveImportErrors({ http: httpMock, @@ -116,124 +123,54 @@ Object { state: { importCount: 0, failedImports: [ + { obj: { type: 'a', id: '1', meta: {} }, error: { type: 'conflict' } }, { - obj: { - type: 'a', - id: '1', - }, - error: { - type: 'conflict', - }, - }, - { - obj: { - type: 'a', - id: '2', - }, - error: { - type: 'conflict', - }, + obj: { type: 'a', id: '2', meta: {} }, + error: { type: 'conflict', destinationId: 'x' }, }, + { obj: { type: 'a', id: '3', meta: {} }, error: { type: 'conflict' } }, ], + importMode: { createNewCopies: false, overwrite: false }, }, }); expect(result).toMatchInlineSnapshot(` -Object { - "failedImports": Array [], - "importCount": 1, - "status": "success", -} -`); + Object { + "failedImports": Array [], + "importCount": 2, + "status": "success", + "successfulImports": Array [ + Object { + "id": "1", + "type": "a", + }, + Object { + "destinationId": "x", + "id": "2", + "type": "a", + }, + ], + } + `); const formData = getFormData(extractBodyFromCall(0)); expect(formData).toMatchInlineSnapshot(` -Object { - "file": "undefined", - "retries": Array [ - Object { - "id": "1", - "overwrite": true, - "replaceReferences": Array [], - "type": "a", - }, - ], -} -`); - }); - - test('resolves missing references', async () => { - httpMock.post.mockResolvedValueOnce({ - success: true, - successCount: 2, - }); - getConflictResolutions.mockResolvedValueOnce({}); - const result = await resolveImportErrors({ - http: httpMock, - getConflictResolutions, - state: { - importCount: 0, - unmatchedReferences: [ - { - existingIndexPatternId: '2', - newIndexPatternId: '3', + Object { + "file": "undefined", + "retries": Array [ + Object { + "id": "1", + "overwrite": true, + "type": "a", }, - ], - failedImports: [ - { - obj: { - type: 'a', - id: '1', - }, - error: { - type: 'missing_references', - references: [ - { - type: 'index-pattern', - id: '2', - }, - ], - blocking: [ - { - type: 'a', - id: '2', - }, - ], - }, + Object { + "destinationId": "x", + "id": "2", + "overwrite": true, + "type": "a", }, ], - }, - }); - expect(result).toMatchInlineSnapshot(` -Object { - "failedImports": Array [], - "importCount": 2, - "status": "success", -} -`); - const formData = getFormData(extractBodyFromCall(0)); - expect(formData).toMatchInlineSnapshot(` -Object { - "file": "undefined", - "retries": Array [ - Object { - "id": "1", - "overwrite": false, - "replaceReferences": Array [ - Object { - "from": "2", - "to": "3", - "type": "index-pattern", - }, - ], - "type": "a", - }, - Object { - "id": "2", - "type": "a", - }, - ], -} -`); + } + `); }); test(`doesn't resolve missing references if newIndexPatternId isn't defined`, async () => { @@ -244,144 +181,115 @@ Object { state: { importCount: 0, unmatchedReferences: [ - { - existingIndexPatternId: '2', - newIndexPatternId: undefined, - }, + { existingIndexPatternId: '2', newIndexPatternId: undefined, list: [] }, ], failedImports: [ { obj: { type: 'a', id: '1', + meta: {}, }, error: { type: 'missing_references', - references: [ - { - type: 'index-pattern', - id: '2', - }, - ], - blocking: [ - { - type: 'a', - id: '2', - }, - ], + references: [{ type: 'index-pattern', id: '2' }], }, }, ], + importMode: { createNewCopies: false, overwrite: false }, }, }); expect(result).toMatchInlineSnapshot(` -Object { - "failedImports": Array [], - "importCount": 0, - "status": "success", -} -`); + Object { + "failedImports": Array [], + "importCount": 0, + "status": "success", + "successfulImports": Array [], + } + `); }); test('handles missing references then conflicts on the same errored objects', async () => { httpMock.post.mockResolvedValueOnce({ success: false, successCount: 0, - errors: [ - { - type: 'a', - id: '1', - error: { - type: 'conflict', - }, - }, - ], + errors: [{ type: 'a', id: '1', error: { type: 'conflict' } }], }); httpMock.post.mockResolvedValueOnce({ success: true, successCount: 1, + successResults: [{ type: 'a', id: '1' }], }); getConflictResolutions.mockResolvedValueOnce({}); getConflictResolutions.mockResolvedValueOnce({ - 'a:1': true, + 'a:1': { retry: true, options: { overwrite: true } }, }); const result = await resolveImportErrors({ http: httpMock, getConflictResolutions, state: { importCount: 0, - unmatchedReferences: [ - { - existingIndexPatternId: '2', - newIndexPatternId: '3', - }, - ], + unmatchedReferences: [{ existingIndexPatternId: '2', newIndexPatternId: '3', list: [] }], failedImports: [ { - obj: { - type: 'a', - id: '1', - }, - error: { - type: 'missing_references', - references: [ - { - type: 'index-pattern', - id: '2', - }, - ], - blocking: [], - }, + obj: { type: 'a', id: '1', meta: {} }, + error: { type: 'missing_references', references: [{ type: 'index-pattern', id: '2' }] }, }, ], + importMode: { createNewCopies: false, overwrite: false }, }, }); expect(result).toMatchInlineSnapshot(` -Object { - "failedImports": Array [], - "importCount": 1, - "status": "success", -} -`); + Object { + "failedImports": Array [], + "importCount": 1, + "status": "success", + "successfulImports": Array [ + Object { + "id": "1", + "type": "a", + }, + ], + } + `); const formData1 = getFormData(extractBodyFromCall(0)); expect(formData1).toMatchInlineSnapshot(` -Object { - "file": "undefined", - "retries": Array [ - Object { - "id": "1", - "overwrite": false, - "replaceReferences": Array [ - Object { - "from": "2", - "to": "3", - "type": "index-pattern", - }, - ], - "type": "a", - }, - ], -} -`); + Object { + "file": "undefined", + "retries": Array [ + Object { + "id": "1", + "replaceReferences": Array [ + Object { + "from": "2", + "to": "3", + "type": "index-pattern", + }, + ], + "type": "a", + }, + ], + } + `); const formData2 = getFormData(extractBodyFromCall(1)); expect(formData2).toMatchInlineSnapshot(` -Object { - "file": "undefined", - "retries": Array [ - Object { - "id": "1", - "overwrite": true, - "replaceReferences": Array [ - Object { - "from": "2", - "to": "3", - "type": "index-pattern", - }, - ], - "type": "a", - }, - ], -} -`); + Object { + "file": "undefined", + "retries": Array [ + Object { + "id": "1", + "overwrite": true, + "replaceReferences": Array [ + Object { + "from": "2", + "to": "3", + "type": "index-pattern", + }, + ], + "type": "a", + }, + ], + } + `); }); }); diff --git a/src/plugins/saved_objects_management/public/lib/resolve_import_errors.ts b/src/plugins/saved_objects_management/public/lib/resolve_import_errors.ts index ea29cc4884d00..3084d40b63be6 100644 --- a/src/plugins/saved_objects_management/public/lib/resolve_import_errors.ts +++ b/src/plugins/saved_objects_management/public/lib/resolve_import_errors.ts @@ -17,47 +17,102 @@ * under the License. */ -import { HttpStart } from 'src/core/public'; -import { FailedImport } from './process_import_response'; +import { + HttpStart, + SavedObjectsImportConflictError, + SavedObjectsImportRetry, + SavedObjectsImportResponse, + SavedObjectsImportAmbiguousConflictError, +} from 'src/core/public'; +import { Required } from '@kbn/utility-types'; +import { FailedImport, ProcessedImportResponse } from './process_import_response'; -interface RetryObject { - id: string; +// the HTTP route requires type and ID; all other field are optional +type RetryObject = Required, 'type' | 'id'>; + +interface Reference { type: string; - overwrite?: boolean; - replaceReferences?: any[]; + from: string; + to: string; +} + +export interface RetryDecision { + retry: boolean; // false == skip + options: { overwrite: boolean; destinationId?: string }; } -async function callResolveImportErrorsApi(http: HttpStart, file: File, retries: any) { +const RESOLVABLE_ERRORS = ['conflict', 'ambiguous_conflict', 'missing_references']; +export interface FailedImportConflict { + obj: FailedImport['obj']; + error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError; +} +const isConflict = ( + failure: FailedImport +): failure is { obj: FailedImport['obj']; error: SavedObjectsImportConflictError } => + failure.error.type === 'conflict'; +const isAmbiguousConflict = ( + failure: FailedImport +): failure is { obj: FailedImport['obj']; error: SavedObjectsImportAmbiguousConflictError } => + failure.error.type === 'ambiguous_conflict'; +const isAnyConflict = (failure: FailedImport): failure is FailedImportConflict => + isConflict(failure) || isAmbiguousConflict(failure); + +/** + * The server-side code was updated to include missing_references errors and conflict/ambiguous_conflict errors for the same object in the + * same response. This client-side code was not built to handle multiple errors for a single import object, though. We simply filter out any + * conflicts if a missing_references error for the same object is present. This means that the missing_references error will get resolved + * or skipped first, and any conflicts still present will be returned again and resolved with another API call. + */ +const filterFailedImports = (failures: FailedImport[]) => { + const missingReferences = failures + .filter(({ error: { type } }) => type === 'missing_references') + .reduce((acc, { obj: { type, id } }) => acc.add(`${type}:${id}`), new Set()); + return failures.filter( + (failure) => + !isAnyConflict(failure) || + (isAnyConflict(failure) && !missingReferences.has(`${failure.obj.type}:${failure.obj.id}`)) + ); +}; + +async function callResolveImportErrorsApi( + http: HttpStart, + file: File, + retries: any, + createNewCopies: boolean +): Promise { const formData = new FormData(); formData.append('file', file); formData.append('retries', JSON.stringify(retries)); + const query = createNewCopies ? { createNewCopies } : {}; return http.post('/api/saved_objects/_resolve_import_errors', { headers: { // Important to be undefined, it forces proper headers to be set for FormData 'Content-Type': undefined, }, body: formData, + query, }); } function mapImportFailureToRetryObject({ failure, - overwriteDecisionCache, + retryDecisionCache, replaceReferencesCache, state, }: { failure: FailedImport; - overwriteDecisionCache: Map; - replaceReferencesCache: Map; - state: any; + retryDecisionCache: Map; + replaceReferencesCache: Map; + state: { unmatchedReferences?: ProcessedImportResponse['unmatchedReferences'] }; }): RetryObject | undefined { - const { isOverwriteAllChecked, unmatchedReferences } = state; - const isOverwriteGranted = - isOverwriteAllChecked || - overwriteDecisionCache.get(`${failure.obj.type}:${failure.obj.id}`) === true; + const { unmatchedReferences = [] } = state; + const retryDecision = retryDecisionCache.get(`${failure.obj.type}:${failure.obj.id}`); - // Conflicts wihtout overwrite granted are skipped - if (!isOverwriteGranted && failure.error.type === 'conflict') { + // Conflicts without a resolution are skipped + if ( + !retryDecision?.retry && + (failure.error.type === 'conflict' || failure.error.type === 'ambiguous_conflict') + ) { return; } @@ -67,17 +122,19 @@ function mapImportFailureToRetryObject({ replaceReferencesCache.get(`${failure.obj.type}:${failure.obj.id}`) || []; const indexPatternRefs = failure.error.references.filter((obj) => obj.type === 'index-pattern'); for (const reference of indexPatternRefs) { - for (const unmatchedReference of unmatchedReferences) { - const hasNewValue = !!unmatchedReference.newIndexPatternId; - const matchesIndexPatternId = unmatchedReference.existingIndexPatternId === reference.id; - if (!hasNewValue || !matchesIndexPatternId) { + for (const { existingIndexPatternId: from, newIndexPatternId: to } of unmatchedReferences) { + const matchesIndexPatternId = from === reference.id; + if (!to || !matchesIndexPatternId) { continue; } - objReplaceReferences.push({ - type: 'index-pattern', - from: unmatchedReference.existingIndexPatternId, - to: unmatchedReference.newIndexPatternId, - }); + const type = 'index-pattern'; + if ( + !objReplaceReferences.some( + (ref) => ref.type === type && ref.from === from && ref.to === to + ) + ) { + objReplaceReferences.push({ type, from, to }); + } } } replaceReferencesCache.set(`${failure.obj.type}:${failure.obj.id}`, objReplaceReferences); @@ -90,10 +147,8 @@ function mapImportFailureToRetryObject({ return { id: failure.obj.id, type: failure.obj.type, - overwrite: - isOverwriteAllChecked || - overwriteDecisionCache.get(`${failure.obj.type}:${failure.obj.id}`) === true, - replaceReferences: replaceReferencesCache.get(`${failure.obj.type}:${failure.obj.id}`) || [], + ...(retryDecision?.retry && retryDecision.options), + replaceReferences: replaceReferencesCache.get(`${failure.obj.type}:${failure.obj.id}`), }; } @@ -103,88 +158,114 @@ export async function resolveImportErrors({ state, }: { http: HttpStart; - getConflictResolutions: (objects: any[]) => Promise>; - state: { importCount: number; failedImports?: FailedImport[] } & Record; + getConflictResolutions: ( + objects: FailedImportConflict[] + ) => Promise>; + state: { + importCount: number; + unmatchedReferences?: ProcessedImportResponse['unmatchedReferences']; + failedImports?: ProcessedImportResponse['failedImports']; + successfulImports?: ProcessedImportResponse['successfulImports']; + file?: File; + importMode: { createNewCopies: boolean; overwrite: boolean }; + }; }) { - const overwriteDecisionCache = new Map(); - const replaceReferencesCache = new Map(); - let { importCount: successImportCount, failedImports: importFailures = [] } = state; - const { file, isOverwriteAllChecked } = state; + const retryDecisionCache = new Map(); + const replaceReferencesCache = new Map(); + let { importCount, failedImports = [], successfulImports = [] } = state; + const { + file, + importMode: { createNewCopies, overwrite: isOverwriteAllChecked }, + } = state; - const doesntHaveOverwriteDecision = ({ obj }: FailedImport) => { - return !overwriteDecisionCache.has(`${obj.type}:${obj.id}`); + const doesntHaveRetryDecision = ({ obj }: FailedImport) => { + return !retryDecisionCache.has(`${obj.type}:${obj.id}`); }; - const getOverwriteDecision = ({ obj }: FailedImport) => { - return overwriteDecisionCache.get(`${obj.type}:${obj.id}`); + const getRetryDecision = ({ obj }: FailedImport) => { + return retryDecisionCache.get(`${obj.type}:${obj.id}`); }; const callMapImportFailure = (failure: FailedImport) => mapImportFailureToRetryObject({ failure, - overwriteDecisionCache, + retryDecisionCache, replaceReferencesCache, state, }); const isNotSkipped = (failure: FailedImport) => { - return ( - (failure.error.type !== 'conflict' && failure.error.type !== 'missing_references') || - getOverwriteDecision(failure) - ); + const { type } = failure.error; + return !RESOLVABLE_ERRORS.includes(type) || getRetryDecision(failure)?.retry; }; // Loop until all issues are resolved - while ( - importFailures.some((failure) => - ['conflict', 'missing_references'].includes(failure.error.type) - ) - ) { - // Ask for overwrites - if (!isOverwriteAllChecked) { - const result = await getConflictResolutions( - importFailures - .filter(({ error }) => error.type === 'conflict') - .filter(doesntHaveOverwriteDecision) - .map(({ obj }) => obj) - ); - for (const key of Object.keys(result)) { - overwriteDecisionCache.set(key, result[key]); - } + while (failedImports.some((failure) => RESOLVABLE_ERRORS.includes(failure.error.type))) { + // Filter out multiple errors for the same object + const filteredFailures = filterFailedImports(failedImports); + + // Resolve regular conflicts + if (isOverwriteAllChecked) { + filteredFailures + .filter(isConflict) + .forEach(({ obj: { type, id }, error: { destinationId } }) => + retryDecisionCache.set(`${type}:${id}`, { + retry: true, + options: { + overwrite: true, + ...(destinationId && { destinationId }), + }, + }) + ); + } + + // prompt the user for each conflict + const result = await getConflictResolutions( + isOverwriteAllChecked + ? filteredFailures.filter(isAmbiguousConflict).filter(doesntHaveRetryDecision) + : filteredFailures.filter(isAnyConflict).filter(doesntHaveRetryDecision) + ); + for (const key of Object.keys(result)) { + retryDecisionCache.set(key, result[key]); } // Build retries array - const retries = importFailures + const failRetries = filteredFailures .map(callMapImportFailure) .filter((obj) => !!obj) as RetryObject[]; - for (const { error, obj } of importFailures) { - if (error.type !== 'missing_references') { - continue; - } - if (!retries.some((retryObj) => retryObj.type === obj.type && retryObj.id === obj.id)) { - continue; - } - for (const { type, id } of error.blocking || []) { - retries.push({ type, id }); + const successRetries = successfulImports.map( + ({ type, id, overwrite, destinationId, createNewCopy }) => { + const replaceReferences = replaceReferencesCache.get(`${type}:${id}`); + return { + type, + id, + ...(overwrite && { overwrite }), + ...(replaceReferences && { replaceReferences }), + destinationId, + createNewCopy, + }; } - } + ); + const retries = [...failRetries, ...successRetries]; - // Scenario where everything is skipped and nothing to retry + // Scenario where there were no success results, all errors were skipped, and nothing to retry if (retries.length === 0) { // Cancelled overwrites aren't failures anymore - importFailures = importFailures.filter(isNotSkipped); + failedImports = filteredFailures.filter(isNotSkipped); break; } // Call API - const response = await callResolveImportErrorsApi(http, file, retries); - successImportCount += response.successCount; - importFailures = []; + const response = await callResolveImportErrorsApi(http, file!, retries, createNewCopies); + importCount = response.successCount; // reset the success count since we retry all successful results each time + failedImports = []; for (const { error, ...obj } of response.errors || []) { - importFailures.push({ error, obj }); + failedImports.push({ error, obj }); } + successfulImports = response.successResults || []; } return { status: 'success', - importCount: successImportCount, - failedImports: importFailures, + importCount, + failedImports, + successfulImports, }; } diff --git a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx index 9cfe99fd3bbf8..2f0189248f943 100644 --- a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx +++ b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx @@ -85,6 +85,7 @@ export const mountManagementSection = async ({ dataStart={data} serviceRegistry={serviceRegistry} actionRegistry={pluginStart.actions} + columnRegistry={pluginStart.columns} allowedTypes={allowedObjectTypes} setBreadcrumbs={setBreadcrumbs} /> 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 7d43158ad8878..139f9f2e8703d 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 @@ -268,6 +268,12 @@ exports[`SavedObjectsTable should render normally 1`] = ` } canDelete={false} canGoInApp={[Function]} + columnRegistry={ + Object { + "getAll": [MockFunction], + "has": [MockFunction], + } + } filterOptions={ Array [ Object { @@ -351,6 +357,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` }, ] } + onActionRefresh={[Function]} onDelete={[Function]} onExport={[Function]} onQueryChange={[Function]} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap index 9f43ecdf0673a..400b01e89cf7e 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -213,6 +213,10 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` "path": "/home/foo.ndjson", }, "importCount": 0, + "importMode": Object { + "createNewCopies": false, + "overwrite": true, + }, "indexPatterns": Array [ Object { "id": "1", @@ -222,9 +226,9 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` }, ], "isLegacyFile": false, - "isOverwriteAllChecked": true, "loadingMessage": undefined, "status": "loading", + "successfulImports": Array [], "unmatchedReferences": Array [ Object { "existingIndexPatternId": "MyIndexPattern*", @@ -255,66 +259,6 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` } `; -exports[`Flyout conflicts should handle errors 1`] = ` - - } -> -

- -

-

- -`; - -exports[`Flyout errors should display unsupported type errors properly 1`] = ` - - } -> -

- -

-

- wigwags [id=1] unsupported type -

-
-`; - exports[`Flyout legacy conflicts should allow conflict resolution 1`] = ` - + @@ -689,3 +631,10 @@ exports[`Flyout should render import step 1`] = ` `; + +exports[`Flyout summary should display summary when import is complete 1`] = ` + +`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index 56fddc075a50c..67bbb46cfb607 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -164,6 +164,7 @@ exports[`Table prevents saved objects from being deleted 1`] = ` }, ], "name": "Actions", + "width": "80px", }, ] } @@ -379,6 +380,7 @@ exports[`Table should render normally 1`] = ` }, ], "name": "Actions", + "width": "80px", }, ] } diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx index e3bb53f9e48df..32462e1e2184d 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx @@ -85,19 +85,6 @@ describe('Flyout', () => { expect(component).toMatchSnapshot(); }); - it('should toggle the overwrite all control', async () => { - const component = shallowRender(defaultProps); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component.state('isOverwriteAllChecked')).toBe(true); - component.find('EuiSwitch').simulate('change'); - expect(component.state('isOverwriteAllChecked')).toBe(false); - }); - it('should allow picking a file', async () => { const component = shallowRender(defaultProps); @@ -191,7 +178,10 @@ describe('Flyout', () => { component.setState({ file: mockFile, isLegacyFile: false }); await component.instance().import(); - expect(importFileMock).toHaveBeenCalledWith(defaultProps.http, mockFile, true); + expect(importFileMock).toHaveBeenCalledWith(defaultProps.http, mockFile, { + createNewCopies: false, + overwrite: true, + }); expect(component.state()).toMatchObject({ conflictedIndexPatterns: undefined, conflictedSavedObjectsLinkedToSavedSearches: undefined, @@ -242,61 +232,10 @@ describe('Flyout', () => { await new Promise((resolve) => process.nextTick(resolve)); expect(resolveImportErrorsMock).toMatchSnapshot(); }); - - it('should handle errors', async () => { - const component = shallowRender(defaultProps); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - resolveImportErrorsMock.mockImplementation(() => ({ - status: 'success', - importCount: 0, - failedImports: [ - { - obj: { - type: 'visualization', - id: '1', - }, - error: { - type: 'unknown', - }, - }, - ], - })); - - component.setState({ file: mockFile, isLegacyFile: false }); - - // Go through the import flow - await component.instance().import(); - component.update(); - // Set a resolution - component.instance().onIndexChanged('MyIndexPattern*', { target: { value: '2' } }); - await component - .find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]') - .simulate('click'); - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - - expect(component.state('failedImports')).toEqual([ - { - error: { - type: 'unknown', - }, - obj: { - id: '1', - type: 'visualization', - }, - }, - ]); - expect(component.find('EuiFlyoutBody EuiCallOut')).toMatchSnapshot(); - }); }); - describe('errors', () => { - it('should display unsupported type errors properly', async () => { + describe('summary', () => { + it('should display summary when import is complete', async () => { const component = shallowRender(defaultProps); // Ensure all promises resolve @@ -307,32 +246,14 @@ describe('Flyout', () => { importFileMock.mockImplementation(() => ({ success: false, successCount: 0, - errors: [ - { - id: '1', - type: 'wigwags', - title: 'My Title', - error: { - type: 'unsupported_type', - }, - }, - ], })); + const failedImports = Symbol(); + const successfulImports = Symbol(); resolveImportErrorsMock.mockImplementation(() => ({ status: 'success', importCount: 0, - failedImports: [ - { - error: { - type: 'unsupported_type', - }, - obj: { - id: '1', - type: 'wigwags', - title: 'My Title', - }, - }, - ], + failedImports, + successfulImports, })); component.setState({ file: mockFile, isLegacyFile: false }); @@ -345,19 +266,7 @@ describe('Flyout', () => { await Promise.resolve(); expect(component.state('status')).toBe('success'); - expect(component.state('failedImports')).toEqual([ - { - error: { - type: 'unsupported_type', - }, - obj: { - id: '1', - type: 'wigwags', - title: 'My Title', - }, - }, - ]); - expect(component.find('EuiFlyout EuiCallOut')).toMatchSnapshot(); + expect(component.find('EuiFlyout ImportSummary')).toMatchSnapshot(); }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx index aac799da6ea67..9bd08ee38414e 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx @@ -17,7 +17,7 @@ * under the License. */ -import React, { Component, Fragment } from 'react'; +import React, { Component, Fragment, ReactNode } from 'react'; import { take, get as getField } from 'lodash'; import { EuiFlyout, @@ -30,7 +30,6 @@ import { EuiTitle, EuiForm, EuiFormRow, - EuiSwitch, EuiFilePicker, EuiInMemoryTable, EuiSelect, @@ -40,9 +39,6 @@ import { EuiCallOut, EuiSpacer, EuiLink, - EuiConfirmModal, - EuiOverlayMask, - EUI_MODAL_CONFIRM_BUTTON, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -57,7 +53,6 @@ import { importLegacyFile, resolveImportErrors, logLegacyImport, - getDefaultTitle, processImportResponse, ProcessedImportResponse, } from '../../../lib'; @@ -68,6 +63,13 @@ import { saveObjects, } from '../../../lib/resolve_saved_objects'; import { ISavedObjectsManagementServiceRegistry } from '../../../services'; +import { FailedImportConflict, RetryDecision } from '../../../lib/resolve_import_errors'; +import { OverwriteModal } from './overwrite_modal'; +import { ImportModeControl, ImportMode } from './import_mode_control'; +import { ImportSummary } from './import_summary'; + +const CREATE_NEW_COPIES_DEFAULT = false; +const OVERWRITE_ALL_DEFAULT = true; export interface FlyoutProps { serviceRegistry: ISavedObjectsManagementServiceRegistry; @@ -87,22 +89,21 @@ export interface FlyoutState { conflictedSearchDocs?: any[]; unmatchedReferences?: ProcessedImportResponse['unmatchedReferences']; failedImports?: ProcessedImportResponse['failedImports']; + successfulImports?: ProcessedImportResponse['successfulImports']; conflictingRecord?: ConflictingRecord; error?: string; file?: File; importCount: number; indexPatterns?: IIndexPattern[]; - isOverwriteAllChecked: boolean; + importMode: ImportMode; loadingMessage?: string; isLegacyFile: boolean; status: string; } interface ConflictingRecord { - id: string; - type: string; - title: string; - done: (success: boolean) => void; + conflict: FailedImportConflict; + done: (result: [boolean, string | undefined]) => void; } export class Flyout extends Component { @@ -119,7 +120,7 @@ export class Flyout extends Component { file: undefined, importCount: 0, indexPatterns: undefined, - isOverwriteAllChecked: true, + importMode: { createNewCopies: CREATE_NEW_COPIES_DEFAULT, overwrite: OVERWRITE_ALL_DEFAULT }, loadingMessage: undefined, isLegacyFile: false, status: 'idle', @@ -135,10 +136,8 @@ export class Flyout extends Component { this.setState({ indexPatterns } as any); }; - changeOverwriteAll = () => { - this.setState((state) => ({ - isOverwriteAllChecked: !state.isOverwriteAllChecked, - })); + changeImportMode = (importMode: FlyoutState['importMode']) => { + this.setState(() => ({ importMode })); }; setImportFile = (files: FileList | null) => { @@ -160,12 +159,12 @@ export class Flyout extends Component { */ import = async () => { const { http } = this.props; - const { file, isOverwriteAllChecked } = this.state; + const { file, importMode } = this.state; this.setState({ status: 'loading', error: undefined }); // Import the file try { - const response = await importFile(http, file!, isOverwriteAllChecked); + const response = await importFile(http, file!, importMode); this.setState(processImportResponse(response), () => { // Resolve import errors right away if there's no index patterns to match // This will ask about overwriting each object, etc @@ -189,23 +188,24 @@ export class Flyout extends Component { * * Function iterates through the objects, displays a modal for each asking the user if they wish to overwrite it or not. * - * @param {array} objects List of objects to request the user if they wish to overwrite it + * @param {array} failures List of objects to request the user if they wish to overwrite it * @return {Promise} An object with the key being "type:id" and value the resolution chosen by the user */ - getConflictResolutions = async (objects: any[]) => { - const resolutions: Record = {}; - for (const { type, id, title } of objects) { - const overwrite = await new Promise((resolve) => { - this.setState({ - conflictingRecord: { - id, - type, - title, - done: resolve, - }, - }); - }); - resolutions[`${type}:${id}`] = overwrite; + getConflictResolutions = async (failures: FailedImportConflict[]) => { + const resolutions: Record = {}; + for (const conflict of failures) { + const [overwrite, destinationId] = await new Promise<[boolean, string | undefined]>( + (done) => { + this.setState({ conflictingRecord: { conflict, done } }); + } + ); + if (overwrite) { + const { type, id } = conflict.obj; + resolutions[`${type}:${id}`] = { + retry: true, + options: { overwrite: true, ...(destinationId && { destinationId }) }, + }; + } this.setState({ conflictingRecord: undefined }); } return resolutions; @@ -243,7 +243,7 @@ export class Flyout extends Component { legacyImport = async () => { const { serviceRegistry, indexPatterns, overlays, http, allowedTypes } = this.props; - const { file, isOverwriteAllChecked } = this.state; + const { file, importMode } = this.state; this.setState({ status: 'loading', error: undefined }); @@ -295,7 +295,7 @@ export class Flyout extends Component { failedImports, } = await resolveSavedObjects( contents, - isOverwriteAllChecked, + importMode.overwrite, serviceRegistry.all().map((e) => e.service), indexPatterns, overlays.openConfirm @@ -360,7 +360,7 @@ export class Flyout extends Component { confirmLegacyImport = async () => { const { conflictedIndexPatterns, - isOverwriteAllChecked, + importMode, conflictedSavedObjectsLinkedToSavedSearches, conflictedSearchDocs, failedImports, @@ -391,11 +391,8 @@ export class Flyout extends Component { importCount += await resolveIndexPatternConflicts( resolutions, conflictedIndexPatterns!, - isOverwriteAllChecked, - { - indexPatterns, - search, - } + importMode.overwrite, + { indexPatterns, search } ); } this.setState({ @@ -406,7 +403,7 @@ export class Flyout extends Component { }); importCount += await saveObjects( conflictedSavedObjectsLinkedToSavedSearches!, - isOverwriteAllChecked + importMode.overwrite ); this.setState({ loadingMessage: i18n.translate( @@ -418,7 +415,7 @@ export class Flyout extends Component { conflictedSearchDocs!, serviceRegistry.all().map((e) => e.service), indexPatterns, - isOverwriteAllChecked + importMode.overwrite ); this.setState({ loadingMessage: i18n.translate( @@ -428,7 +425,7 @@ export class Flyout extends Component { }); importCount += await saveObjects( failedImports!.map(({ obj }) => obj) as any[], - isOverwriteAllChecked + importMode.overwrite ); } catch (e) { this.setState({ @@ -594,10 +591,11 @@ export class Flyout extends Component { const { status, loadingMessage, - isOverwriteAllChecked, importCount, failedImports = [], + successfulImports = [], isLegacyFile, + importMode, } = this.state; if (status === 'loading') { @@ -614,11 +612,12 @@ export class Flyout extends Component { ); } - // Kept backwards compatible logic - if ( - failedImports.length && - (!this.hasUnmatchedReferences || (isLegacyFile === false && status === 'success')) - ) { + if (isLegacyFile === false && status === 'success') { + return ; + } + + // Import summary for failed legacy import + if (failedImports.length && !this.hasUnmatchedReferences) { return ( { ); } + // Import summary for completed legacy import if (status === 'success') { if (importCount === 0) { return ( @@ -725,6 +725,7 @@ export class Flyout extends Component { return ( { } > { onChange={this.setImportFile} /> - - - } - data-test-subj="importSavedObjectsOverwriteToggle" - checked={isOverwriteAllChecked} - onChange={this.changeOverwriteAll} + + this.changeImportMode(newValues)} /> @@ -909,56 +904,16 @@ export class Flyout extends Component { ); } - overwriteConfirmed() { - this.state.conflictingRecord!.done(true); - } - - overwriteSkipped() { - this.state.conflictingRecord!.done(false); - } - render() { const { close } = this.props; - let confirmOverwriteModal; - if (this.state.conflictingRecord) { - confirmOverwriteModal = ( - - -

- -

-
-
- ); + let confirmOverwriteModal: ReactNode; + const { conflictingRecord } = this.state; + if (conflictingRecord) { + const { conflict } = conflictingRecord; + const onFinish = (overwrite: boolean, destinationId?: string) => + conflictingRecord.done([overwrite, destinationId]); + confirmOverwriteModal = ; } return ( diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_mode_control.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_mode_control.test.tsx new file mode 100644 index 0000000000000..467347d95d1d7 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_mode_control.test.tsx @@ -0,0 +1,91 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { ReactWrapper } from 'enzyme'; +import { shallowWithI18nProvider, mountWithIntl } from 'test_utils/enzyme_helpers'; +import { ImportModeControl, ImportModeControlProps } from './import_mode_control'; + +describe('ImportModeControl', () => { + const initialValues = { createNewCopies: false, overwrite: true }; // some test cases below make assumptions based on these initial values + const updateSelection = jest.fn(); + + const getOverwriteRadio = (wrapper: ReactWrapper) => + wrapper.find( + 'EuiRadioGroup[data-test-subj="savedObjectsManagement-importModeControl-overwriteRadioGroup"]' + ); + const getOverwriteEnabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="overwriteEnabled"]'); + const getOverwriteDisabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="overwriteDisabled"]'); + const getCreateNewCopiesDisabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="createNewCopiesDisabled"]'); + const getCreateNewCopiesEnabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="createNewCopiesEnabled"]'); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const props: ImportModeControlProps = { initialValues, updateSelection, isLegacyFile: false }; + + it('returns partial import mode control when used with a legacy file', async () => { + const wrapper = shallowWithI18nProvider(); + expect(wrapper.find('EuiFormFieldset')).toHaveLength(0); + }); + + it('returns full import mode control when used without a legacy file', async () => { + const wrapper = shallowWithI18nProvider(); + expect(wrapper.find('EuiFormFieldset')).toHaveLength(1); + }); + + it('should allow the user to toggle `overwrite`', async () => { + const wrapper = mountWithIntl(); + + expect(updateSelection).not.toHaveBeenCalled(); + const { createNewCopies } = initialValues; + + getOverwriteDisabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(1, { createNewCopies, overwrite: false }); + + getOverwriteEnabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies, overwrite: true }); + }); + + it('should disable the Overwrite switch when `createNewCopies` is enabled', async () => { + const wrapper = mountWithIntl(); + + expect(getOverwriteRadio(wrapper).prop('disabled')).toBe(false); + getCreateNewCopiesEnabled(wrapper).simulate('change'); + expect(getOverwriteRadio(wrapper).prop('disabled')).toBe(true); + }); + + it('should allow the user to toggle `createNewCopies`', async () => { + const wrapper = mountWithIntl(); + + expect(updateSelection).not.toHaveBeenCalled(); + const { overwrite } = initialValues; + + getCreateNewCopiesEnabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(1, { createNewCopies: true, overwrite }); + + getCreateNewCopiesDisabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies: false, overwrite }); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_mode_control.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_mode_control.tsx new file mode 100644 index 0000000000000..ac8099893d00e --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_mode_control.tsx @@ -0,0 +1,160 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState } from 'react'; +import { + EuiFormFieldset, + EuiTitle, + EuiCheckableCard, + EuiRadioGroup, + EuiText, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export interface ImportModeControlProps { + initialValues: ImportMode; + isLegacyFile: boolean; + updateSelection: (result: ImportMode) => void; +} + +export interface ImportMode { + createNewCopies: boolean; + overwrite: boolean; +} + +const createNewCopiesDisabled = { + id: 'createNewCopiesDisabled', + text: i18n.translate( + 'savedObjectsManagement.objectsTable.importModeControl.createNewCopies.disabledTitle', + { defaultMessage: 'Check for existing objects' } + ), + tooltip: i18n.translate( + 'savedObjectsManagement.objectsTable.importModeControl.createNewCopies.disabledText', + { + defaultMessage: + 'Check if each object was previously copied or imported into the destination space.', + } + ), +}; +const createNewCopiesEnabled = { + id: 'createNewCopiesEnabled', + text: i18n.translate( + 'savedObjectsManagement.objectsTable.importModeControl.createNewCopies.enabledTitle', + { defaultMessage: 'Create new objects with random IDs' } + ), + tooltip: i18n.translate( + 'savedObjectsManagement.objectsTable.importModeControl.createNewCopies.enabledText', + { defaultMessage: 'All imported objects will be created with new random IDs.' } + ), +}; +const overwriteEnabled = { + id: 'overwriteEnabled', + label: i18n.translate( + 'savedObjectsManagement.objectsTable.importModeControl.overwrite.enabledLabel', + { defaultMessage: 'Automatically try to overwrite conflicts' } + ), +}; +const overwriteDisabled = { + id: 'overwriteDisabled', + label: i18n.translate( + 'savedObjectsManagement.objectsTable.importModeControl.overwrite.disabledLabel', + { defaultMessage: 'Request action when conflict occurs' } + ), +}; +const importOptionsTitle = i18n.translate( + 'savedObjectsManagement.objectsTable.importModeControl.importOptionsTitle', + { defaultMessage: 'Import options' } +); + +const createLabel = ({ text, tooltip }: { text: string; tooltip: string }) => ( + + + {text} + + + + + +); + +export const ImportModeControl = ({ + initialValues, + isLegacyFile, + updateSelection, +}: ImportModeControlProps) => { + const [createNewCopies, setCreateNewCopies] = useState(initialValues.createNewCopies); + const [overwrite, setOverwrite] = useState(initialValues.overwrite); + + const onChange = (partial: Partial) => { + if (partial.createNewCopies !== undefined) { + setCreateNewCopies(partial.createNewCopies); + } else if (partial.overwrite !== undefined) { + setOverwrite(partial.overwrite); + } + updateSelection({ createNewCopies, overwrite, ...partial }); + }; + + const overwriteRadio = ( + onChange({ overwrite: id === overwriteEnabled.id })} + disabled={createNewCopies} + data-test-subj={'savedObjectsManagement-importModeControl-overwriteRadioGroup'} + /> + ); + + if (isLegacyFile) { + return overwriteRadio; + } + + return ( + + {importOptionsTitle} + + ), + }} + > + onChange({ createNewCopies: false })} + > + {overwriteRadio} + + + + + onChange({ createNewCopies: true })} + /> + + ); +}; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.scss b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.scss new file mode 100644 index 0000000000000..eb738b71db825 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.scss @@ -0,0 +1,24 @@ +.savedObjectsManagementImportSummary__row { + margin-bottom: $euiSizeXS; +} + +.savedObjectsManagementImportSummary__title { + // Constrains title to the flex item, and allows for truncation when necessary + min-width: 0; +} + +.savedObjectsManagementImportSummary__createdCount { + color: $euiColorSuccessText; +} + +.savedObjectsManagementImportSummary__overwrittenCount { + color: $euiColorWarningText; +} + +.savedObjectsManagementImportSummary__errorCount { + color: $euiColorDangerText; +} + +.savedObjectsManagementImportSummary__icon { + margin-left: $euiSizeXS; +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.test.tsx new file mode 100644 index 0000000000000..ed65131b0fc6b --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.test.tsx @@ -0,0 +1,152 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { ShallowWrapper } from 'enzyme'; +import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; +import { ImportSummary, ImportSummaryProps } from './import_summary'; +import { FailedImport } from '../../../lib'; + +// @ts-expect-error +import { findTestSubject } from '@elastic/eui/lib/test'; + +describe('ImportSummary', () => { + const errorUnsupportedType: FailedImport = { + obj: { type: 'error-obj-type', id: 'error-obj-id', meta: { title: 'Error object' } }, + error: { type: 'unsupported_type' }, + }; + const successNew = { type: 'dashboard', id: 'dashboard-id', meta: { title: 'New' } }; + const successOverwritten = { + type: 'visualization', + id: 'viz-id', + meta: { title: 'Overwritten' }, + overwrite: true, + }; + + const findHeader = (wrapper: ShallowWrapper) => wrapper.find('h3'); + const findCountCreated = (wrapper: ShallowWrapper) => + wrapper.find('h4.savedObjectsManagementImportSummary__createdCount'); + const findCountOverwritten = (wrapper: ShallowWrapper) => + wrapper.find('h4.savedObjectsManagementImportSummary__overwrittenCount'); + const findCountError = (wrapper: ShallowWrapper) => + wrapper.find('h4.savedObjectsManagementImportSummary__errorCount'); + const findObjectRow = (wrapper: ShallowWrapper) => + wrapper.find('.savedObjectsManagementImportSummary__row'); + + it('should render as expected with no results', async () => { + const props: ImportSummaryProps = { failedImports: [], successfulImports: [] }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 0 } }) + ); + expect(findCountCreated(wrapper)).toHaveLength(0); + expect(findCountOverwritten(wrapper)).toHaveLength(0); + expect(findCountError(wrapper)).toHaveLength(0); + expect(findObjectRow(wrapper)).toHaveLength(0); + }); + + it('should render as expected with a newly created object', async () => { + const props: ImportSummaryProps = { + failedImports: [], + successfulImports: [successNew], + }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 1 } }) + ); + const countCreated = findCountCreated(wrapper); + expect(countCreated).toHaveLength(1); + expect(countCreated.childAt(0).props()).toEqual( + expect.objectContaining({ values: { createdCount: 1 } }) + ); + expect(findCountOverwritten(wrapper)).toHaveLength(0); + expect(findCountError(wrapper)).toHaveLength(0); + expect(findObjectRow(wrapper)).toHaveLength(1); + }); + + it('should render as expected with an overwritten object', async () => { + const props: ImportSummaryProps = { + failedImports: [], + successfulImports: [successOverwritten], + }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 1 } }) + ); + expect(findCountCreated(wrapper)).toHaveLength(0); + const countOverwritten = findCountOverwritten(wrapper); + expect(countOverwritten).toHaveLength(1); + expect(countOverwritten.childAt(0).props()).toEqual( + expect.objectContaining({ values: { overwrittenCount: 1 } }) + ); + expect(findCountError(wrapper)).toHaveLength(0); + expect(findObjectRow(wrapper)).toHaveLength(1); + }); + + it('should render as expected with an error object', async () => { + const props: ImportSummaryProps = { + failedImports: [errorUnsupportedType], + successfulImports: [], + }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 1 } }) + ); + expect(findCountCreated(wrapper)).toHaveLength(0); + expect(findCountOverwritten(wrapper)).toHaveLength(0); + const countError = findCountError(wrapper); + expect(countError).toHaveLength(1); + expect(countError.childAt(0).props()).toEqual( + expect.objectContaining({ values: { errorCount: 1 } }) + ); + expect(findObjectRow(wrapper)).toHaveLength(1); + }); + + it('should render as expected with mixed objects', async () => { + const props: ImportSummaryProps = { + failedImports: [errorUnsupportedType], + successfulImports: [successNew, successOverwritten], + }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 3 } }) + ); + const countCreated = findCountCreated(wrapper); + expect(countCreated).toHaveLength(1); + expect(countCreated.childAt(0).props()).toEqual( + expect.objectContaining({ values: { createdCount: 1 } }) + ); + const countOverwritten = findCountOverwritten(wrapper); + expect(countOverwritten).toHaveLength(1); + expect(countOverwritten.childAt(0).props()).toEqual( + expect.objectContaining({ values: { overwrittenCount: 1 } }) + ); + const countError = findCountError(wrapper); + expect(countError).toHaveLength(1); + expect(countError.childAt(0).props()).toEqual( + expect.objectContaining({ values: { errorCount: 1 } }) + ); + expect(findObjectRow(wrapper)).toHaveLength(3); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx new file mode 100644 index 0000000000000..6a6c37a9455b1 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx @@ -0,0 +1,238 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import './import_summary.scss'; +import _ from 'lodash'; +import React, { Fragment } from 'react'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiIcon, + EuiIconTip, + EuiHorizontalRule, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { SavedObjectsImportSuccess } from 'kibana/public'; +import { FailedImport } from '../../..'; +import { getDefaultTitle, getSavedObjectLabel } from '../../../lib'; + +const DEFAULT_ICON = 'apps'; + +export interface ImportSummaryProps { + failedImports: FailedImport[]; + successfulImports: SavedObjectsImportSuccess[]; +} + +interface ImportItem { + type: string; + id: string; + title: string; + icon: string; + outcome: 'created' | 'overwritten' | 'error'; + errorMessage?: string; +} + +const unsupportedTypeErrorMessage = i18n.translate( + 'savedObjectsManagement.objectsTable.importSummary.unsupportedTypeError', + { defaultMessage: 'Unsupported object type' } +); + +const getErrorMessage = ({ error }: FailedImport) => { + if (error.type === 'unknown') { + return error.message; + } else if (error.type === 'unsupported_type') { + return unsupportedTypeErrorMessage; + } +}; + +const mapFailedImport = (failure: FailedImport): ImportItem => { + const { obj } = failure; + const { type, id, meta } = obj; + const title = meta.title || getDefaultTitle(obj); + const icon = meta.icon || DEFAULT_ICON; + const errorMessage = getErrorMessage(failure); + return { type, id, title, icon, outcome: 'error', errorMessage }; +}; + +const mapImportSuccess = (obj: SavedObjectsImportSuccess): ImportItem => { + const { type, id, meta, overwrite } = obj; + const title = meta.title || getDefaultTitle(obj); + const icon = meta.icon || DEFAULT_ICON; + const outcome = overwrite ? 'overwritten' : 'created'; + return { type, id, title, icon, outcome }; +}; + +const getCountIndicators = (importItems: ImportItem[]) => { + if (!importItems.length) { + return null; + } + + const outcomeCounts = importItems.reduce( + (acc, { outcome }) => acc.set(outcome, (acc.get(outcome) ?? 0) + 1), + new Map() + ); + const createdCount = outcomeCounts.get('created'); + const overwrittenCount = outcomeCounts.get('overwritten'); + const errorCount = outcomeCounts.get('error'); + + return ( + + {createdCount && ( + + +

+ +

+
+
+ )} + {overwrittenCount && ( + + +

+ +

+
+
+ )} + {errorCount && ( + + +

+ +

+
+
+ )} +
+ ); +}; + +const getStatusIndicator = ({ outcome, errorMessage }: ImportItem) => { + switch (outcome) { + case 'created': + return ( + + ); + case 'overwritten': + return ( + + ); + case 'error': + return ( + + ); + } +}; + +export const ImportSummary = ({ failedImports, successfulImports }: ImportSummaryProps) => { + const importItems: ImportItem[] = _.sortBy( + [ + ...failedImports.map((x) => mapFailedImport(x)), + ...successfulImports.map((x) => mapImportSuccess(x)), + ], + ['type', 'title'] + ); + + return ( + + +

+ +

+
+ + {getCountIndicators(importItems)} + + {importItems.map((item, index) => { + const { type, title, icon } = item; + return ( + + + + + + + + +

+ {title} +

+
+
+ +
{getStatusIndicator(item)}
+
+
+ ); + })} +
+ ); +}; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.test.tsx new file mode 100644 index 0000000000000..16c6a3340b9f3 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.test.tsx @@ -0,0 +1,109 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { shallowWithI18nProvider, mountWithIntl } from 'test_utils/enzyme_helpers'; +import { OverwriteModalProps, OverwriteModal } from './overwrite_modal'; + +// @ts-expect-error +import { findTestSubject } from '@elastic/eui/lib/test'; + +describe('OverwriteModal', () => { + const obj = { type: 'foo', id: 'bar', meta: { title: 'baz' } }; + const onFinish = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('with a regular conflict', () => { + const props: OverwriteModalProps = { + conflict: { obj, error: { type: 'conflict', destinationId: 'qux' } }, + onFinish, + }; + + it('should render as expected', async () => { + const wrapper = shallowWithI18nProvider(); + + expect(wrapper.find('p').text()).toMatchInlineSnapshot( + `"\\"baz\\" conflicts with an existing object, are you sure you want to overwrite it?"` + ); + expect(wrapper.find('EuiSuperSelect')).toHaveLength(0); + }); + + it('should call onFinish with expected args when Skip is clicked', async () => { + const wrapper = mountWithIntl(); + + expect(onFinish).not.toHaveBeenCalled(); + findTestSubject(wrapper, 'confirmModalCancelButton').simulate('click'); + expect(onFinish).toHaveBeenCalledWith(false); + }); + + it('should call onFinish with expected args when Overwrite is clicked', async () => { + const wrapper = mountWithIntl(); + + expect(onFinish).not.toHaveBeenCalled(); + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + expect(onFinish).toHaveBeenCalledWith(true, 'qux'); + }); + }); + + describe('with an ambiguous conflict', () => { + const props: OverwriteModalProps = { + conflict: { + obj, + error: { + type: 'ambiguous_conflict', + destinations: [ + // TODO: change one of these to have an actual `updatedAt` date string, and mock Moment for the snapshot below + { id: 'qux', title: 'some title', updatedAt: undefined }, + { id: 'quux', title: 'another title', updatedAt: undefined }, + ], + }, + }, + onFinish, + }; + + it('should render as expected', async () => { + const wrapper = shallowWithI18nProvider(); + + expect(wrapper.find('p').text()).toMatchInlineSnapshot( + `"\\"baz\\" conflicts with multiple existing objects, do you want to overwrite one of them?"` + ); + expect(wrapper.find('EuiSuperSelect')).toHaveLength(1); + }); + + it('should call onFinish with expected args when Skip is clicked', async () => { + const wrapper = mountWithIntl(); + + expect(onFinish).not.toHaveBeenCalled(); + findTestSubject(wrapper, 'confirmModalCancelButton').simulate('click'); + expect(onFinish).toHaveBeenCalledWith(false); + }); + + it('should call onFinish with expected args when Overwrite is clicked', async () => { + const wrapper = mountWithIntl(); + + expect(onFinish).not.toHaveBeenCalled(); + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + // first destination is selected by default + expect(onFinish).toHaveBeenCalledWith(true, 'qux'); + }); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.tsx new file mode 100644 index 0000000000000..3f6820ce24fe6 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.tsx @@ -0,0 +1,139 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, Fragment, ReactNode } from 'react'; +import { + EuiOverlayMask, + EuiConfirmModal, + EUI_MODAL_CONFIRM_BUTTON, + EuiText, + EuiSuperSelect, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { FailedImportConflict } from '../../../lib/resolve_import_errors'; +import { getDefaultTitle } from '../../../lib'; + +export interface OverwriteModalProps { + conflict: FailedImportConflict; + onFinish: (overwrite: boolean, destinationId?: string) => void; +} + +export const OverwriteModal = ({ conflict, onFinish }: OverwriteModalProps) => { + const { obj, error } = conflict; + let initialDestinationId: string | undefined; + let selectControl: ReactNode = null; + if (error.type === 'conflict') { + initialDestinationId = error.destinationId; + } else { + // ambiguous conflict must have at least two destinations; default to the first one + initialDestinationId = error.destinations[0].id; + } + const [destinationId, setDestinationId] = useState(initialDestinationId); + + if (error.type === 'ambiguous_conflict') { + const selectProps = { + options: error.destinations.map((destination) => { + const header = destination.title ?? `${type} [id=${destination.id}]`; + const lastUpdated = destination.updatedAt + ? moment(destination.updatedAt).fromNow() + : 'never'; + const idText = `ID: ${destination.id}`; + const lastUpdatedText = `Last updated: ${lastUpdated}`; + return { + value: destination.id, + inputDisplay: destination.id, + dropdownDisplay: ( + + {header} + +

+ {idText} +
+ {lastUpdatedText} +

+
+
+ ), + }; + }), + onChange: (value: string) => { + setDestinationId(value); + }, + }; + selectControl = ( + + ); + } + + const { type, meta } = obj; + const title = meta.title || getDefaultTitle(obj); + const bodyText = + error.type === 'conflict' + ? i18n.translate('savedObjectsManagement.objectsTable.overwriteModal.body.conflict', { + defaultMessage: + '"{title}" conflicts with an existing object, are you sure you want to overwrite it?', + values: { title }, + }) + : i18n.translate( + 'savedObjectsManagement.objectsTable.overwriteModal.body.ambiguousConflict', + { + defaultMessage: + '"{title}" conflicts with multiple existing objects, do you want to overwrite one of them?', + values: { title }, + } + ); + return ( + + onFinish(false)} + onConfirm={() => onFinish(true, destinationId)} + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + maxWidth="500px" + > +

{bodyText}

+ {selectControl} +
+
+ ); +}; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx index 0c7bf64ca011d..7733a587ca9a7 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx @@ -23,11 +23,14 @@ import { findTestSubject } from '@elastic/eui/lib/test'; import { keys } from '@elastic/eui'; import { httpServiceMock } from '../../../../../../core/public/mocks'; import { actionServiceMock } from '../../../services/action_service.mock'; +import { columnServiceMock } from '../../../services/column_service.mock'; +import { SavedObjectsManagementAction } from '../../..'; import { Table, TableProps } from './table'; const defaultProps: TableProps = { basePath: httpServiceMock.createSetupContract().basePath, actionRegistry: actionServiceMock.createStart(), + columnRegistry: columnServiceMock.createStart(), selectedSavedObjects: [ { id: '1', @@ -50,6 +53,7 @@ const defaultProps: TableProps = { }, filterOptions: [{ value: 2 }], onDelete: () => {}, + onActionRefresh: () => {}, onExport: () => {}, goInspectObject: () => {}, canGoInApp: () => true, @@ -122,4 +126,32 @@ describe('Table', () => { expect(component).toMatchSnapshot(); }); + + it(`allows for automatic refreshing after an action`, () => { + const actionRegistry = actionServiceMock.createStart(); + actionRegistry.getAll.mockReturnValue([ + { + // minimal action mock to exercise this test case + id: 'someAction', + render: () =>
action!
, + refreshOnFinish: () => true, + euiAction: { name: 'foo', description: 'bar', icon: 'beaker', type: 'icon' }, + registerOnFinishCallback: (callback: Function) => callback(), // call the callback immediately for this test + } as SavedObjectsManagementAction, + ]); + const onActionRefresh = jest.fn(); + const customizedProps = { ...defaultProps, actionRegistry, onActionRefresh }; + const component = shallowWithI18nProvider(); + + const table = component.find('EuiBasicTable'); + const columns = table.prop('columns') as any[]; + const actionColumn = columns.find((x) => x.hasOwnProperty('actions')) as { actions: any[] }; + const someAction = actionColumn.actions.find( + (x) => x['data-test-subj'] === 'savedObjectsTableAction-someAction' + ); + + expect(onActionRefresh).not.toHaveBeenCalled(); + someAction.onClick(); + expect(onActionRefresh).toHaveBeenCalled(); + }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx index 719729cee2602..0ce7e6e38962a 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -42,11 +42,13 @@ import { SavedObjectWithMetadata } from '../../../types'; import { SavedObjectsManagementActionServiceStart, SavedObjectsManagementAction, + SavedObjectsManagementColumnServiceStart, } from '../../../services'; export interface TableProps { basePath: IBasePath; actionRegistry: SavedObjectsManagementActionServiceStart; + columnRegistry: SavedObjectsManagementColumnServiceStart; selectedSavedObjects: SavedObjectWithMetadata[]; selectionConfig: { onSelectionChange: (selection: SavedObjectWithMetadata[]) => void; @@ -54,6 +56,7 @@ export interface TableProps { filterOptions: any[]; canDelete: boolean; onDelete: () => void; + onActionRefresh: (object: SavedObjectWithMetadata) => void; onExport: (includeReferencesDeep: boolean) => void; goInspectObject: (obj: SavedObjectWithMetadata) => void; pageIndex: number; @@ -74,6 +77,7 @@ interface TableState { isExportPopoverOpen: boolean; isIncludeReferencesDeepChecked: boolean; activeAction?: SavedObjectsManagementAction; + isColumnDataLoaded: boolean; } export class Table extends PureComponent { @@ -83,12 +87,22 @@ export class Table extends PureComponent { isExportPopoverOpen: false, isIncludeReferencesDeepChecked: true, activeAction: undefined, + isColumnDataLoaded: false, }; constructor(props: TableProps) { super(props); } + componentDidMount() { + this.loadColumnData(); + } + + loadColumnData = async () => { + await Promise.all(this.props.columnRegistry.getAll().map((column) => column.loadData())); + this.setState({ isColumnDataLoaded: true }); + }; + onChange = ({ query, error }: any) => { if (error) { this.setState({ @@ -139,12 +153,14 @@ export class Table extends PureComponent { filterOptions, selectionConfig: selection, onDelete, + onActionRefresh, selectedSavedObjects, onTableChange, goInspectObject, onShowRelationships, basePath, actionRegistry, + columnRegistry, } = this.props; const pagination = { @@ -224,10 +240,18 @@ export class Table extends PureComponent { ); }, } as EuiTableFieldDataColumnType>, + ...columnRegistry.getAll().map((column) => { + return { + ...column.euiColumn, + sortable: false, + 'data-test-subj': `savedObjectsTableColumn-${column.id}`, + }; + }), { name: i18n.translate('savedObjectsManagement.objectsTable.table.columnActionsName', { defaultMessage: 'Actions', }), + width: '80px', actions: [ { name: i18n.translate( @@ -274,6 +298,10 @@ export class Table extends PureComponent { this.setState({ activeAction: undefined, }); + const { refreshOnFinish = () => false } = action; + if (refreshOnFinish()) { + onActionRefresh(object); + } }); if (action.euiAction.onClick) { diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 3719dac24e6e7..1bc3dc8066520 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -41,6 +41,7 @@ import { import { dataPluginMock } from '../../../../data/public/mocks'; import { serviceRegistryMock } from '../../services/service_registry.mock'; import { actionServiceMock } from '../../services/action_service.mock'; +import { columnServiceMock } from '../../services/column_service.mock'; import { SavedObjectsTable, SavedObjectsTableProps, @@ -134,6 +135,7 @@ describe('SavedObjectsTable', () => { allowedTypes, serviceRegistry: serviceRegistryMock.create(), actionRegistry: actionServiceMock.createStart(), + columnRegistry: columnServiceMock.createStart(), savedObjectsClient: savedObjects.client, indexPatterns: dataPluginMock.createStartContract().indexPatterns, http, 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 340c0e3237f91..8844fa0ec673c 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 @@ -65,6 +65,7 @@ import { fetchExportObjects, fetchExportByTypeAndSearch, findObjects, + findObject, extractExportDetails, SavedObjectsExportResultDetails, } from '../../lib'; @@ -72,6 +73,7 @@ import { SavedObjectWithMetadata } from '../../types'; import { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementActionServiceStart, + SavedObjectsManagementColumnServiceStart, } from '../../services'; import { Header, Table, Flyout, Relationships } from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; @@ -85,6 +87,7 @@ export interface SavedObjectsTableProps { allowedTypes: string[]; serviceRegistry: ISavedObjectsManagementServiceRegistry; actionRegistry: SavedObjectsManagementActionServiceStart; + columnRegistry: SavedObjectsManagementColumnServiceStart; savedObjectsClient: SavedObjectsClientContract; indexPatterns: IndexPatternsContract; http: HttpStart; @@ -157,7 +160,7 @@ export class SavedObjectsTable extends Component { @@ -202,15 +205,14 @@ export class SavedObjectsTable extends Component { - this.setState( - { - isSearching: true, - }, - this.debouncedFetch - ); + this.setState({ isSearching: true }, this.debouncedFetchObjects); }; - debouncedFetch = debounce(async () => { + fetchSavedObject = (type: string, id: string) => { + this.setState({ isSearching: true }, () => this.debouncedFetchObject(type, id)); + }; + + debouncedFetchObjects = debounce(async () => { const { activeQuery: query, page, perPage } = this.state; const { notifications, http, allowedTypes } = this.props; const { queryText, visibleTypes } = parseQuery(query); @@ -261,10 +263,48 @@ export class SavedObjectsTable extends Component { + debouncedFetchObject = debounce(async (type: string, id: string) => { + const { notifications, http } = this.props; + try { + const resp = await findObject(http, type, id); + if (!this._isMounted) { + return; + } + + this.setState(({ savedObjects, filteredItemCount }) => { + const refreshedSavedObjects = savedObjects.map((object) => + object.type === type && object.id === id ? resp : object + ); + return { + savedObjects: refreshedSavedObjects, + filteredItemCount, + isSearching: false, + }; + }); + } catch (error) { + if (this._isMounted) { + this.setState({ + isSearching: false, + }); + } + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.unableFindSavedObjectNotificationMessage', + { defaultMessage: 'Unable to find saved object' } + ), + text: `${error}`, + }); + } + }, 300); + + refreshObjects = async () => { await Promise.all([this.fetchSavedObjects(), this.fetchCounts()]); }; + refreshObject = async ({ type, id }: SavedObjectWithMetadata) => { + await this.fetchSavedObject(type, id); + }; + onSelectionChanged = (selection: SavedObjectWithMetadata[]) => { this.setState({ selectedSavedObjects: selection }); }; @@ -731,7 +771,7 @@ export class SavedObjectsTable extends Component this.setState({ isShowingExportAllOptionsModal: true })} onImport={this.showImportFlyout} - onRefresh={this.refreshData} + onRefresh={this.refreshObjects} filteredCount={filteredItemCount} /> @@ -740,6 +780,7 @@ export class SavedObjectsTable extends Component void; }) => { const capabilities = coreStart.application.capabilities; @@ -62,6 +65,7 @@ const SavedObjectsTablePage = ({ allowedTypes={allowedTypes} serviceRegistry={serviceRegistry} actionRegistry={actionRegistry} + columnRegistry={columnRegistry} savedObjectsClient={coreStart.savedObjects.client} indexPatterns={dataStart.indexPatterns} search={dataStart.search} diff --git a/src/plugins/saved_objects_management/public/mocks.ts b/src/plugins/saved_objects_management/public/mocks.ts index 1de3de8e85302..3bd5a70884d85 100644 --- a/src/plugins/saved_objects_management/public/mocks.ts +++ b/src/plugins/saved_objects_management/public/mocks.ts @@ -18,12 +18,14 @@ */ import { actionServiceMock } from './services/action_service.mock'; +import { columnServiceMock } from './services/column_service.mock'; import { serviceRegistryMock } from './services/service_registry.mock'; import { SavedObjectsManagementPluginSetup, SavedObjectsManagementPluginStart } from './plugin'; const createSetupContractMock = (): jest.Mocked => { const mock = { actions: actionServiceMock.createSetup(), + columns: columnServiceMock.createSetup(), serviceRegistry: serviceRegistryMock.create(), }; return mock; @@ -32,6 +34,7 @@ const createSetupContractMock = (): jest.Mocked => { const mock = { actions: actionServiceMock.createStart(), + columns: columnServiceMock.createStart(), }; return mock; }; diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index 47d445e63b942..8b7d5f2f99448 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -29,6 +29,9 @@ import { SavedObjectsManagementActionService, SavedObjectsManagementActionServiceSetup, SavedObjectsManagementActionServiceStart, + SavedObjectsManagementColumnService, + SavedObjectsManagementColumnServiceSetup, + SavedObjectsManagementColumnServiceStart, SavedObjectsManagementServiceRegistry, ISavedObjectsManagementServiceRegistry, } from './services'; @@ -36,11 +39,13 @@ import { registerServices } from './register_services'; export interface SavedObjectsManagementPluginSetup { actions: SavedObjectsManagementActionServiceSetup; + columns: SavedObjectsManagementColumnServiceSetup; serviceRegistry: ISavedObjectsManagementServiceRegistry; } export interface SavedObjectsManagementPluginStart { actions: SavedObjectsManagementActionServiceStart; + columns: SavedObjectsManagementColumnServiceStart; } export interface SetupDependencies { @@ -64,6 +69,7 @@ export class SavedObjectsManagementPlugin StartDependencies > { private actionService = new SavedObjectsManagementActionService(); + private columnService = new SavedObjectsManagementColumnService(); private serviceRegistry = new SavedObjectsManagementServiceRegistry(); public setup( @@ -71,6 +77,7 @@ export class SavedObjectsManagementPlugin { home, management }: SetupDependencies ): SavedObjectsManagementPluginSetup { const actionSetup = this.actionService.setup(); + const columnSetup = this.columnService.setup(); home.featureCatalogue.register({ id: 'saved_objects', @@ -109,15 +116,18 @@ export class SavedObjectsManagementPlugin return { actions: actionSetup, + columns: columnSetup, serviceRegistry: this.serviceRegistry, }; } public start(core: CoreStart, { data }: StartDependencies) { const actionStart = this.actionService.start(); + const columnStart = this.columnService.start(); return { actions: actionStart, + columns: columnStart, }; } } diff --git a/src/plugins/saved_objects_management/public/services/column_service.mock.ts b/src/plugins/saved_objects_management/public/services/column_service.mock.ts new file mode 100644 index 0000000000000..977b2099771ba --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/column_service.mock.ts @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + SavedObjectsManagementColumnService, + SavedObjectsManagementColumnServiceSetup, + SavedObjectsManagementColumnServiceStart, +} from './column_service'; + +const createSetupMock = (): jest.Mocked => { + const mock = { + register: jest.fn(), + }; + return mock; +}; + +const createStartMock = (): jest.Mocked => { + const mock = { + has: jest.fn(), + getAll: jest.fn(), + }; + + mock.has.mockReturnValue(true); + mock.getAll.mockReturnValue([]); + + return mock; +}; + +const createServiceMock = (): jest.Mocked> => { + const mock = { + setup: jest.fn().mockReturnValue(createSetupMock()), + start: jest.fn().mockReturnValue(createStartMock()), + }; + return mock; +}; + +export const columnServiceMock = { + create: createServiceMock, + createSetup: createSetupMock, + createStart: createStartMock, +}; diff --git a/src/plugins/saved_objects_management/public/services/column_service.test.ts b/src/plugins/saved_objects_management/public/services/column_service.test.ts new file mode 100644 index 0000000000000..367422b0bbe11 --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/column_service.test.ts @@ -0,0 +1,66 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + SavedObjectsManagementColumnService, + SavedObjectsManagementColumnServiceSetup, +} from './column_service'; +import { SavedObjectsManagementColumn } from './types'; + +class DummyColumn implements SavedObjectsManagementColumn { + constructor(public id: string) {} + + public euiColumn = { + field: 'id', + name: 'name', + }; + + public loadData = async () => {}; +} + +describe('SavedObjectsManagementColumnRegistry', () => { + let service: SavedObjectsManagementColumnService; + let setup: SavedObjectsManagementColumnServiceSetup; + + const createColumn = (id: string): SavedObjectsManagementColumn => { + return new DummyColumn(id); + }; + + beforeEach(() => { + service = new SavedObjectsManagementColumnService(); + setup = service.setup(); + }); + + describe('#register', () => { + it('allows columns to be registered and retrieved', () => { + const column = createColumn('foo'); + setup.register(column); + const start = service.start(); + expect(start.getAll()).toContain(column); + }); + + it('does not allow columns with duplicate ids to be registered', () => { + const column = createColumn('my-column'); + setup.register(column); + expect(() => setup.register(column)).toThrowErrorMatchingInlineSnapshot( + `"Saved Objects Management Column with id 'my-column' already exists"` + ); + }); + }); +}); diff --git a/src/plugins/saved_objects_management/public/services/column_service.ts b/src/plugins/saved_objects_management/public/services/column_service.ts new file mode 100644 index 0000000000000..5006d9df813cf --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/column_service.ts @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectsManagementColumn } from './types'; + +export interface SavedObjectsManagementColumnServiceSetup { + /** + * register given column in the registry. + */ + register: (column: SavedObjectsManagementColumn) => void; +} + +export interface SavedObjectsManagementColumnServiceStart { + /** + * return all {@link SavedObjectsManagementColumn | columns} currently registered. + */ + getAll: () => Array>; +} + +export class SavedObjectsManagementColumnService { + private readonly columns = new Map>(); + + setup(): SavedObjectsManagementColumnServiceSetup { + return { + register: (column) => { + if (this.columns.has(column.id)) { + throw new Error(`Saved Objects Management Column with id '${column.id}' already exists`); + } + this.columns.set(column.id, column); + }, + }; + } + + start(): SavedObjectsManagementColumnServiceStart { + return { + getAll: () => [...this.columns.values()], + }; + } +} diff --git a/src/plugins/saved_objects_management/public/services/index.ts b/src/plugins/saved_objects_management/public/services/index.ts index a59ad9012c402..f3379a3e29702 100644 --- a/src/plugins/saved_objects_management/public/services/index.ts +++ b/src/plugins/saved_objects_management/public/services/index.ts @@ -22,9 +22,18 @@ export { SavedObjectsManagementActionServiceStart, SavedObjectsManagementActionServiceSetup, } from './action_service'; +export { + SavedObjectsManagementColumnService, + SavedObjectsManagementColumnServiceStart, + SavedObjectsManagementColumnServiceSetup, +} from './column_service'; export { SavedObjectsManagementServiceRegistry, ISavedObjectsManagementServiceRegistry, SavedObjectsManagementServiceRegistryEntry, } from './service_registry'; -export { SavedObjectsManagementAction, SavedObjectsManagementRecord } from './types'; +export { + SavedObjectsManagementAction, + SavedObjectsManagementColumn, + SavedObjectsManagementRecord, +} from './types'; diff --git a/src/plugins/saved_objects_management/public/services/types.ts b/src/plugins/saved_objects_management/public/services/types/action.ts similarity index 86% rename from src/plugins/saved_objects_management/public/services/types.ts rename to src/plugins/saved_objects_management/public/services/types/action.ts index c2f807f63b1b9..2ead55d1f4338 100644 --- a/src/plugins/saved_objects_management/public/services/types.ts +++ b/src/plugins/saved_objects_management/public/services/types/action.ts @@ -17,18 +17,8 @@ * under the License. */ -import { ReactNode } from 'react'; -import { SavedObjectReference } from 'src/core/public'; - -export interface SavedObjectsManagementRecord { - type: string; - id: string; - meta: { - icon: string; - title: string; - }; - references: SavedObjectReference[]; -} +import { ReactNode } from '@elastic/eui/node_modules/@types/react'; +import { SavedObjectsManagementRecord } from '.'; export abstract class SavedObjectsManagementAction { public abstract render: () => ReactNode; @@ -43,6 +33,7 @@ export abstract class SavedObjectsManagementAction { onClick?: (item: SavedObjectsManagementRecord) => void; render?: (item: SavedObjectsManagementRecord) => any; }; + public refreshOnFinish?: () => boolean; private callbacks: Function[] = []; diff --git a/src/plugins/saved_objects_management/public/services/types/column.ts b/src/plugins/saved_objects_management/public/services/types/column.ts new file mode 100644 index 0000000000000..79ee4d649177f --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/types/column.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EuiTableFieldDataColumnType } from '@elastic/eui'; +import { SavedObjectsManagementRecord } from '.'; + +export interface SavedObjectsManagementColumn { + id: string; + euiColumn: Omit, 'sortable'>; + + data?: T; + loadData: () => Promise; +} diff --git a/src/plugins/saved_objects_management/public/services/types/index.ts b/src/plugins/saved_objects_management/public/services/types/index.ts new file mode 100644 index 0000000000000..667ba8a683d8d --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/types/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { SavedObjectsManagementAction } from './action'; +export { SavedObjectsManagementColumn } from './column'; +export { SavedObjectsManagementRecord } from './record'; diff --git a/src/plugins/saved_objects_management/public/services/types/record.ts b/src/plugins/saved_objects_management/public/services/types/record.ts new file mode 100644 index 0000000000000..9e00935e674ad --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/types/record.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectReference, SavedObjectsNamespaceType } from 'src/core/public'; + +export interface SavedObjectsManagementRecord { + type: string; + id: string; + meta: { + icon: string; + title: string; + namespaceType: SavedObjectsNamespaceType; + }; + references: SavedObjectReference[]; + namespaces?: string[]; +} 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 0c0f9d8feb506..11e685bd198e4 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 @@ -34,6 +34,7 @@ describe('injectMetaAttributes', () => { path: 'path', uiCapabilitiesPath: 'uiCapabilitiesPath', }); + managementService.getNamespaceType.mockReturnValue('single'); }); it('inject the metadata to the obj', () => { @@ -58,6 +59,7 @@ describe('injectMetaAttributes', () => { path: 'path', uiCapabilitiesPath: 'uiCapabilitiesPath', }, + namespaceType: 'single', }, }); }); 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 615caffd3b60b..54cad2d54e60a 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 @@ -35,6 +35,7 @@ export function injectMetaAttributes( result.meta.title = savedObjectsManagement.getTitle(savedObject); result.meta.editUrl = savedObjectsManagement.getEditUrl(savedObject); result.meta.inAppUrl = savedObjectsManagement.getInAppUrl(savedObject); + result.meta.namespaceType = savedObjectsManagement.getNamespaceType(savedObject); return result; } diff --git a/src/plugins/saved_objects_management/server/routes/get.ts b/src/plugins/saved_objects_management/server/routes/get.ts new file mode 100644 index 0000000000000..a2c12a3970523 --- /dev/null +++ b/src/plugins/saved_objects_management/server/routes/get.ts @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { injectMetaAttributes } from '../lib'; +import { ISavedObjectsManagement } from '../services'; + +export const registerGetRoute = ( + router: IRouter, + managementServicePromise: Promise +) => { + router.get( + { + path: '/api/kibana/management/saved_objects/{type}/{id}', + validate: { + params: schema.object({ + type: schema.string(), + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const managementService = await managementServicePromise; + const { client } = context.core.savedObjects; + + const { type, id } = req.params; + const findResponse = await client.get(type, id); + + const enhancedSavedObject = injectMetaAttributes(findResponse, managementService); + + return res.ok({ body: enhancedSavedObject }); + }) + ); +}; diff --git a/src/plugins/saved_objects_management/server/routes/index.test.ts b/src/plugins/saved_objects_management/server/routes/index.test.ts index 237760444f04e..b39262f0c8b3c 100644 --- a/src/plugins/saved_objects_management/server/routes/index.test.ts +++ b/src/plugins/saved_objects_management/server/routes/index.test.ts @@ -34,7 +34,7 @@ describe('registerRoutes', () => { }); expect(httpSetup.createRouter).toHaveBeenCalledTimes(1); - expect(router.get).toHaveBeenCalledTimes(3); + expect(router.get).toHaveBeenCalledTimes(4); expect(router.post).toHaveBeenCalledTimes(2); expect(router.get).toHaveBeenCalledWith( @@ -43,6 +43,12 @@ describe('registerRoutes', () => { }), expect.any(Function) ); + expect(router.get).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/kibana/management/saved_objects/{type}/{id}', + }), + expect.any(Function) + ); expect(router.get).toHaveBeenCalledWith( expect.objectContaining({ path: '/api/kibana/management/saved_objects/relationships/{type}/{id}', diff --git a/src/plugins/saved_objects_management/server/routes/index.ts b/src/plugins/saved_objects_management/server/routes/index.ts index 0929de56b215e..e074a0d5cbee2 100644 --- a/src/plugins/saved_objects_management/server/routes/index.ts +++ b/src/plugins/saved_objects_management/server/routes/index.ts @@ -20,6 +20,7 @@ import { HttpServiceSetup } from 'src/core/server'; import { ISavedObjectsManagement } from '../services'; import { registerFindRoute } from './find'; +import { registerGetRoute } from './get'; import { registerScrollForCountRoute } from './scroll_count'; import { registerScrollForExportRoute } from './scroll_export'; import { registerRelationshipsRoute } from './relationships'; @@ -33,6 +34,7 @@ interface RegisterRouteOptions { export function registerRoutes({ http, managementServicePromise }: RegisterRouteOptions) { const router = http.createRouter(); registerFindRoute(router, managementServicePromise); + registerGetRoute(router, managementServicePromise); registerScrollForCountRoute(router); registerScrollForExportRoute(router); registerRelationshipsRoute(router, managementServicePromise); 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 2099cc0f77bcc..85c2d3e4b08d9 100644 --- a/src/plugins/saved_objects_management/server/services/management.mock.ts +++ b/src/plugins/saved_objects_management/server/services/management.mock.ts @@ -28,6 +28,7 @@ const createManagementMock = () => { getTitle: jest.fn(), getEditUrl: jest.fn(), getInAppUrl: jest.fn(), + getNamespaceType: jest.fn(), }; return mocked; }; diff --git a/src/plugins/saved_objects_management/server/services/management.test.ts b/src/plugins/saved_objects_management/server/services/management.test.ts index 3625a3f913444..7ddde312767de 100644 --- a/src/plugins/saved_objects_management/server/services/management.test.ts +++ b/src/plugins/saved_objects_management/server/services/management.test.ts @@ -198,4 +198,28 @@ describe('SavedObjectsManagement', () => { expect(result).toEqual({ path: 'called', uiCapabilitiesPath: 'my.path' }); }); }); + + describe('getNamespaceType()', () => { + it('returns empty for unknown type', () => { + const result = management.getNamespaceType({ + id: '1', + type: 'foo', + attributes: {}, + references: [], + }); + expect(result).toEqual(undefined); + }); + + it('returns explicit value', () => { + registerType({ name: 'foo', namespaceType: 'single' }); + + const result = management.getNamespaceType({ + id: '1', + type: 'foo', + attributes: {}, + references: [], + }); + expect(result).toEqual('single'); + }); + }); }); diff --git a/src/plugins/saved_objects_management/server/services/management.ts b/src/plugins/saved_objects_management/server/services/management.ts index 7aee974182497..499f37990c346 100644 --- a/src/plugins/saved_objects_management/server/services/management.ts +++ b/src/plugins/saved_objects_management/server/services/management.ts @@ -50,4 +50,8 @@ export class SavedObjectsManagement { const getInAppUrl = this.registry.getType(savedObject.type)?.management?.getInAppUrl; return getInAppUrl ? getInAppUrl(savedObject) : undefined; } + + public getNamespaceType(savedObject: SavedObject) { + return this.registry.getType(savedObject.type)?.namespaceType; + } } diff --git a/test/api_integration/apis/saved_objects/import.js b/test/api_integration/apis/saved_objects/import.js index fe0849eb7bcab..1666df2c83e5a 100644 --- a/test/api_integration/apis/saved_objects/import.js +++ b/test/api_integration/apis/saved_objects/import.js @@ -25,30 +25,33 @@ export default function ({ getService }) { const esArchiver = getService('esArchiver'); describe('import', () => { + // mock success results including metadata + const indexPattern = { + type: 'index-pattern', + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + meta: { title: 'logstash-*', icon: 'indexPatternApp' }, + }; + const visualization = { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + meta: { title: 'Count of requests', icon: 'visualizeApp' }, + }; + const dashboard = { + type: 'dashboard', + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + meta: { title: 'Requests', icon: 'dashboardApp' }, + }; + const createError = (object, type) => ({ + ...object, + title: object.meta.title, + error: { type }, + }); + describe('with kibana index', () => { describe('with basic data existing', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); - it('should return 200', async () => { - await supertest - .post('/api/saved_objects/_import') - .query({ overwrite: true }) - .attach('file', join(__dirname, '../../fixtures/import.ndjson')) - .expect(200) - .then((resp) => { - expect(resp.body).to.eql({ - success: true, - successCount: 3, - successResults: [ - { type: 'index-pattern', id: '91200a00-9efd-11e7-acb3-3dab96693fab' }, - { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab' }, - { type: 'dashboard', id: 'be3733a0-9efe-11e7-acb3-3dab96693fab' }, - ], - }); - }); - }); - it('should return 415 when no file passed in', async () => { await supertest .post('/api/saved_objects/_import') @@ -72,30 +75,9 @@ export default function ({ getService }) { success: false, successCount: 0, errors: [ - { - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - type: 'index-pattern', - title: 'logstash-*', - error: { - type: 'conflict', - }, - }, - { - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - type: 'visualization', - title: 'Count of requests', - error: { - type: 'conflict', - }, - }, - { - id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', - type: 'dashboard', - title: 'Requests', - error: { - type: 'conflict', - }, - }, + createError(indexPattern, 'conflict'), + createError(visualization, 'conflict'), + createError(dashboard, 'conflict'), ], }); }); @@ -104,9 +86,7 @@ export default function ({ getService }) { it('should return 200 when conflicts exist but overwrite is passed in', async () => { await supertest .post('/api/saved_objects/_import') - .query({ - overwrite: true, - }) + .query({ overwrite: true }) .attach('file', join(__dirname, '../../fixtures/import.ndjson')) .expect(200) .then((resp) => { @@ -114,9 +94,9 @@ export default function ({ getService }) { success: true, successCount: 3, successResults: [ - { type: 'index-pattern', id: '91200a00-9efd-11e7-acb3-3dab96693fab' }, - { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab' }, - { type: 'dashboard', id: 'be3733a0-9efe-11e7-acb3-3dab96693fab' }, + { ...indexPattern, overwrite: true }, + { ...visualization, overwrite: true }, + { ...dashboard, overwrite: true }, ], }); }); @@ -140,9 +120,8 @@ export default function ({ getService }) { id: '1', type: 'wigwags', title: 'my title', - error: { - type: 'unsupported_type', - }, + meta: { title: 'my title' }, + error: { type: 'unsupported_type' }, }, ], }); @@ -172,7 +151,7 @@ export default function ({ getService }) { JSON.stringify({ type: 'visualization', id: '1', - attributes: {}, + attributes: { title: 'My visualization' }, references: [ { name: 'ref_0', @@ -199,9 +178,10 @@ export default function ({ getService }) { { type: 'visualization', id: '1', + title: 'My visualization', + meta: { title: 'My visualization', icon: 'visualizeApp' }, error: { type: 'missing_references', - blocking: [], references: [ { type: 'index-pattern', diff --git a/test/api_integration/apis/saved_objects/resolve_import_errors.js b/test/api_integration/apis/saved_objects/resolve_import_errors.js index 093bfc74c60d4..5380e9c3d11d8 100644 --- a/test/api_integration/apis/saved_objects/resolve_import_errors.js +++ b/test/api_integration/apis/saved_objects/resolve_import_errors.js @@ -25,6 +25,23 @@ export default function ({ getService }) { const esArchiver = getService('esArchiver'); describe('resolve_import_errors', () => { + // mock success results including metadata + const indexPattern = { + type: 'index-pattern', + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + meta: { title: 'logstash-*', icon: 'indexPatternApp' }, + }; + const visualization = { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + meta: { title: 'Count of requests', icon: 'visualizeApp' }, + }; + const dashboard = { + type: 'dashboard', + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + meta: { title: 'Requests', icon: 'dashboardApp' }, + }; + describe('without kibana index', () => { // Cleanup data that got created in import after(() => esArchiver.unload('saved_objects/basic')); @@ -73,9 +90,9 @@ export default function ({ getService }) { success: true, successCount: 3, successResults: [ - { type: 'index-pattern', id: '91200a00-9efd-11e7-acb3-3dab96693fab' }, - { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab' }, - { type: 'dashboard', id: 'be3733a0-9efe-11e7-acb3-3dab96693fab' }, + { ...indexPattern, overwrite: true }, + { ...visualization, overwrite: true }, + { ...dashboard, overwrite: true }, ], }); }); @@ -114,9 +131,8 @@ export default function ({ getService }) { id: '1', type: 'wigwags', title: 'my title', - error: { - type: 'unsupported_type', - }, + meta: { title: 'my title' }, + error: { type: 'unsupported_type' }, }, ], }); @@ -180,9 +196,9 @@ export default function ({ getService }) { id: '1', type: 'visualization', title: 'My favorite vis', + meta: { title: 'My favorite vis', icon: 'visualizeApp' }, error: { type: 'missing_references', - blocking: [], references: [ { type: 'index-pattern', @@ -243,9 +259,9 @@ export default function ({ getService }) { success: true, successCount: 3, successResults: [ - { type: 'index-pattern', id: '91200a00-9efd-11e7-acb3-3dab96693fab' }, - { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab' }, - { type: 'dashboard', id: 'be3733a0-9efe-11e7-acb3-3dab96693fab' }, + { ...indexPattern, overwrite: true }, + { ...visualization, overwrite: true }, + { ...dashboard, overwrite: true }, ], }); }); @@ -270,9 +286,7 @@ export default function ({ getService }) { expect(resp.body).to.eql({ success: true, successCount: 1, - successResults: [ - { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab' }, - ], + successResults: [{ ...visualization, overwrite: true }], }); }); }); @@ -317,7 +331,13 @@ export default function ({ getService }) { expect(resp.body).to.eql({ success: true, successCount: 1, - successResults: [{ type: 'visualization', id: '1' }], + successResults: [ + { + type: 'visualization', + id: '1', + meta: { title: 'My favorite vis', icon: 'visualizeApp' }, + }, + ], }); }); await supertest diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index 08c4327d7c0c4..c1c78570d8fe1 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -68,6 +68,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, title: 'Count of requests', + namespaceType: 'single', }, }, ], @@ -225,6 +226,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }); })); @@ -243,6 +245,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', }, + namespaceType: 'single', }); })); @@ -261,6 +264,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }); expect(resp.body.saved_objects[1].meta).to.eql({ icon: 'visualizeApp', @@ -271,6 +275,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }); })); @@ -290,6 +295,7 @@ export default function ({ getService }: FtrProviderContext) { '/app/management/kibana/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.kibana.indexPatterns', }, + namespaceType: 'single', }); })); }); diff --git a/test/api_integration/apis/saved_objects_management/get.ts b/test/api_integration/apis/saved_objects_management/get.ts new file mode 100644 index 0000000000000..8eb4cd7ab9a43 --- /dev/null +++ b/test/api_integration/apis/saved_objects_management/get.ts @@ -0,0 +1,69 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; +import { Response } from 'supertest'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const es = getService('legacyEs'); + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('get', () => { + const existingObject = 'visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab'; + const nonexistentObject = 'wigwags/foo'; + + describe('with kibana index', () => { + before(() => esArchiver.load('saved_objects/basic')); + after(() => esArchiver.unload('saved_objects/basic')); + + it('should return 200 for object that exists and inject metadata', async () => + await supertest + .get(`/api/kibana/management/saved_objects/${existingObject}`) + .expect(200) + .then((resp: Response) => { + const { body } = resp; + const { type, id, meta } = body; + expect(type).to.eql('visualization'); + expect(id).to.eql('dd7caf20-9efd-11e7-acb3-3dab96693fab'); + expect(meta).to.not.equal(undefined); + })); + + it('should return 404 for object that does not exist', async () => + await supertest + .get(`/api/kibana/management/saved_objects/${nonexistentObject}`) + .expect(404)); + }); + + describe('without kibana index', () => { + before( + async () => + // just in case the kibana server has recreated it + await es.indices.delete({ + index: '.kibana', + ignore: [404], + }) + ); + + it('should return 404 for object that no longer exists', async () => + await supertest.get(`/api/kibana/management/saved_objects/${existingObject}`).expect(404)); + }); + }); +} diff --git a/test/api_integration/apis/saved_objects_management/index.ts b/test/api_integration/apis/saved_objects_management/index.ts index 9f13e4fc5975d..a5db29a6200f3 100644 --- a/test/api_integration/apis/saved_objects_management/index.ts +++ b/test/api_integration/apis/saved_objects_management/index.ts @@ -22,6 +22,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('saved objects management apis', () => { loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./relationships')); loadTestFile(require.resolve('./scroll_count')); }); diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts index a1ea65645c13f..8b7837f80ee44 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -38,6 +38,7 @@ export default function ({ getService }: FtrProviderContext) { path: schema.string(), uiCapabilitiesPath: schema.string(), }), + namespaceType: schema.string(), }), }) ); @@ -89,6 +90,7 @@ export default function ({ getService }: FtrProviderContext) { '/app/management/kibana/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.kibana.indexPatterns', }, + namespaceType: 'single', }, }, { @@ -104,6 +106,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, }, ]); @@ -130,6 +133,7 @@ export default function ({ getService }: FtrProviderContext) { '/app/management/kibana/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.kibana.indexPatterns', }, + namespaceType: 'single', }, relationship: 'child', }, @@ -145,6 +149,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, relationship: 'parent', }, @@ -189,6 +194,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, }, { @@ -204,6 +210,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, }, ]); @@ -227,6 +234,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, relationship: 'child', }, @@ -242,6 +250,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, relationship: 'child', }, @@ -286,6 +295,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }, }, { @@ -301,6 +311,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', }, + namespaceType: 'single', }, }, ]); @@ -326,6 +337,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }, relationship: 'child', }, @@ -369,6 +381,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }, }, { @@ -384,6 +397,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, }, ]); @@ -409,6 +423,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }, relationship: 'parent', }, diff --git a/test/functional/page_objects/management/saved_objects_page.ts b/test/functional/page_objects/management/saved_objects_page.ts index ad82ea9b6fbc1..e165341dbd63d 100644 --- a/test/functional/page_objects/management/saved_objects_page.ts +++ b/test/functional/page_objects/management/saved_objects_page.ts @@ -48,7 +48,13 @@ export function SavedObjectsPageProvider({ getService, getPageObjects }: FtrProv if (!overwriteAll) { log.debug(`Toggling overwriteAll`); - await testSubjects.click('importSavedObjectsOverwriteToggle'); + const radio = await testSubjects.find( + 'savedObjectsManagement-importModeControl-overwriteRadioGroup' + ); + // a radio button consists of a div tag that contains an input, a div, and a label + // we can't click the input directly, need to go up one level and click the parent div + const div = await radio.findByXpath("//div[input[@id='overwriteDisabled']]"); + await div.click(); } else { log.debug(`Leaving overwriteAll alone`); } diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index bfbc8b68c3d2c..e4014cf49778c 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -323,6 +323,7 @@ Array [ "edit", "delete", "copyIntoSpace", + "shareIntoSpace", ], }, "privilegeId": "all", diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 9df042b45a32e..e37c7491de5dc 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -349,7 +349,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS all: [...savedObjectTypes], read: [], }, - ui: ['read', 'edit', 'delete', 'copyIntoSpace'], + ui: ['read', 'edit', 'delete', 'copyIntoSpace', 'shareIntoSpace'], }, read: { app: ['kibana'], diff --git a/x-pack/plugins/spaces/common/model/types.ts b/x-pack/plugins/spaces/common/model/types.ts index 30004c739ee7a..aad77f2bbcef9 100644 --- a/x-pack/plugins/spaces/common/model/types.ts +++ b/x-pack/plugins/spaces/common/model/types.ts @@ -4,4 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export type GetSpacePurpose = 'any' | 'copySavedObjectsIntoSpace' | 'findSavedObjects'; +export type GetSpacePurpose = + | 'any' + | 'copySavedObjectsIntoSpace' + | 'findSavedObjects' + | 'shareSavedObjectsIntoSpace'; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.test.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.test.tsx new file mode 100644 index 0000000000000..4e49a2da3e534 --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ReactWrapper } from 'enzyme'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { CopyModeControl, CopyModeControlProps } from './copy_mode_control'; + +describe('CopyModeControl', () => { + const initialValues = { createNewCopies: false, overwrite: true }; // some test cases below make assumptions based on these initial values + const updateSelection = jest.fn(); + + const getOverwriteRadio = (wrapper: ReactWrapper) => + wrapper.find('EuiRadioGroup[data-test-subj="cts-copyModeControl-overwriteRadioGroup"]'); + const getOverwriteEnabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="overwriteEnabled"]'); + const getOverwriteDisabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="overwriteDisabled"]'); + const getCreateNewCopiesDisabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="createNewCopiesDisabled"]'); + const getCreateNewCopiesEnabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="createNewCopiesEnabled"]'); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const props: CopyModeControlProps = { initialValues, updateSelection }; + + it('should allow the user to toggle `overwrite`', async () => { + const wrapper = mountWithIntl(); + + expect(updateSelection).not.toHaveBeenCalled(); + const { createNewCopies } = initialValues; + + getOverwriteDisabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(1, { createNewCopies, overwrite: false }); + + getOverwriteEnabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies, overwrite: true }); + }); + + it('should disable the Overwrite switch when `createNewCopies` is enabled', async () => { + const wrapper = mountWithIntl(); + + expect(getOverwriteRadio(wrapper).prop('disabled')).toBe(false); + getCreateNewCopiesEnabled(wrapper).simulate('change'); + expect(getOverwriteRadio(wrapper).prop('disabled')).toBe(true); + }); + + it('should allow the user to toggle `createNewCopies`', async () => { + const wrapper = mountWithIntl(); + + expect(updateSelection).not.toHaveBeenCalled(); + const { overwrite } = initialValues; + + getCreateNewCopiesEnabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(1, { createNewCopies: true, overwrite }); + + getCreateNewCopiesDisabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies: false, overwrite }); + }); +}); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.tsx new file mode 100644 index 0000000000000..42fbf8954396e --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.tsx @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { + EuiFormFieldset, + EuiTitle, + EuiCheckableCard, + EuiRadioGroup, + EuiText, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export interface CopyModeControlProps { + initialValues: CopyMode; + updateSelection: (result: CopyMode) => void; +} + +export interface CopyMode { + createNewCopies: boolean; + overwrite: boolean; +} + +const createNewCopiesDisabled = { + id: 'createNewCopiesDisabled', + text: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.createNewCopies.disabledTitle', + { defaultMessage: 'Check for existing objects' } + ), + tooltip: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.createNewCopies.disabledText', + { + defaultMessage: + 'Check if each object was previously copied or imported into the destination space.', + } + ), +}; +const createNewCopiesEnabled = { + id: 'createNewCopiesEnabled', + text: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.createNewCopies.enabledTitle', + { defaultMessage: 'Create new objects with random IDs' } + ), + tooltip: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.createNewCopies.enabledText', + { defaultMessage: 'All copied objects will be created with new random IDs.' } + ), +}; +const overwriteEnabled = { + id: 'overwriteEnabled', + label: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.overwrite.enabledLabel', + { defaultMessage: 'Automatically try to overwrite conflicts' } + ), +}; +const overwriteDisabled = { + id: 'overwriteDisabled', + label: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.overwrite.disabledLabel', + { defaultMessage: 'Request action when conflict occurs' } + ), +}; +const includeRelated = { + id: 'includeRelated', + text: i18n.translate('xpack.spaces.management.copyToSpace.copyModeControl.includeRelated.title', { + defaultMessage: 'Include related saved objects', + }), + tooltip: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.includeRelated.text', + { + defaultMessage: + 'This will copy any other objects this has references to -- for example, a dashboard may have references to multiple visualizations.', + } + ), +}; +const copyOptionsTitle = i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.copyOptionsTitle', + { defaultMessage: 'Copy options' } +); +const relationshipOptionsTitle = i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.relationshipOptionsTitle', + { defaultMessage: 'Relationship options' } +); + +const createLabel = ({ text, tooltip }: { text: string; tooltip: string }) => ( + + + {text} + + + + + +); + +export const CopyModeControl = ({ initialValues, updateSelection }: CopyModeControlProps) => { + const [createNewCopies, setCreateNewCopies] = useState(initialValues.createNewCopies); + const [overwrite, setOverwrite] = useState(initialValues.overwrite); + + const onChange = (partial: Partial) => { + if (partial.createNewCopies !== undefined) { + setCreateNewCopies(partial.createNewCopies); + } else if (partial.overwrite !== undefined) { + setOverwrite(partial.overwrite); + } + updateSelection({ createNewCopies, overwrite, ...partial }); + }; + + return ( + <> + + {copyOptionsTitle} + + ), + }} + > + onChange({ createNewCopies: false })} + > + onChange({ overwrite: id === overwriteEnabled.id })} + disabled={createNewCopies} + data-test-subj={'cts-copyModeControl-overwriteRadioGroup'} + /> + + + + + onChange({ createNewCopies: true })} + /> + + + + + + {relationshipOptionsTitle} + + ), + }} + > + {}} // noop + disabled + /> + + + ); +}; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_indicator.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_indicator.tsx index 62f9503443951..55457e07e7ad5 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_indicator.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_indicator.tsx @@ -4,20 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { EuiLoadingSpinner, EuiText, EuiIconTip } from '@elastic/eui'; +import React, { Fragment } from 'react'; +import { EuiLoadingSpinner, EuiIconTip, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { ImportRetry } from '../types'; import { SummarizedCopyToSpaceResult, SummarizedSavedObjectResult } from '..'; interface Props { summarizedCopyResult: SummarizedCopyToSpaceResult; object: { type: string; id: string }; - overwritePending: boolean; + pendingObjectRetry?: ImportRetry; conflictResolutionInProgress: boolean; } export const CopyStatusIndicator = (props: Props) => { - const { summarizedCopyResult, conflictResolutionInProgress } = props; + const { summarizedCopyResult, conflictResolutionInProgress, pendingObjectRetry } = props; if (summarizedCopyResult.processing || conflictResolutionInProgress) { return ; } @@ -25,28 +26,51 @@ export const CopyStatusIndicator = (props: Props) => { const objectResult = summarizedCopyResult.objects.find( (o) => o.type === props.object!.type && o.id === props.object!.id ) as SummarizedSavedObjectResult; + const { conflict, hasMissingReferences, hasUnresolvableErrors, overwrite } = objectResult; + const hasConflicts = conflict && !pendingObjectRetry?.overwrite; + const successful = !hasMissingReferences && !hasUnresolvableErrors && !hasConflicts; - const successful = - !objectResult.hasUnresolvableErrors && - (objectResult.conflicts.length === 0 || props.overwritePending === true); - const successColor = props.overwritePending ? 'warning' : 'success'; - const hasConflicts = objectResult.conflicts.length > 0; - const hasUnresolvableErrors = objectResult.hasUnresolvableErrors; - - if (successful) { - const message = props.overwritePending ? ( + if (successful && !pendingObjectRetry) { + // there is no retry pending, so this object was actually copied + const message = overwrite ? ( + // the object was overwritten ) : ( + // the object was not overwritten ); - return ; + return ; + } + + if (successful && pendingObjectRetry) { + const message = overwrite ? ( + // this is an "automatic overwrite", e.g., the "Overwrite all conflicts" option was selected + + ) : pendingObjectRetry?.overwrite ? ( + // this is a manual overwrite, e.g., the individual "Overwrite?" switch was enabled + + ) : ( + // this object is pending success, but it will not result in an overwrite + + ); + return ; } + if (hasUnresolvableErrors) { return ( { /> ); } + if (hasConflicts) { return ( -

- -

-

- -

- + + + + + } /> ); } - return null; + + return hasMissingReferences ? ( + + ) : conflict ? ( + + ) : ( + + ) + } + /> + ) : null; }; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.scss b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.scss new file mode 100644 index 0000000000000..d1c3cbbd2b6af --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.scss @@ -0,0 +1,7 @@ +.spcCopyToSpace__summaryCountBadge { + margin-left: $euiSizeXS; +} + +.spcCopyToSpace__missingReferencesIcon { + margin-left: $euiSizeXS; +} diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.tsx index 9d73c216c73ce..bcd21674b95d9 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.tsx @@ -4,27 +4,47 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { EuiLoadingSpinner, EuiIconTip } from '@elastic/eui'; +import './copy_status_summary_indicator.scss'; +import React, { Fragment } from 'react'; +import { EuiLoadingSpinner, EuiIconTip, EuiBadge } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Space } from '../../../common/model/space'; +import { ImportRetry } from '../types'; +import { ResolveAllConflicts } from './resolve_all_conflicts'; import { SummarizedCopyToSpaceResult } from '..'; interface Props { space: Space; summarizedCopyResult: SummarizedCopyToSpaceResult; conflictResolutionInProgress: boolean; + retries: ImportRetry[]; + onRetriesChange: (retries: ImportRetry[]) => void; + onDestinationMapChange: (value?: Map) => void; } -export const CopyStatusSummaryIndicator = (props: Props) => { - const { summarizedCopyResult } = props; - const getDataTestSubj = (status: string) => `cts-summary-indicator-${status}-${props.space.id}`; +const renderIcon = (props: Props) => { + const { + space, + summarizedCopyResult, + conflictResolutionInProgress, + retries, + onRetriesChange, + onDestinationMapChange, + } = props; + const getDataTestSubj = (status: string) => `cts-summary-indicator-${status}-${space.id}`; - if (summarizedCopyResult.processing || props.conflictResolutionInProgress) { + if (summarizedCopyResult.processing || conflictResolutionInProgress) { return ; } - if (summarizedCopyResult.successful) { + const { + successful, + hasUnresolvableErrors, + hasMissingReferences, + hasConflicts, + } = summarizedCopyResult; + + if (successful) { return ( { } /> ); } - if (summarizedCopyResult.hasUnresolvableErrors) { + + if (hasUnresolvableErrors) { return ( { } /> ); } - if (summarizedCopyResult.hasConflicts) { - return ( + + const missingReferences = hasMissingReferences ? ( + } /> + + ) : null; + + if (hasConflicts) { + return ( + + + + } + /> + {missingReferences} + ); } - return null; + + return missingReferences; +}; + +export const CopyStatusSummaryIndicator = (props: Props) => { + const { summarizedCopyResult } = props; + + return ( + + {renderIcon(props)} + + {summarizedCopyResult.objects.length} + + + ); }; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx index 99b4e184c071a..972b166afa6ae 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx @@ -17,6 +17,7 @@ import { ProcessingCopyToSpace } from './processing_copy_to_space'; import { spacesManagerMock } from '../../spaces_manager/mocks'; import { SpacesManager } from '../../spaces_manager'; import { ToastsApi } from 'src/core/public'; +import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; interface SetupOpts { mockSpaces?: Space[]; @@ -73,8 +74,8 @@ const setup = async (opts: SetupOpts = {}) => { name: 'My Viz', }, ], - meta: { icon: 'dashboard', title: 'foo' }, - }; + meta: { icon: 'dashboard', title: 'foo', namespaceType: 'single' }, + } as SavedObjectsManagementRecord; const wrapper = mountWithIntl( { type: 'index-pattern', id: 'conflicting-ip', error: { type: 'conflict' }, + meta: {}, }, { type: 'visualization', id: 'my-viz', error: { type: 'conflict' }, + meta: {}, }, ], }, @@ -223,8 +226,12 @@ describe('CopyToSpaceFlyout', () => { const spaceResult = findTestSubject(wrapper, `cts-space-result-space-2`); spaceResult.simulate('click'); - const overwriteButton = findTestSubject(wrapper, `cts-overwrite-conflict-conflicting-ip`); - overwriteButton.simulate('click'); + const overwriteSwitch = findTestSubject( + wrapper, + `cts-overwrite-conflict-index-pattern:conflicting-ip` + ); + expect(overwriteSwitch.props()['aria-checked']).toEqual(false); + overwriteSwitch.simulate('click'); const finishButton = findTestSubject(wrapper, 'cts-finish-button'); @@ -282,6 +289,7 @@ describe('CopyToSpaceFlyout', () => { [{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }], ['space-1', 'space-2'], true, + false, true ); @@ -309,21 +317,45 @@ describe('CopyToSpaceFlyout', () => { mockSpacesManager.copySavedObjects.mockResolvedValue({ 'space-1': { success: true, - successCount: 3, + successCount: 5, }, 'space-2': { success: false, successCount: 1, errors: [ + // regular conflict without destinationId { type: 'index-pattern', id: 'conflicting-ip', error: { type: 'conflict' }, + meta: {}, + }, + // regular conflict with destinationId + { + type: 'search', + id: 'conflicting-search', + error: { type: 'conflict', destinationId: 'another-search' }, + meta: {}, + }, + // ambiguous conflict + { + type: 'canvas-workpad', + id: 'conflicting-canvas', + error: { + type: 'ambiguous_conflict', + destinations: [ + { id: 'another-canvas', title: 'foo', updatedAt: undefined }, + { id: 'yet-another-canvas', title: 'bar', updatedAt: undefined }, + ], + }, + meta: {}, }, + // negative test case (skip) { type: 'visualization', id: 'my-viz', error: { type: 'conflict' }, + meta: {}, }, ], }, @@ -358,8 +390,15 @@ describe('CopyToSpaceFlyout', () => { const spaceResult = findTestSubject(wrapper, `cts-space-result-space-2`); spaceResult.simulate('click'); - const overwriteButton = findTestSubject(wrapper, `cts-overwrite-conflict-conflicting-ip`); - overwriteButton.simulate('click'); + [ + 'index-pattern:conflicting-ip', + 'search:conflicting-search', + 'canvas-workpad:conflicting-canvas', + ].forEach((id) => { + const overwriteSwitch = findTestSubject(wrapper, `cts-overwrite-conflict-${id}`); + expect(overwriteSwitch.props()['aria-checked']).toEqual(false); + overwriteSwitch.simulate('click'); + }); const finishButton = findTestSubject(wrapper, 'cts-finish-button'); @@ -372,16 +411,148 @@ describe('CopyToSpaceFlyout', () => { expect(mockSpacesManager.resolveCopySavedObjectsErrors).toHaveBeenCalledWith( [{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }], { - 'space-2': [{ type: 'index-pattern', id: 'conflicting-ip', overwrite: true }], + 'space-1': [], + 'space-2': [ + { type: 'index-pattern', id: 'conflicting-ip', overwrite: true }, + { + type: 'search', + id: 'conflicting-search', + overwrite: true, + destinationId: 'another-search', + }, + { + type: 'canvas-workpad', + id: 'conflicting-canvas', + overwrite: true, + destinationId: 'another-canvas', + }, + ], }, - true + true, + false + ); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + }); + + it('displays a warning when missing references are encountered', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToCopy, + } = await setup(); + + mockSpacesManager.copySavedObjects.mockResolvedValue({ + 'space-1': { + success: false, + successCount: 1, + errors: [ + // my-viz-1 just has a missing_references error + { + type: 'visualization', + id: 'my-viz-1', + error: { + type: 'missing_references', + references: [{ type: 'index-pattern', id: 'missing-index-pattern' }], + }, + meta: {}, + }, + // my-viz-2 has both a missing_references error and a conflict error + { + type: 'visualization', + id: 'my-viz-2', + error: { + type: 'missing_references', + references: [{ type: 'index-pattern', id: 'missing-index-pattern' }], + }, + meta: {}, + }, + { + type: 'visualization', + id: 'my-viz-2', + error: { type: 'conflict' }, + meta: {}, + }, + ], + successResults: [{ type: savedObjectToCopy.type, id: savedObjectToCopy.id, meta: {} }], + }, + }); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-1']); + }); + + const startButton = findTestSubject(wrapper, 'cts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(CopyToSpaceForm)).toHaveLength(0); + expect(wrapper.find(ProcessingCopyToSpace)).toHaveLength(1); + + const spaceResult = findTestSubject(wrapper, `cts-space-result-space-1`); + spaceResult.simulate('click'); + + const errorIconTip1 = spaceResult.find( + 'EuiIconTip[data-test-subj="cts-object-result-missing-references-my-viz-1"]' + ); + expect(errorIconTip1.props()).toMatchInlineSnapshot(` + Object { + "color": "warning", + "content": , + "data-test-subj": "cts-object-result-missing-references-my-viz-1", + "type": "link", + } + `); + + const myViz2Icon = 'EuiIconTip[data-test-subj="cts-object-result-missing-references-my-viz-2"]'; + expect(spaceResult.find(myViz2Icon)).toHaveLength(0); + + // TODO: test for a missing references icon by selecting overwrite for the my-viz-2 conflict + + const finishButton = findTestSubject(wrapper, 'cts-finish-button'); + await act(async () => { + finishButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.resolveCopySavedObjectsErrors).toHaveBeenCalledWith( + [{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }], + { + 'space-1': [ + { type: 'dashboard', id: 'my-dash', overwrite: false }, + { + type: 'visualization', + id: 'my-viz-1', + overwrite: false, + ignoreMissingReferences: true, + }, + ], + }, + true, + false ); expect(onClose).toHaveBeenCalledTimes(1); expect(mockToastNotifications.addError).not.toHaveBeenCalled(); }); - it('displays an error when missing references are encountered', async () => { + it('displays an error when an unresolvable error is encountered', async () => { const { wrapper, onClose, mockSpacesManager, mockToastNotifications } = await setup(); mockSpacesManager.copySavedObjects.mockResolvedValue({ @@ -396,11 +567,8 @@ describe('CopyToSpaceFlyout', () => { { type: 'visualization', id: 'my-viz', - error: { - type: 'missing_references', - blocking: [], - references: [{ type: 'index-pattern', id: 'missing-index-pattern' }], - }, + error: { type: 'unknown', message: 'some error message', statusCode: 400 }, + meta: {}, }, ], }, diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx index 47fc603ee46e8..d82e00535e3d0 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx @@ -22,17 +22,17 @@ import { mapValues } from 'lodash'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ToastsStart } from 'src/core/public'; -import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; +import { + ProcessedImportResponse, + processImportResponse, + SavedObjectsManagementRecord, +} from '../../../../../../src/plugins/saved_objects_management/public'; import { Space } from '../../../common/model/space'; import { SpacesManager } from '../../spaces_manager'; import { ProcessingCopyToSpace } from './processing_copy_to_space'; import { CopyToSpaceFlyoutFooter } from './copy_to_space_flyout_footer'; import { CopyToSpaceForm } from './copy_to_space_form'; import { CopyOptions, ImportRetry } from '../types'; -import { - ProcessedImportResponse, - processImportResponse, -} from '../../../../../../src/plugins/saved_objects_management/public'; interface Props { onClose: () => void; @@ -41,11 +41,16 @@ interface Props { toastNotifications: ToastsStart; } +const INCLUDE_RELATED_DEFAULT = true; +const CREATE_NEW_COPIES_DEFAULT = false; +const OVERWRITE_ALL_DEFAULT = true; + export const CopySavedObjectsToSpaceFlyout = (props: Props) => { const { onClose, savedObject, spacesManager, toastNotifications } = props; const [copyOptions, setCopyOptions] = useState({ - includeRelated: true, - overwrite: true, + includeRelated: INCLUDE_RELATED_DEFAULT, + createNewCopies: CREATE_NEW_COPIES_DEFAULT, + overwrite: OVERWRITE_ALL_DEFAULT, selectedSpaceIds: [], }); @@ -90,18 +95,48 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { setCopyResult({}); try { const copySavedObjectsResult = await spacesManager.copySavedObjects( - [ - { - type: savedObject.type, - id: savedObject.id, - }, - ], + [{ type: savedObject.type, id: savedObject.id }], copyOptions.selectedSpaceIds, copyOptions.includeRelated, + copyOptions.createNewCopies, copyOptions.overwrite ); const processedResult = mapValues(copySavedObjectsResult, processImportResponse); setCopyResult(processedResult); + + // retry all successful imports + const getAutomaticRetries = (response: ProcessedImportResponse): ImportRetry[] => { + const { failedImports, successfulImports } = response; + if (!failedImports.length) { + // if no imports failed for this space, return an empty array + return []; + } + + // get missing references failures that do not also have a conflict + const nonMissingReferencesFailures = failedImports + .filter(({ error }) => error.type !== 'missing_references') + .reduce((acc, { obj: { type, id } }) => acc.add(`${type}:${id}`), new Set()); + const missingReferencesToRetry = failedImports.filter( + ({ obj: { type, id }, error }) => + error.type === 'missing_references' && + !nonMissingReferencesFailures.has(`${type}:${id}`) + ); + + // otherwise, some imports failed for this space, so retry any successful imports (if any) + return [ + ...successfulImports.map(({ type, id, overwrite, destinationId, createNewCopy }) => { + return { type, id, overwrite: overwrite === true, destinationId, createNewCopy }; + }), + ...missingReferencesToRetry.map(({ obj: { type, id } }) => ({ + type, + id, + overwrite: false, + ignoreMissingReferences: true, + })), + ]; + }; + const automaticRetries = mapValues(processedResult, getAutomaticRetries); + setRetries(automaticRetries); } catch (e) { setCopyInProgress(false); toastNotifications.addError(e, { @@ -113,27 +148,22 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { } async function finishCopy() { - const needsConflictResolution = Object.values(retries).some((spaceRetry) => - spaceRetry.some((retry) => retry.overwrite) - ); + // if any retries are present, attempt to resolve errors again + const needsErrorResolution = Object.values(retries).some((spaceRetry) => spaceRetry.length); - if (needsConflictResolution) { + if (needsErrorResolution) { setConflictResolutionInProgress(true); try { await spacesManager.resolveCopySavedObjectsErrors( - [ - { - type: savedObject.type, - id: savedObject.id, - }, - ], + [{ type: savedObject.type, id: savedObject.id }], retries, - copyOptions.includeRelated + copyOptions.includeRelated, + copyOptions.createNewCopies ); toastNotifications.addSuccess( i18n.translate('xpack.spaces.management.copyToSpace.resolveCopySuccessTitle', { - defaultMessage: 'Overwrite successful', + defaultMessage: 'Copy successful', }) ); @@ -184,7 +214,12 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { // Step 2: Copy has not been initiated yet; User must fill out form to continue. if (!copyInProgress) { return ( - + ); } @@ -208,14 +243,14 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { - +

diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx index d7ded819771fc..96833c5e53f38 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx @@ -8,8 +8,8 @@ import React, { Fragment } from 'react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiStat, EuiHorizontalRule } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { ProcessedImportResponse, FailedImport } from 'src/plugins/saved_objects_management/public'; import { ImportRetry } from '../types'; -import { ProcessedImportResponse } from '../../../../../../src/plugins/saved_objects_management/public'; interface Props { copyInProgress: boolean; @@ -21,30 +21,44 @@ interface Props { onCopyStart: () => void; onCopyFinish: () => void; } + +const isResolvableError = ({ error: { type } }: FailedImport) => + ['conflict', 'ambiguous_conflict', 'missing_references'].includes(type); +const isUnresolvableError = (failure: FailedImport) => !isResolvableError(failure); + export const CopyToSpaceFlyoutFooter = (props: Props) => { const { copyInProgress, initialCopyFinished, copyResult, retries } = props; let summarizedResults = { successCount: 0, - overwriteConflictCount: 0, - conflictCount: 0, - unresolvableErrorCount: 0, + pendingCount: 0, + skippedCount: 0, + errorCount: 0, }; if (copyResult) { summarizedResults = Object.entries(copyResult).reduce((acc, result) => { const [spaceId, spaceResult] = result; - const overwriteCount = (retries[spaceId] || []).filter((c) => c.overwrite).length; + let successCount = 0; + let pendingCount = 0; + let skippedCount = 0; + let errorCount = 0; + if (spaceResult.status === 'success') { + successCount = spaceResult.importCount; + } else { + const uniqueResolvableErrors = spaceResult.failedImports + .filter(isResolvableError) + .reduce((set, { obj: { type, id } }) => set.add(`${type}:${id}`), new Set()); + pendingCount = (retries[spaceId] || []).length; + skippedCount = + uniqueResolvableErrors.size + spaceResult.successfulImports.length - pendingCount; + errorCount = spaceResult.failedImports.filter(isUnresolvableError).length; + } return { loading: false, - successCount: acc.successCount + spaceResult.importCount, - overwriteConflictCount: acc.overwriteConflictCount + overwriteCount, - conflictCount: - acc.conflictCount + - spaceResult.failedImports.filter((i) => i.error.type === 'conflict').length - - overwriteCount, - unresolvableErrorCount: - acc.unresolvableErrorCount + - spaceResult.failedImports.filter((i) => i.error.type !== 'conflict').length, + successCount: acc.successCount + successCount, + pendingCount: acc.pendingCount + pendingCount, + skippedCount: acc.skippedCount + skippedCount, + errorCount: acc.errorCount + errorCount, }; }, summarizedResults); } @@ -52,13 +66,13 @@ export const CopyToSpaceFlyoutFooter = (props: Props) => { const getButton = () => { let actionButton; if (initialCopyFinished) { - const hasPendingOverwrites = summarizedResults.overwriteConflictCount > 0; + const hasPendingRetries = summarizedResults.pendingCount > 0; - const buttonText = hasPendingOverwrites ? ( + const buttonText = hasPendingRetries ? ( ) : ( { } />
- {summarizedResults.overwriteConflictCount > 0 && ( - - 0 ? 'primary' : 'subdued'} - isLoading={!initialCopyFinished} - textAlign="center" - description={ - - } - /> - - )} + + 0 ? 'primary' : 'subdued'} + isLoading={!initialCopyFinished} + textAlign="center" + description={ + + } + /> + 0 ? 'primary' : 'subdued'} + titleColor={summarizedResults.skippedCount > 0 ? 'primary' : 'subdued'} isLoading={!initialCopyFinished} textAlign="center" description={ @@ -178,9 +190,9 @@ export const CopyToSpaceFlyoutFooter = (props: Props) => { 0 ? 'danger' : 'subdued'} + titleColor={summarizedResults.errorCount > 0 ? 'danger' : 'subdued'} isLoading={!initialCopyFinished} textAlign="center" description={ diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx index 0df2a7720e587..fdc8d8c73e324 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx @@ -4,78 +4,62 @@ * you may not use this file except in compliance with the Elastic License. */ -import './copy_to_space_form.scss'; import React from 'react'; -import { - EuiSwitch, - EuiSpacer, - EuiHorizontalRule, - EuiFormRow, - EuiListGroup, - EuiListGroupItem, -} from '@elastic/eui'; +import { EuiSpacer, EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { CopyOptions } from '../types'; +import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; import { Space } from '../../../common/model/space'; import { SelectableSpacesControl } from './selectable_spaces_control'; +import { CopyModeControl, CopyMode } from './copy_mode_control'; interface Props { + savedObject: SavedObjectsManagementRecord; spaces: Space[]; onUpdate: (copyOptions: CopyOptions) => void; copyOptions: CopyOptions; } export const CopyToSpaceForm = (props: Props) => { - const setOverwrite = (overwrite: boolean) => props.onUpdate({ ...props.copyOptions, overwrite }); + const { savedObject, spaces, onUpdate, copyOptions } = props; + + // if the user is not creating new copies, prevent them from copying objects an object into a space where it already exists + const getDisabledSpaceIds = (createNewCopies: boolean) => + createNewCopies + ? new Set() + : (savedObject.namespaces ?? []).reduce((acc, cur) => acc.add(cur), new Set()); + + const changeCopyMode = ({ createNewCopies, overwrite }: CopyMode) => { + const disabled = getDisabledSpaceIds(createNewCopies); + const selectedSpaceIds = copyOptions.selectedSpaceIds.filter((x) => !disabled.has(x)); + onUpdate({ ...copyOptions, createNewCopies, overwrite, selectedSpaceIds }); + }; const setSelectedSpaceIds = (selectedSpaceIds: string[]) => - props.onUpdate({ ...props.copyOptions, selectedSpaceIds }); + onUpdate({ ...copyOptions, selectedSpaceIds }); return (
- - - - - } - /> - - - - - - } - checked={props.copyOptions.overwrite} - onChange={(e) => setOverwrite(e.target.checked)} + changeCopyMode(newValues)} /> - + } fullWidth > setSelectedSpaceIds(selection)} /> diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx index 255268d388eb8..ceaa1dc9f5e21 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx @@ -19,7 +19,7 @@ import { } from 'src/plugins/saved_objects_management/public'; import { Space } from '../../../common/model/space'; import { CopyOptions, ImportRetry } from '../types'; -import { SpaceResult } from './space_result'; +import { SpaceResult, SpaceResultProcessing } from './space_result'; import { summarizeCopyResult } from '..'; interface Props { @@ -33,6 +33,52 @@ interface Props { copyOptions: CopyOptions; } +const renderCopyOptions = ({ createNewCopies, overwrite, includeRelated }: CopyOptions) => { + const createNewCopiesLabel = createNewCopies ? ( + + ) : ( + + ); + const overwriteLabel = overwrite ? ( + + ) : ( + + ); + const includeRelatedLabel = includeRelated ? ( + + ) : ( + + ); + + return ( + + + {!createNewCopies && ( + + )} + + + ); +}; + export const ProcessingCopyToSpace = (props: Props) => { function updateRetries(spaceId: string, updatedRetries: ImportRetry[]) { props.onRetriesChange({ @@ -43,46 +89,13 @@ export const ProcessingCopyToSpace = (props: Props) => { return (
- - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - + {renderCopyOptions(props.copyOptions)}
@@ -90,22 +103,22 @@ export const ProcessingCopyToSpace = (props: Props) => { {props.copyOptions.selectedSpaceIds.map((id) => { const space = props.spaces.find((s) => s.id === id) as Space; const spaceCopyResult = props.copyResult[space.id]; - const summarizedSpaceCopyResult = summarizeCopyResult( - props.savedObject, - spaceCopyResult, - props.copyOptions.includeRelated - ); + const summarizedSpaceCopyResult = summarizeCopyResult(props.savedObject, spaceCopyResult); return ( - updateRetries(space.id, retries)} - conflictResolutionInProgress={props.conflictResolutionInProgress} - /> + {summarizedSpaceCopyResult.processing ? ( + + ) : ( + updateRetries(space.id, retries)} + conflictResolutionInProgress={props.conflictResolutionInProgress} + /> + )} ); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.scss b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.scss new file mode 100644 index 0000000000000..ce019d17ceaf7 --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.scss @@ -0,0 +1,4 @@ +.spcCopyToSpace__resolveAllConflictsLink { + font-size: $euiFontSizeS; + margin-right: $euiSizeS; +} diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.test.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.test.tsx new file mode 100644 index 0000000000000..7da265d8f9958 --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.test.tsx @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ReactWrapper } from 'enzyme'; +import { act } from '@testing-library/react'; +import { shallowWithIntl, mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { ResolveAllConflicts, ResolveAllConflictsProps } from './resolve_all_conflicts'; +import { SummarizedCopyToSpaceResult } from '..'; +import { ImportRetry } from '../types'; +describe('ResolveAllConflicts', () => { + const summarizedCopyResult = ({ + objects: [ + // these objects have minimal attributes to exercise test scenarios; these are not fully realistic results + { type: 'type-1', id: 'id-1', conflict: undefined }, // not a conflict + { type: 'type-2', id: 'id-2', conflict: { error: { type: 'conflict' } } }, // conflict without a destinationId + { + // conflict with a destinationId + type: 'type-3', + id: 'id-3', + conflict: { error: { type: 'conflict', destinationId: 'dest-3' } }, + }, + { + // ambiguous conflict with two destinations + type: 'type-4', + id: 'id-4', + conflict: { + error: { + type: 'ambiguous_conflict', + destinations: [{ id: 'dest-4a' }, { id: 'dest-4b' }], + }, + }, + }, + { + // ambiguous conflict with two destinations (a retry already exists for dest-5b) + type: 'type-5', + id: 'id-5', + conflict: { + error: { + type: 'ambiguous_conflict', + destinations: [{ id: 'dest-5a' }, { id: 'dest-5b' }], + }, + }, + }, + ], + } as unknown) as SummarizedCopyToSpaceResult; + const retries: ImportRetry[] = [ + { type: 'type-1', id: 'id-1', overwrite: false }, + { type: 'type-5', id: 'id-5', overwrite: true, destinationId: 'dest-5b' }, + ]; + const onRetriesChange = jest.fn(); + const onDestinationMapChange = jest.fn(); + + const getOverwriteOption = (wrapper: ReactWrapper) => + findTestSubject(wrapper, 'cts-resolve-all-conflicts-overwrite'); + const getSkipOption = (wrapper: ReactWrapper) => + findTestSubject(wrapper, 'cts-resolve-all-conflicts-skip'); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const props: ResolveAllConflictsProps = { + summarizedCopyResult, + retries, + onRetriesChange, + onDestinationMapChange, + }; + const openPopover = async (wrapper: ReactWrapper) => { + await act(async () => { + wrapper.setState({ isPopoverOpen: true }); + await nextTick(); + wrapper.update(); + }); + }; + + it('should render as expected', async () => { + const wrapper = shallowWithIntl(); + + expect(wrapper).toMatchInlineSnapshot(` + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="resolveAllConflictsVisibilityPopover" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + > + + Overwrite all + , + + Skip all + , + ] + } + /> + + `); + }); + + it('should add overwrite retries when "Overwrite all" is selected', async () => { + const wrapper = mountWithIntl(); + await openPopover(wrapper); + expect(onRetriesChange).not.toHaveBeenCalled(); + + getOverwriteOption(wrapper).simulate('click'); + expect(onRetriesChange).toHaveBeenCalledWith([ + { type: 'type-1', id: 'id-1', overwrite: false }, // unchanged + { type: 'type-5', id: 'id-5', overwrite: true, destinationId: 'dest-5b' }, // unchanged + { type: 'type-2', id: 'id-2', overwrite: true }, // added without a destinationId + { type: 'type-3', id: 'id-3', overwrite: true, destinationId: 'dest-3' }, // added with the destinationId + { type: 'type-4', id: 'id-4', overwrite: true, destinationId: 'dest-4a' }, // added with the first destinationId + ]); + expect(onDestinationMapChange).not.toHaveBeenCalled(); + }); + + it('should remove overwrite retries when "Skip all" is selected', async () => { + const wrapper = mountWithIntl(); + await openPopover(wrapper); + expect(onRetriesChange).not.toHaveBeenCalled(); + expect(onDestinationMapChange).not.toHaveBeenCalled(); + + getSkipOption(wrapper).simulate('click'); + expect(onRetriesChange).toHaveBeenCalledWith([ + { type: 'type-1', id: 'id-1', overwrite: false }, // unchanged + ]); + expect(onDestinationMapChange).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.tsx new file mode 100644 index 0000000000000..a4ded022debe8 --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './resolve_all_conflicts.scss'; + +import { EuiContextMenuItem, EuiContextMenuPanel, EuiLink, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { Component } from 'react'; +import { ImportRetry } from '../types'; +import { SummarizedCopyToSpaceResult } from '..'; + +export interface ResolveAllConflictsProps { + summarizedCopyResult: SummarizedCopyToSpaceResult; + retries: ImportRetry[]; + onRetriesChange: (retries: ImportRetry[]) => void; + onDestinationMapChange: (value?: Map) => void; +} + +interface State { + isPopoverOpen: boolean; +} + +interface ResolveOption { + id: 'overwrite' | 'skip'; + text: string; +} + +const options: ResolveOption[] = [ + { + id: 'overwrite', + text: i18n.translate('xpack.spaces.management.copyToSpace.overwriteAllConflictsText', { + defaultMessage: 'Overwrite all', + }), + }, + { + id: 'skip', + text: i18n.translate('xpack.spaces.management.copyToSpace.skipAllConflictsText', { + defaultMessage: 'Skip all', + }), + }, +]; + +export class ResolveAllConflicts extends Component { + public state = { + isPopoverOpen: false, + }; + + public render() { + const button = ( + + + + ); + + const items = options.map((item) => { + return ( + { + this.onSelect(item.id); + }} + > + {item.text} + + ); + }); + + return ( + + + + ); + } + + private onSelect = (selection: ResolveOption['id']) => { + const { summarizedCopyResult, retries, onRetriesChange, onDestinationMapChange } = this.props; + const overwrite = selection === 'overwrite'; + + if (overwrite) { + const existingOverwrites = retries.filter((retry) => retry.overwrite === true); + const newOverwrites = summarizedCopyResult.objects.reduce((acc, { type, id, conflict }) => { + if ( + conflict && + !existingOverwrites.some((retry) => retry.type === type && retry.id === id) + ) { + const { error } = conflict; + // if this is a regular conflict, use its destinationId if it has one; + // otherwise, this is an ambiguous conflict, so use the first destinationId available + const destinationId = + error.type === 'conflict' ? error.destinationId : error.destinations[0].id; + return [...acc, { type, id, overwrite, ...(destinationId && { destinationId }) }]; + } + return acc; + }, new Array()); + onRetriesChange([...retries, ...newOverwrites]); + } else { + const objectsToSkip = summarizedCopyResult.objects.reduce( + (acc, { type, id, conflict }) => (conflict ? acc.add(`${type}:${id}`) : acc), + new Set() + ); + const filtered = retries.filter(({ type, id }) => !objectsToSkip.has(`${type}:${id}`)); + onRetriesChange(filtered); + onDestinationMapChange(undefined); + } + + this.setState({ isPopoverOpen: false }); + }; + + private onButtonClick = () => { + this.setState({ + isPopoverOpen: !this.state.isPopoverOpen, + }); + }; + + private closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; +} diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx index 9db045f4f068a..2a8b5e660f38c 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -5,42 +5,53 @@ */ import './selectable_spaces_control.scss'; -import React, { Fragment, useState } from 'react'; -import { EuiSelectable, EuiLoadingSpinner } from '@elastic/eui'; +import React, { Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSelectable, EuiSelectableOption, EuiLoadingSpinner, EuiIconTip } from '@elastic/eui'; import { SpaceAvatar } from '../../space_avatar'; import { Space } from '../../../common/model/space'; interface Props { spaces: Space[]; selectedSpaceIds: string[]; + disabledSpaceIds: Set; onChange: (selectedSpaceIds: string[]) => void; disabled?: boolean; } -interface SpaceOption { - label: string; - prepend?: any; - checked: 'on' | 'off' | null; - ['data-space-id']: string; - disabled?: boolean; -} +type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; export const SelectableSpacesControl = (props: Props) => { - const [options, setOptions] = useState([]); - - // TODO: update once https://github.com/elastic/eui/issues/2071 is fixed - if (options.length === 0) { - setOptions( - props.spaces.map((space) => ({ - label: space.name, - prepend: , - checked: props.selectedSpaceIds.includes(space.id) ? 'on' : null, - ['data-space-id']: space.id, - ['data-test-subj']: `cts-space-selector-row-${space.id}`, - })) - ); + if (props.spaces.length === 0) { + return ; } + const disabledIndicator = ( + + } + position="left" + type="iInCircle" + /> + ); + + const options = props.spaces.map((space) => { + const disabled = props.disabledSpaceIds.has(space.id); + return { + label: space.name, + prepend: , + append: disabled ? disabledIndicator : null, + checked: props.selectedSpaceIds.includes(space.id) ? 'on' : undefined, + disabled, + ['data-space-id']: space.id, + ['data-test-subj']: `cts-space-selector-row-${space.id}`, + }; + }); + function updateSelectedSpaces(selectedOptions: SpaceOption[]) { if (props.disabled) return; @@ -49,17 +60,11 @@ export const SelectableSpacesControl = (props: Props) => { .map((opt) => opt['data-space-id']); props.onChange(selectedSpaceIds); - // TODO: remove once https://github.com/elastic/eui/issues/2071 is fixed - setOptions(selectedOptions); - } - - if (options.length === 0) { - return ; } return ( updateSelectedSpaces(newOptions as SpaceOption[])} listProps={{ bordered: true, diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result.tsx index f1a8f64a61449..eefd9f8ea2467 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result.tsx @@ -5,8 +5,15 @@ */ import './space_result.scss'; -import React from 'react'; -import { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui'; +import React, { useState } from 'react'; +import { + EuiAccordion, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiSpacer, + EuiLoadingSpinner, +} from '@elastic/eui'; import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; import { SummarizedCopyToSpaceResult } from '../index'; import { SpaceAvatar } from '../../space_avatar'; @@ -24,6 +31,39 @@ interface Props { conflictResolutionInProgress: boolean; } +const getInitialDestinationMap = (objects: SummarizedCopyToSpaceResult['objects']) => + objects.reduce((acc, { type, id, conflict }) => { + if (conflict?.error.type === 'ambiguous_conflict') { + acc.set(`${type}:${id}`, conflict.error.destinations[0].id); + } + return acc; + }, new Map()); + +export const SpaceResultProcessing = (props: Pick) => { + const { space } = props; + return ( + + + + + + {space.name} + + + } + extraAction={} + > + + + + ); +}; + export const SpaceResult = (props: Props) => { const { space, @@ -33,7 +73,12 @@ export const SpaceResult = (props: Props) => { savedObject, conflictResolutionInProgress, } = props; + const { objects } = summarizedCopyResult; const spaceHasPendingOverwrites = retries.some((r) => r.overwrite); + const [destinationMap, setDestinationMap] = useState(getInitialDestinationMap(objects)); + const onDestinationMapChange = (value?: Map) => { + setDestinationMap(value || getInitialDestinationMap(objects)); + }; return ( { extraAction={ @@ -65,6 +113,8 @@ export const SpaceResult = (props: Props) => { space={space} retries={retries} onRetriesChange={onRetriesChange} + destinationMap={destinationMap} + onDestinationMapChange={onDestinationMapChange} conflictResolutionInProgress={conflictResolutionInProgress && spaceHasPendingOverwrites} /> diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.scss b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.scss index 7702987220282..bca07da9eae42 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.scss +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.scss @@ -11,3 +11,28 @@ // Constrains name to the flex item, and allows for truncation when necessary min-width: 0; } + +.spcCopyToSpaceResultDetails__selectControl { + margin-left: $euiSizeL; +} + +.spcCopyToSpaceResultDetails__selectControl__childWrapper { + // Derived from euiAccordion + visibility: hidden; + opacity: 0; + height: 0; + overflow: hidden; + transform: translatez(0); + // sass-lint:disable-block indentation + transition: + height $euiAnimSpeedNormal $euiAnimSlightResistance, + opacity $euiAnimSpeedNormal $euiAnimSlightResistance; +} + +.spcCopyToSpaceResultDetails__selectControl.spcCopyToSpaceResultDetails__selectControl-isOpen { + .spcCopyToSpaceResultDetails__selectControl__childWrapper { + visibility: visible; + opacity: 1; + height: auto; + } +} diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.tsx index ef7931260e643..c37d40ec0a9f7 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.tsx @@ -5,9 +5,23 @@ */ import './space_result_details.scss'; -import React from 'react'; -import { EuiText, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import React, { Fragment } from 'react'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiSwitchEvent, + EuiToolTip, + EuiIcon, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { + SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, +} from 'kibana/public'; +import { EuiSuperSelect } from '@elastic/eui'; +import moment from 'moment'; import { SummarizedCopyToSpaceResult } from '../index'; import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; import { Space } from '../../../common/model/space'; @@ -20,104 +34,161 @@ interface Props { space: Space; retries: ImportRetry[]; onRetriesChange: (retries: ImportRetry[]) => void; + destinationMap: Map; + onDestinationMapChange: (value?: Map) => void; conflictResolutionInProgress: boolean; } -export const SpaceCopyResultDetails = (props: Props) => { - const onOverwriteClick = (object: { type: string; id: string }) => { - const retry = props.retries.find((r) => r.type === object.type && r.id === object.id); - - props.onRetriesChange([ - ...props.retries.filter((r) => r !== retry), - { - type: object.type, - id: object.id, - overwrite: retry ? !retry.overwrite : true, - }, - ]); - }; - - const hasPendingOverwrite = (object: { type: string; id: string }) => { - const retry = props.retries.find((r) => r.type === object.type && r.id === object.id); +function getSavedObjectLabel(type: string) { + switch (type) { + case 'index-pattern': + case 'index-patterns': + case 'indexPatterns': + return 'index patterns'; + default: + return type; + } +} - return Boolean(retry && retry.overwrite); - }; +const isAmbiguousConflictError = ( + error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError +): error is SavedObjectsImportAmbiguousConflictError => error.type === 'ambiguous_conflict'; - const { objects } = props.summarizedCopyResult; +export const SpaceCopyResultDetails = (props: Props) => { + const { destinationMap, onDestinationMapChange, summarizedCopyResult } = props; + const { objects } = summarizedCopyResult; return (
{objects.map((object, index) => { - const objectOverwritePending = hasPendingOverwrite(object); + const { type, id, name, icon, conflict } = object; + const pendingObjectRetry = props.retries.find((r) => r.type === type && r.id === id); + const isOverwritePending = Boolean(pendingObjectRetry?.overwrite); + const switchProps = { + show: conflict && !props.conflictResolutionInProgress, + label: i18n.translate('xpack.spaces.management.copyToSpace.copyDetail.overwriteSwitch', { + defaultMessage: 'Overwrite?', + }), + onChange: ({ target: { checked } }: EuiSwitchEvent) => { + const filtered = props.retries.filter((r) => r.type !== type || r.id !== id); + const { error } = conflict!; - const showOverwriteButton = - object.conflicts.length > 0 && - !objectOverwritePending && - !props.conflictResolutionInProgress; - - const showSkipButton = - !showOverwriteButton && objectOverwritePending && !props.conflictResolutionInProgress; + if (!checked) { + props.onRetriesChange(filtered); + if (isAmbiguousConflictError(error)) { + // reset the selection to the first entry + const value = error.destinations[0].id; + onDestinationMapChange(new Map(destinationMap.set(`${type}:${id}`, value))); + } + } else { + const destinationId = isAmbiguousConflictError(error) + ? destinationMap.get(`${type}:${id}`) + : error.destinationId; + const retry = { type, id, overwrite: true, ...(destinationId && { destinationId }) }; + props.onRetriesChange([...filtered, retry]); + } + }, + }; + const selectProps = { + options: + conflict?.error && isAmbiguousConflictError(conflict.error) + ? conflict.error.destinations.map((destination) => { + const header = destination.title ?? `${type} [id=${destination.id}]`; + const lastUpdated = destination.updatedAt + ? moment(destination.updatedAt).fromNow() + : 'never'; + return { + value: destination.id, + inputDisplay: destination.id, + dropdownDisplay: ( + + {header} + +

+ ID: {destination.id} +
+ Last updated: {lastUpdated} +

+
+
+ ), + }; + }) + : [], + onChange: (value: string) => { + onDestinationMapChange(new Map(destinationMap.set(`${type}:${id}`, value))); + const filtered = props.retries.filter((r) => r.type !== type || r.id !== id); + const retry = { type, id, overwrite: true, destinationId: value }; + props.onRetriesChange([...filtered, retry]); + }, + }; + const selectContainerClass = + selectProps.options.length > 0 && isOverwritePending + ? ' spcCopyToSpaceResultDetails__selectControl-isOpen' + : ''; return ( - - - -

- {object.type}: {object.name || object.id} -

-
-
- {showOverwriteButton && ( - - - onOverwriteClick(object)} - size="xs" - data-test-subj={`cts-overwrite-conflict-${object.id}`} - > - - - + + + + + + - )} - {showSkipButton && ( - + - onOverwriteClick(object)} - size="xs" - data-test-subj={`cts-skip-conflict-${object.id}`} - > - - +

+ {name} +

- )} - -
- + + + )} + +
+ +
+
+ +
+
+
- - +
+ ); })}
diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_action.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_action.tsx index 28b48044a1783..9bbde31ff6fea 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_action.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_action.tsx @@ -21,10 +21,13 @@ export class CopyToSpaceSavedObjectsManagementAction extends SavedObjectsManagem defaultMessage: 'Copy to space', }), description: i18n.translate('xpack.spaces.management.copyToSpace.actionDescription', { - defaultMessage: 'Copy this saved object to one or more spaces', + defaultMessage: 'Make a copy of this saved object in one or more spaces', }), - icon: 'spacesApp', + icon: 'copy', type: 'icon', + available: (object: SavedObjectsManagementRecord) => { + return object.meta.namespaceType !== 'agnostic'; + }, onClick: (object: SavedObjectsManagementRecord) => { this.start(object); }, diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts index a8ecd7c7b9d9f..b8fc89f47a3e0 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts @@ -5,50 +5,123 @@ */ import { summarizeCopyResult } from './summarize_copy_result'; -import { ProcessedImportResponse } from 'src/plugins/saved_objects_management/public'; +import { + ProcessedImportResponse, + FailedImport, + SavedObjectsManagementRecord, +} from 'src/plugins/saved_objects_management/public'; -const createSavedObjectsManagementRecord = () => ({ - type: 'dashboard', - id: 'foo', - meta: { icon: 'foo-icon', title: 'my-dashboard' }, - references: [ - { - type: 'visualization', - id: 'foo-viz', - name: 'Foo Viz', - }, - { - type: 'visualization', - id: 'bar-viz', - name: 'Bar Viz', - }, - ], -}); +// Sample data references: +// +// /-> Visualization bar -> Index pattern foo +// My dashboard +// \-> Visualization baz -> Index pattern bar +// +// Dashboard has references to visualizations, and transitive references to index patterns + +const OBJECTS = { + MY_DASHBOARD: { + type: 'dashboard', + id: 'foo', + meta: { title: 'my-dashboard-title', icon: 'dashboardApp', namespaceType: 'single' }, + references: [ + { type: 'visualization', id: 'foo', name: 'Visualization foo' }, + { type: 'visualization', id: 'bar', name: 'Visualization bar' }, + ], + } as SavedObjectsManagementRecord, + VISUALIZATION_FOO: { + type: 'visualization', + id: 'bar', + meta: { title: 'visualization-foo-title', icon: 'visualizeApp', namespaceType: 'single' }, + references: [{ type: 'index-pattern', id: 'foo', name: 'Index pattern foo' }], + } as SavedObjectsManagementRecord, + VISUALIZATION_BAR: { + type: 'visualization', + id: 'baz', + meta: { title: 'visualization-bar-title', icon: 'visualizeApp', namespaceType: 'single' }, + references: [{ type: 'index-pattern', id: 'bar', name: 'Index pattern bar' }], + } as SavedObjectsManagementRecord, + INDEX_PATTERN_FOO: { + type: 'index-pattern', + id: 'foo', + meta: { title: 'index-pattern-foo-title', icon: 'indexPatternApp', namespaceType: 'single' }, + references: [], + } as SavedObjectsManagementRecord, + INDEX_PATTERN_BAR: { + type: 'index-pattern', + id: 'bar', + meta: { title: 'index-pattern-bar-title', icon: 'indexPatternApp', namespaceType: 'single' }, + references: [], + } as SavedObjectsManagementRecord, +}; + +interface ObjectProperties { + type: string; + id: string; + meta: { title?: string; icon?: string }; +} +const createSuccessResult = ({ type, id, meta }: ObjectProperties) => { + return { type, id, meta }; +}; +const createFailureConflict = ({ type, id, meta }: ObjectProperties): FailedImport => { + return { obj: { type, id, meta }, error: { type: 'conflict' } }; +}; +const createFailureMissingReferences = ({ type, id, meta }: ObjectProperties): FailedImport => { + return { + obj: { type, id, meta }, + error: { type: 'missing_references', references: [] }, + }; +}; +const createFailureUnresolvable = ({ type, id, meta }: ObjectProperties): FailedImport => { + return { + obj: { type, id, meta }, + // currently, unresolvable errors are 'unsupported_type' and 'unknown'; either would work for this test case + error: { type: 'unknown', message: 'some error message', statusCode: 400 }, + }; +}; const createCopyResult = ( - opts: { withConflicts?: boolean; withUnresolvableError?: boolean } = {} + opts: { + withConflicts?: boolean; + withMissingReferencesError?: boolean; + withUnresolvableError?: boolean; + overwrite?: boolean; + } = {} ) => { - const failedImports: ProcessedImportResponse['failedImports'] = []; + let successfulImports: ProcessedImportResponse['successfulImports'] = [ + createSuccessResult(OBJECTS.MY_DASHBOARD), + ]; + let failedImports: ProcessedImportResponse['failedImports'] = []; if (opts.withConflicts) { - failedImports.push( - { - obj: { type: 'visualization', id: 'foo-viz' }, - error: { type: 'conflict' }, - }, - { - obj: { type: 'index-pattern', id: 'transient-index-pattern-conflict' }, - error: { type: 'conflict' }, - } - ); + failedImports.push(createFailureConflict(OBJECTS.VISUALIZATION_FOO)); + } else { + successfulImports.push(createSuccessResult(OBJECTS.VISUALIZATION_FOO)); } if (opts.withUnresolvableError) { - failedImports.push({ - obj: { type: 'visualization', id: 'bar-viz' }, - error: { type: 'missing_references', blocking: [], references: [] }, - }); + failedImports.push(createFailureUnresolvable(OBJECTS.INDEX_PATTERN_FOO)); + } else { + successfulImports.push(createSuccessResult(OBJECTS.INDEX_PATTERN_FOO)); + } + if (opts.withMissingReferencesError) { + failedImports.push(createFailureMissingReferences(OBJECTS.VISUALIZATION_BAR)); + // INDEX_PATTERN_BAR is not present in the source space, therefore VISUALIZATION_BAR resulted in a missing_references error + } else { + successfulImports.push( + createSuccessResult(OBJECTS.VISUALIZATION_BAR), + createSuccessResult(OBJECTS.INDEX_PATTERN_BAR) + ); + } + + if (opts.overwrite) { + failedImports = failedImports.map(({ obj, error }) => ({ + obj: { ...obj, overwrite: true }, + error, + })); + successfulImports = successfulImports.map((obj) => ({ ...obj, overwrite: true })); } const copyResult: ProcessedImportResponse = { + successfulImports, failedImports, } as ProcessedImportResponse; @@ -57,109 +130,101 @@ const createCopyResult = ( describe('summarizeCopyResult', () => { it('indicates the result is processing when not provided', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); const copyResult = undefined; - const includeRelated = true; - - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, - Object { - "conflicts": Array [], - "hasUnresolvableErrors": false, - "id": "foo-viz", - "name": "Foo Viz", - "type": "visualization", - }, - Object { - "conflicts": Array [], - "hasUnresolvableErrors": false, - "id": "bar-viz", - "name": "Bar Viz", - "type": "visualization", - }, ], "processing": true, } `); }); - it('processes failedImports to extract conflicts, including transient conflicts', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); + it('processes failedImports to extract conflicts, including transitive conflicts', () => { const copyResult = createCopyResult({ withConflicts: true }); - const includeRelated = true; + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); expect(summarizedResult).toMatchInlineSnapshot(` Object { "hasConflicts": true, + "hasMissingReferences": false, "hasUnresolvableErrors": false, "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, Object { - "conflicts": Array [ - Object { - "error": Object { - "type": "conflict", - }, - "obj": Object { - "id": "foo-viz", - "type": "visualization", + "conflict": Object { + "error": Object { + "type": "conflict", + }, + "obj": Object { + "id": "bar", + "meta": Object { + "icon": "visualizeApp", + "namespaceType": "single", + "title": "visualization-foo-title", }, + "type": "visualization", }, - ], + }, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "foo-viz", - "name": "Foo Viz", + "icon": "visualizeApp", + "id": "bar", + "name": "visualization-foo-title", + "overwrite": false, "type": "visualization", }, Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "bar-viz", - "name": "Bar Viz", - "type": "visualization", + "icon": "indexPatternApp", + "id": "foo", + "name": "index-pattern-foo-title", + "overwrite": false, + "type": "index-pattern", }, Object { - "conflicts": Array [ - Object { - "error": Object { - "type": "conflict", - }, - "obj": Object { - "id": "transient-index-pattern-conflict", - "type": "index-pattern", - }, - }, - ], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "transient-index-pattern-conflict", - "name": "transient-index-pattern-conflict", + "icon": "indexPatternApp", + "id": "bar", + "name": "index-pattern-bar-title", + "overwrite": false, "type": "index-pattern", }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "baz", + "name": "visualization-bar-title", + "overwrite": false, + "type": "visualization", + }, ], "processing": false, "successful": false, @@ -167,40 +232,54 @@ describe('summarizeCopyResult', () => { `); }); - it('processes failedImports to extract unresolvable errors', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); - const copyResult = createCopyResult({ withUnresolvableError: true }); - const includeRelated = true; + it('processes failedImports to extract missing references errors', () => { + const copyResult = createCopyResult({ withMissingReferencesError: true }); + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); expect(summarizedResult).toMatchInlineSnapshot(` Object { "hasConflicts": false, - "hasUnresolvableErrors": true, + "hasMissingReferences": true, + "hasUnresolvableErrors": false, "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": true, "hasUnresolvableErrors": false, - "id": "foo-viz", - "name": "Foo Viz", + "icon": "visualizeApp", + "id": "baz", + "name": "visualization-bar-title", + "overwrite": false, "type": "visualization", }, Object { - "conflicts": Array [], - "hasUnresolvableErrors": true, - "id": "bar-viz", - "name": "Bar Viz", + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "indexPatternApp", + "id": "foo", + "name": "index-pattern-foo-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "bar", + "name": "visualization-foo-title", + "overwrite": false, "type": "visualization", }, ], @@ -210,75 +289,147 @@ describe('summarizeCopyResult', () => { `); }); - it('processes a result without errors', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); - const copyResult = createCopyResult(); - const includeRelated = true; + it('processes failedImports to extract unresolvable errors', () => { + const copyResult = createCopyResult({ withUnresolvableError: true }); + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); expect(summarizedResult).toMatchInlineSnapshot(` Object { "hasConflicts": false, - "hasUnresolvableErrors": false, + "hasMissingReferences": false, + "hasUnresolvableErrors": true, "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": true, + "icon": "indexPatternApp", + "id": "foo", + "name": "index-pattern-foo-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "foo-viz", - "name": "Foo Viz", + "icon": "indexPatternApp", + "id": "bar", + "name": "index-pattern-bar-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "bar", + "name": "visualization-foo-title", + "overwrite": false, "type": "visualization", }, Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "bar-viz", - "name": "Bar Viz", + "icon": "visualizeApp", + "id": "baz", + "name": "visualization-bar-title", + "overwrite": false, "type": "visualization", }, ], "processing": false, - "successful": true, + "successful": false, } `); }); - it('does not include references unless requested', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); + it('processes a result without errors', () => { const copyResult = createCopyResult(); - const includeRelated = false; + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); expect(summarizedResult).toMatchInlineSnapshot(` Object { "hasConflicts": false, + "hasMissingReferences": false, "hasUnresolvableErrors": false, "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "indexPatternApp", + "id": "foo", + "name": "index-pattern-foo-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "indexPatternApp", + "id": "bar", + "name": "index-pattern-bar-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "bar", + "name": "visualization-foo-title", + "overwrite": false, + "type": "visualization", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "baz", + "name": "visualization-bar-title", + "overwrite": false, + "type": "visualization", + }, ], "processing": false, "successful": true, } `); }); + + it('indicates when successes and failures have been overwritten', () => { + const copyResult = createCopyResult({ withMissingReferencesError: true, overwrite: true }); + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + + expect(summarizedResult.objects).toHaveLength(4); + for (const obj of summarizedResult.objects) { + expect(obj.overwrite).toBe(true); + } + }); }); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts index 518e89df579a6..0c07d1a5da7eb 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts @@ -7,19 +7,28 @@ import { SavedObjectsManagementRecord, ProcessedImportResponse, + FailedImport, } from 'src/plugins/saved_objects_management/public'; +import { + SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, +} from 'kibana/public'; export interface SummarizedSavedObjectResult { type: string; id: string; name: string; - conflicts: ProcessedImportResponse['failedImports']; + icon: string; + conflict?: FailedImportConflict; + hasMissingReferences: boolean; hasUnresolvableErrors: boolean; + overwrite: boolean; } interface SuccessfulResponse { successful: true; hasConflicts: false; + hasMissingReferences: false; hasUnresolvableErrors: false; objects: SummarizedSavedObjectResult[]; processing: false; @@ -27,6 +36,7 @@ interface SuccessfulResponse { interface UnsuccessfulResponse { successful: false; hasConflicts: boolean; + hasMissingReferences: boolean; hasUnresolvableErrors: boolean; objects: SummarizedSavedObjectResult[]; processing: false; @@ -37,6 +47,19 @@ interface ProcessingResponse { processing: true; } +interface FailedImportConflict { + obj: FailedImport['obj']; + error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError; +} + +const isAnyConflict = (failure: FailedImport): failure is FailedImportConflict => + failure.error.type === 'conflict' || failure.error.type === 'ambiguous_conflict'; +const isMissingReferences = (failure: FailedImport) => failure.error.type === 'missing_references'; +const isUnresolvableError = (failure: FailedImport) => + !isAnyConflict(failure) && !isMissingReferences(failure); +const typeComparator = (a: { type: string }, b: { type: string }) => + a.type > b.type ? 1 : a.type < b.type ? -1 : 0; + export type SummarizedCopyToSpaceResult = | SuccessfulResponse | UnsuccessfulResponse @@ -44,69 +67,61 @@ export type SummarizedCopyToSpaceResult = export function summarizeCopyResult( savedObject: SavedObjectsManagementRecord, - copyResult: ProcessedImportResponse | undefined, - includeRelated: boolean + copyResult: ProcessedImportResponse | undefined ): SummarizedCopyToSpaceResult { - const successful = Boolean(copyResult && copyResult.failedImports.length === 0); - - const conflicts = copyResult - ? copyResult.failedImports.filter((failed) => failed.error.type === 'conflict') - : []; - - const unresolvableErrors = copyResult - ? copyResult.failedImports.filter((failed) => failed.error.type !== 'conflict') - : []; - - const hasConflicts = conflicts.length > 0; - - const hasUnresolvableErrors = Boolean( - copyResult && copyResult.failedImports.some((failed) => failed.error.type !== 'conflict') - ); + const conflicts = copyResult?.failedImports.filter(isAnyConflict) ?? []; + const missingReferences = copyResult?.failedImports.filter(isMissingReferences) ?? []; + const unresolvableErrors = + copyResult?.failedImports.filter((failed) => isUnresolvableError(failed)) ?? []; + const getExtraFields = ({ type, id }: { type: string; id: string }) => { + const conflict = conflicts.find(({ obj }) => obj.type === type && obj.id === id); + const missingReference = missingReferences.find( + ({ obj }) => obj.type === type && obj.id === id + ); + const hasMissingReferences = missingReference !== undefined; + const hasUnresolvableErrors = unresolvableErrors.some( + ({ obj }) => obj.type === type && obj.id === id + ); + const overwrite = conflict + ? false + : missingReference + ? missingReference.obj.overwrite === true + : copyResult?.successfulImports.some( + (obj) => obj.type === type && obj.id === id && obj.overwrite + ) === true; + + return { conflict, hasMissingReferences, hasUnresolvableErrors, overwrite }; + }; - const objectMap = new Map(); + const objectMap = new Map(); objectMap.set(`${savedObject.type}:${savedObject.id}`, { type: savedObject.type, id: savedObject.id, name: savedObject.meta.title, - conflicts: conflicts.filter( - (c) => c.obj.type === savedObject.type && c.obj.id === savedObject.id - ), - hasUnresolvableErrors: unresolvableErrors.some( - (e) => e.obj.type === savedObject.type && e.obj.id === savedObject.id - ), + icon: savedObject.meta.icon, + ...getExtraFields(savedObject), }); - if (includeRelated) { - savedObject.references.forEach((ref) => { - objectMap.set(`${ref.type}:${ref.id}`, { - type: ref.type, - id: ref.id, - name: ref.name, - conflicts: conflicts.filter((c) => c.obj.type === ref.type && c.obj.id === ref.id), - hasUnresolvableErrors: unresolvableErrors.some( - (e) => e.obj.type === ref.type && e.obj.id === ref.id - ), - }); - }); - - // The `savedObject.references` array only includes the direct references. It does not include any references of references. - // Therefore, if there are conflicts detected in these transitive references, we need to include them here so that they are visible - // in the UI as resolvable conflicts. - const transitiveConflicts = conflicts.filter( - (c) => !objectMap.has(`${c.obj.type}:${c.obj.id}`) - ); - transitiveConflicts.forEach((conflict) => { - objectMap.set(`${conflict.obj.type}:${conflict.obj.id}`, { - type: conflict.obj.type, - id: conflict.obj.id, - name: conflict.obj.title || conflict.obj.id, - conflicts: conflicts.filter((c) => c.obj.type === conflict.obj.type && conflict.obj.id), - hasUnresolvableErrors: unresolvableErrors.some( - (e) => e.obj.type === conflict.obj.type && e.obj.id === conflict.obj.id - ), + const addObjectsToMap = ( + objects: Array<{ id: string; type: string; meta: { title?: string; icon?: string } }> + ) => { + objects.forEach((obj) => { + const { type, id, meta } = obj; + objectMap.set(`${type}:${id}`, { + type, + id, + name: meta.title || `${type} [id=${id}]`, + icon: meta.icon || 'apps', + ...getExtraFields(obj), }); }); - } + }; + const failedImports = (copyResult?.failedImports ?? []) + .map(({ obj }) => obj) + .sort(typeComparator); + addObjectsToMap(failedImports); + const successfulImports = (copyResult?.successfulImports ?? []).sort(typeComparator); + addObjectsToMap(successfulImports); if (typeof copyResult === 'undefined') { return { @@ -115,20 +130,26 @@ export function summarizeCopyResult( }; } + const successful = Boolean(copyResult && copyResult.failedImports.length === 0); if (successful) { return { successful, hasConflicts: false, objects: Array.from(objectMap.values()), + hasMissingReferences: false, hasUnresolvableErrors: false, processing: false, }; } + const hasConflicts = conflicts.length > 0; + const hasMissingReferences = missingReferences.length > 0; + const hasUnresolvableErrors = unresolvableErrors.length > 0; return { successful, hasConflicts, objects: Array.from(objectMap.values()), + hasMissingReferences, hasUnresolvableErrors, processing: false, }; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts index 9fcc5a89736cc..2310f6c96937c 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts @@ -8,6 +8,7 @@ import { SavedObjectsImportRetry, SavedObjectsImportResponse } from 'src/core/pu export interface CopyOptions { includeRelated: boolean; + createNewCopies: boolean; overwrite: boolean; selectedSpaceIds: string[]; } diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 8589993a97e02..cd31a4aa17fc3 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -15,6 +15,7 @@ import { SpacesManager } from './spaces_manager'; import { initSpacesNavControl } from './nav_control'; import { createSpacesFeatureCatalogueEntry } from './create_feature_catalogue_entry'; import { CopySavedObjectsToSpaceService } from './copy_saved_objects_to_space'; +import { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space'; import { AdvancedSettingsService } from './advanced_settings'; import { ManagementService } from './management'; import { spaceSelectorApp } from './space_selector'; @@ -67,6 +68,12 @@ export class SpacesPlugin implements Plugin void; + disabled?: boolean; +} + +type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; + +const activeSpaceProps = { + append: Current, + disabled: true, + checked: 'on' as 'on', +}; + +export const SelectableSpacesControl = (props: Props) => { + if (props.spaces.length === 0) { + return ; + } + + const options = props.spaces + .sort((a, b) => (a.isActiveSpace ? -1 : b.isActiveSpace ? 1 : 0)) + .map((space) => ({ + label: space.name, + prepend: , + checked: props.selectedSpaceIds.includes(space.id) ? 'on' : undefined, + ['data-space-id']: space.id, + ['data-test-subj']: `sts-space-selector-row-${space.id}`, + ...(space.isActiveSpace ? activeSpaceProps : {}), + })); + + function updateSelectedSpaces(selectedOptions: SpaceOption[]) { + if (props.disabled) return; + + const selectedSpaceIds = selectedOptions + .filter((opt) => opt.checked && !opt.disabled) + .map((opt) => opt['data-space-id']); + + props.onChange(selectedSpaceIds); + } + + return ( + updateSelectedSpaces(newOptions as SpaceOption[])} + listProps={{ + bordered: true, + rowHeight: 40, + className: 'spcShareToSpace__spacesList', + 'data-test-subj': 'sts-form-space-selector', + }} + searchable + > + {(list, search) => { + return ( + + {search} + {list} + + ); + }} + + ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx new file mode 100644 index 0000000000000..c17a2dcb1a831 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx @@ -0,0 +1,371 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import Boom from 'boom'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout'; +import { ShareToSpaceForm } from './share_to_space_form'; +import { EuiLoadingSpinner, EuiEmptyPrompt } from '@elastic/eui'; +import { Space } from '../../../common/model/space'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { SelectableSpacesControl } from './selectable_spaces_control'; +import { act } from '@testing-library/react'; +import { spacesManagerMock } from '../../spaces_manager/mocks'; +import { SpacesManager } from '../../spaces_manager'; +import { ToastsApi } from 'src/core/public'; +import { EuiCallOut } from '@elastic/eui'; +import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; + +interface SetupOpts { + mockSpaces?: Space[]; + namespaces?: string[]; + returnBeforeSpacesLoad?: boolean; +} + +const setup = async (opts: SetupOpts = {}) => { + const onClose = jest.fn(); + const onObjectUpdated = jest.fn(); + + const mockSpacesManager = spacesManagerMock.create(); + + mockSpacesManager.getActiveSpace.mockResolvedValue({ + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }); + + mockSpacesManager.getSpaces.mockResolvedValue( + opts.mockSpaces || [ + { + id: 'space-1', + name: 'Space 1', + disabledFeatures: [], + }, + { + id: 'space-2', + name: 'Space 2', + disabledFeatures: [], + }, + { + id: 'space-3', + name: 'Space 3', + disabledFeatures: [], + }, + { + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }, + ] + ); + + const mockToastNotifications = { + addError: jest.fn(), + addSuccess: jest.fn(), + }; + const savedObjectToShare = { + type: 'dashboard', + id: 'my-dash', + references: [ + { + type: 'visualization', + id: 'my-viz', + name: 'My Viz', + }, + ], + meta: { icon: 'dashboard', title: 'foo' }, + namespaces: opts.namespaces || ['my-active-space', 'space-1'], + } as SavedObjectsManagementRecord; + + const wrapper = mountWithIntl( + + ); + + if (!opts.returnBeforeSpacesLoad) { + // Wait for spaces manager to complete and flyout to rerender + await act(async () => { + await nextTick(); + wrapper.update(); + }); + } + + return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare }; +}; + +describe('ShareToSpaceFlyout', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + it('waits for spaces to load', async () => { + const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + }); + + it('shows a message within an EuiEmptyPrompt when no spaces are available', async () => { + const { wrapper, onClose } = await setup({ mockSpaces: [] }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows a message within an EuiEmptyPrompt when only the active space is available', async () => { + const { wrapper, onClose } = await setup({ + mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('does not show a warning callout when the saved object has multiple namespaces', async () => { + const { wrapper, onClose } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows a warning callout when the saved object only has one namespace', async () => { + const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('does not show the Copy flyout by default', async () => { + const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows the Copy flyout if the the "Make a copy" button is clicked', async () => { + const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + const copyButton = findTestSubject(wrapper, 'sts-copy-button'); // this button is only present in the warning callout + + await act(async () => { + copyButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('handles errors thrown from shareSavedObjectsAdd API call', async () => { + const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); + + mockSpacesManager.shareSavedObjectAdd.mockImplementation(() => { + return Promise.reject(Boom.serverUnavailable('Something bad happened')); + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); + expect(mockSpacesManager.shareSavedObjectRemove).not.toHaveBeenCalled(); + expect(mockToastNotifications.addError).toHaveBeenCalled(); + }); + + it('handles errors thrown from shareSavedObjectsRemove API call', async () => { + const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); + + mockSpacesManager.shareSavedObjectRemove.mockImplementation(() => { + return Promise.reject(Boom.serverUnavailable('Something bad happened')); + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); + expect(mockSpacesManager.shareSavedObjectRemove).toHaveBeenCalled(); + expect(mockToastNotifications.addError).toHaveBeenCalled(); + }); + + it('allows the form to be filled out to add a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-1', 'space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); + expect(shareSavedObjectRemove).not.toHaveBeenCalled(); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('allows the form to be filled out to remove a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange([]); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).not.toHaveBeenCalled(); + expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('allows the form to be filled out to add and remove a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); + expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(2); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx new file mode 100644 index 0000000000000..10cc5777cdcff --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx @@ -0,0 +1,268 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState, useEffect } from 'react'; +import { + EuiFlyout, + EuiIcon, + EuiFlyoutHeader, + EuiTitle, + EuiText, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiEmptyPrompt, + EuiButton, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ToastsStart } from 'src/core/public'; +import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; +import { Space } from '../../../common/model/space'; +import { SpacesManager } from '../../spaces_manager'; +import { ShareToSpaceForm } from './share_to_space_form'; +import { ShareOptions, SpaceTarget } from '../types'; +import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; + +interface Props { + onClose: () => void; + onObjectUpdated: () => void; + savedObject: SavedObjectsManagementRecord; + spacesManager: SpacesManager; + toastNotifications: ToastsStart; +} + +const arraysAreEqual = (a: unknown[], b: unknown[]) => + a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); + +export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { + const { onClose, onObjectUpdated, savedObject, spacesManager, toastNotifications } = props; + const { namespaces: currentNamespaces = [] } = savedObject; + const [shareOptions, setShareOptions] = useState({ selectedSpaceIds: [] }); + const [showMakeCopy, setShowMakeCopy] = useState(false); + + const [{ isLoading, spaces }, setSpacesState] = useState<{ + isLoading: boolean; + spaces: SpaceTarget[]; + }>({ isLoading: true, spaces: [] }); + useEffect(() => { + const getSpaces = spacesManager.getSpaces('shareSavedObjectsIntoSpace'); + const getActiveSpace = spacesManager.getActiveSpace(); + Promise.all([getSpaces, getActiveSpace]) + .then(([allSpaces, activeSpace]) => { + const createSpaceTarget = (space: Space): SpaceTarget => ({ + ...space, + isActiveSpace: space.id === activeSpace.id, + }); + setSpacesState({ + isLoading: false, + spaces: allSpaces.map((space) => createSpaceTarget(space)), + }); + setShareOptions({ + selectedSpaceIds: currentNamespaces.filter((spaceId) => spaceId !== activeSpace.id), + }); + }) + .catch((e) => { + toastNotifications.addError(e, { + title: i18n.translate('xpack.spaces.management.shareToSpace.spacesLoadErrorTitle', { + defaultMessage: 'Error loading available spaces', + }), + }); + }); + }, [currentNamespaces, spacesManager, toastNotifications]); + + const getSelectionChanges = () => { + const activeSpace = spaces.find((space) => space.isActiveSpace); + if (!activeSpace) { + return { changed: false, spacesToAdd: [], spacesToRemove: [] }; + } + const initialSelection = currentNamespaces.filter( + (spaceId) => spaceId !== activeSpace.id && spaceId !== '?' + ); + const { selectedSpaceIds } = shareOptions; + const changed = !arraysAreEqual(initialSelection, selectedSpaceIds); + const spacesToAdd = selectedSpaceIds.filter((spaceId) => !initialSelection.includes(spaceId)); + const spacesToRemove = initialSelection.filter( + (spaceId) => !selectedSpaceIds.includes(spaceId) + ); + return { changed, spacesToAdd, spacesToRemove }; + }; + const { changed: isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); + + const [shareInProgress, setShareInProgress] = useState(false); + + async function startShare() { + setShareInProgress(true); + try { + const { type, id, meta } = savedObject; + const title = + currentNamespaces.length === 1 + ? i18n.translate('xpack.spaces.management.shareToSpace.shareNewSuccessTitle', { + defaultMessage: 'Saved Object is now shared!', + }) + : i18n.translate('xpack.spaces.management.shareToSpace.shareEditSuccessTitle', { + defaultMessage: 'Saved Object updated', + }); + if (spacesToAdd.length > 0) { + await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); + const spaceNames = spacesToAdd.map( + (spaceId) => spaces.find((space) => space.id === spaceId)!.name + ); + const text = i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessText', { + defaultMessage: `'{object}' was added to the following spaces:\n{spaces}`, + values: { object: meta.title, spaces: spaceNames.join(', ') }, + }); + toastNotifications.addSuccess({ title, text }); + } + if (spacesToRemove.length > 0) { + await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); + const spaceNames = spacesToRemove.map( + (spaceId) => spaces.find((space) => space.id === spaceId)!.name + ); + const text = i18n.translate('xpack.spaces.management.shareToSpace.shareRemoveSuccessText', { + defaultMessage: `'{object}' was removed from the following spaces:\n{spaces}`, + values: { object: meta.title, spaces: spaceNames.join(', ') }, + }); + toastNotifications.addSuccess({ title, text }); + } + onObjectUpdated(); + onClose(); + } catch (e) { + setShareInProgress(false); + toastNotifications.addError(e, { + title: i18n.translate('xpack.spaces.management.shareToSpace.shareErrorTitle', { + defaultMessage: 'Error updating saved object', + }), + }); + } + } + + const getFlyoutBody = () => { + // Step 1: loading assets for main form + if (isLoading) { + return ; + } + + // Step 1a: assets loaded, but no spaces are available for share. + // The `spaces` array includes the current space, so at minimum it will have a length of 1. + if (spaces.length < 2) { + return ( + + +

+ } + title={ +

+ +

+ } + /> + ); + } + + const showShareWarning = currentNamespaces.length === 1; + // Step 2: Share has not been initiated yet; User must fill out form to continue. + return ( + setShowMakeCopy(true)} + /> + ); + }; + + if (showMakeCopy) { + return ( + + ); + } + + return ( + + + + + + + + +

+ +

+
+
+
+
+ + + + + + + +

{savedObject.meta.title}

+
+
+
+ + + + {getFlyoutBody()} +
+ + + + + onClose()} + data-test-subj="sts-cancel-button" + disabled={shareInProgress} + > + + + + + startShare()} + data-test-subj="sts-initiate-button" + disabled={!isSelectionChanged || shareInProgress} + > + + + + + +
+ ); +}; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.scss b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.scss similarity index 74% rename from x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.scss rename to x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.scss index 87af5d83629a9..41a9c907de745 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.scss +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.scss @@ -1,10 +1,10 @@ // make icon occupy the same space as an EuiSwitch // icon is size m, which is the native $euiSize value // see @elastic/eui/src/components/icon/_variables.scss -.spcCopyToSpaceIncludeRelated .euiIcon { +.spcShareToSpaceIncludeRelated .euiIcon { margin-right: $euiSwitchWidth - $euiSize; } -.spcCopyToSpaceIncludeRelated__label { +.spcShareToSpaceIncludeRelated__label { font-size: $euiFontSizeS; } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx new file mode 100644 index 0000000000000..24402fec8d771 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './share_to_space_form.scss'; +import React, { Fragment } from 'react'; +import { EuiHorizontalRule, EuiFormRow, EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ShareOptions, SpaceTarget } from '../types'; +import { SelectableSpacesControl } from './selectable_spaces_control'; + +interface Props { + spaces: SpaceTarget[]; + onUpdate: (shareOptions: ShareOptions) => void; + shareOptions: ShareOptions; + showShareWarning: boolean; + makeCopy: () => void; +} + +export const ShareToSpaceForm = (props: Props) => { + const setSelectedSpaceIds = (selectedSpaceIds: string[]) => + props.onUpdate({ ...props.shareOptions, selectedSpaceIds }); + + const getShareWarning = () => { + if (!props.showShareWarning) { + return null; + } + + return ( + + + } + color="warning" + > + + + props.makeCopy()} + color="warning" + data-test-subj="sts-copy-button" + size="s" + > + + + + + + + ); + }; + + return ( +
+ {getShareWarning()} + + + } + labelAppend={ + + } + fullWidth + > + setSelectedSpaceIds(selection)} + /> + +
+ ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts new file mode 100644 index 0000000000000..037fcb684b47d --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space_service'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx new file mode 100644 index 0000000000000..ba9a6473999df --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.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; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'src/core/public'; +import { + SavedObjectsManagementAction, + SavedObjectsManagementRecord, +} from '../../../../../src/plugins/saved_objects_management/public'; +import { ShareSavedObjectsToSpaceFlyout } from './components'; +import { SpacesManager } from '../spaces_manager'; + +export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManagementAction { + public id: string = 'share_saved_objects_to_space'; + + public euiAction = { + name: i18n.translate('xpack.spaces.management.shareToSpace.actionTitle', { + defaultMessage: 'Share to space', + }), + description: i18n.translate('xpack.spaces.management.shareToSpace.actionDescription', { + defaultMessage: 'Share this saved object to one or more spaces', + }), + icon: 'share', + type: 'icon', + available: (object: SavedObjectsManagementRecord) => { + return object.meta.namespaceType === 'multiple'; + }, + onClick: (object: SavedObjectsManagementRecord) => { + this.isDataChanged = false; + this.start(object); + }, + }; + public refreshOnFinish = () => this.isDataChanged; + + private isDataChanged: boolean = false; + + constructor( + private readonly spacesManager: SpacesManager, + private readonly notifications: NotificationsStart + ) { + super(); + } + + public render = () => { + if (!this.record) { + throw new Error('No record available! `render()` was likely called before `start()`.'); + } + + return ( + (this.isDataChanged = true)} + savedObject={this.record} + spacesManager={this.spacesManager} + toastNotifications={this.notifications.toasts} + /> + ); + }; + + private onClose = () => { + this.finish(); + }; +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx new file mode 100644 index 0000000000000..e8649faa120be --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState, ReactNode } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBadge } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + SavedObjectsManagementColumn, + SavedObjectsManagementRecord, +} from '../../../../../src/plugins/saved_objects_management/public'; +import { SpaceTarget } from './types'; +import { SpacesManager } from '../spaces_manager'; +import { getSpaceColor } from '..'; + +const SPACES_DISPLAY_COUNT = 5; + +type SpaceMap = Map; +interface ColumnDataProps { + namespaces?: string[]; + data?: SpaceMap; +} + +const ColumnDisplay = ({ namespaces, data }: ColumnDataProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + if (!data) { + return null; + } + + const authorized = namespaces?.filter((namespace) => namespace !== '?') ?? []; + const authorizedSpaceTargets: SpaceTarget[] = []; + authorized.forEach((namespace) => { + const spaceTarget = data.get(namespace); + if (spaceTarget === undefined) { + // in the event that a new space was created after this page has loaded, fall back to displaying the space ID + authorizedSpaceTargets.push({ + id: namespace, + name: namespace, + disabledFeatures: [], + isActiveSpace: false, + }); + } else if (!spaceTarget.isActiveSpace) { + authorizedSpaceTargets.push(spaceTarget); + } + }); + const unauthorizedCount = (namespaces?.filter((namespace) => namespace === '?') ?? []).length; + const unauthorizedTooltip = i18n.translate( + 'xpack.spaces.management.shareToSpace.columnUnauthorizedLabel', + { defaultMessage: 'You do not have permission to view these spaces' } + ); + + const displayedSpaces = isExpanded + ? authorizedSpaceTargets + : authorizedSpaceTargets.slice(0, SPACES_DISPLAY_COUNT); + const showButton = authorizedSpaceTargets.length > SPACES_DISPLAY_COUNT; + + const unauthorizedCountBadge = + (isExpanded || !showButton) && unauthorizedCount > 0 ? ( + + + +{unauthorizedCount} + + + ) : null; + + let button: ReactNode = null; + if (showButton) { + button = isExpanded ? ( + setIsExpanded(false)}> + + + ) : ( + setIsExpanded(true)}> + + + ); + } + + return ( + + {displayedSpaces.map(({ id, name, color }) => ( + + {name} + + ))} + {unauthorizedCountBadge} + {button} + + ); +}; + +export class ShareToSpaceSavedObjectsManagementColumn + implements SavedObjectsManagementColumn { + public id: string = 'share_saved_objects_to_space'; + public data: Map | undefined; + + public euiColumn = { + field: 'namespaces', + name: i18n.translate('xpack.spaces.management.shareToSpace.columnTitle', { + defaultMessage: 'Shared spaces', + }), + description: i18n.translate('xpack.spaces.management.shareToSpace.columnDescription', { + defaultMessage: 'The other spaces that this object is currently shared to', + }), + render: (namespaces: string[] | undefined, _object: SavedObjectsManagementRecord) => ( + + ), + }; + + constructor(private readonly spacesManager: SpacesManager) {} + + public loadData = () => { + this.data = undefined; + return Promise.all([this.spacesManager.getSpaces(), this.spacesManager.getActiveSpace()]).then( + ([spaces, activeSpace]) => { + this.data = spaces + .map((space) => ({ + ...space, + isActiveSpace: space.id === activeSpace.id, + color: getSpaceColor(space), + })) + .reduce((acc, cur) => acc.set(cur.id, cur), new Map()); + return this.data; + } + ); + }; +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts new file mode 100644 index 0000000000000..33e978bc53287 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; +import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; +import { spacesManagerMock } from '../spaces_manager/mocks'; +import { ShareSavedObjectsToSpaceService } from '.'; +import { notificationServiceMock } from 'src/core/public/mocks'; +import { savedObjectsManagementPluginMock } from '../../../../../src/plugins/saved_objects_management/public/mocks'; + +describe('ShareSavedObjectsToSpaceService', () => { + describe('#setup', () => { + it('registers the ShareToSpaceSavedObjectsManagement Action and Column', () => { + const deps = { + spacesManager: spacesManagerMock.create(), + notificationsSetup: notificationServiceMock.createSetupContract(), + savedObjectsManagementSetup: savedObjectsManagementPluginMock.createSetupContract(), + }; + + const service = new ShareSavedObjectsToSpaceService(); + service.setup(deps); + + expect(deps.savedObjectsManagementSetup.actions.register).toHaveBeenCalledTimes(1); + expect(deps.savedObjectsManagementSetup.actions.register).toHaveBeenCalledWith( + expect.any(ShareToSpaceSavedObjectsManagementAction) + ); + + expect(deps.savedObjectsManagementSetup.columns.register).toHaveBeenCalledTimes(1); + expect(deps.savedObjectsManagementSetup.columns.register).toHaveBeenCalledWith( + expect.any(ShareToSpaceSavedObjectsManagementColumn) + ); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts new file mode 100644 index 0000000000000..220df421c74fc --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { NotificationsSetup } from 'src/core/public'; +import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; +import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; +import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; +import { SpacesManager } from '../spaces_manager'; + +interface SetupDeps { + spacesManager: SpacesManager; + savedObjectsManagementSetup: SavedObjectsManagementPluginSetup; + notificationsSetup: NotificationsSetup; +} + +export class ShareSavedObjectsToSpaceService { + public setup({ spacesManager, savedObjectsManagementSetup, notificationsSetup }: SetupDeps) { + const action = new ShareToSpaceSavedObjectsManagementAction(spacesManager, notificationsSetup); + const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); + savedObjectsManagementSetup.actions.register(action); + savedObjectsManagementSetup.columns.register(column); + } +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts new file mode 100644 index 0000000000000..fe41f4a5fadc8 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsImportRetry, SavedObjectsImportResponse } from 'src/core/public'; +import { Space } from '..'; + +export interface ShareOptions { + selectedSpaceIds: string[]; +} + +export type ImportRetry = Omit; + +export interface ShareSavedObjectsToSpaceResponse { + [spaceId: string]: SavedObjectsImportResponse; +} + +export interface SpaceTarget extends Space { + isActiveSpace: boolean; +} diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts index 6186ac7fd93be..f666c823bd365 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts @@ -18,6 +18,8 @@ function createSpacesManagerMock() { updateSpace: jest.fn().mockResolvedValue(undefined), deleteSpace: jest.fn().mockResolvedValue(undefined), copySavedObjects: jest.fn().mockResolvedValue(undefined), + shareSavedObjectAdd: jest.fn().mockResolvedValue(undefined), + shareSavedObjectRemove: jest.fn().mockResolvedValue(undefined), resolveCopySavedObjectsErrors: jest.fn().mockResolvedValue(undefined), redirectToSpaceSelector: jest.fn().mockResolvedValue(undefined), } as unknown) as jest.Mocked; diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts index ac5cb56084cfc..2daf9ab420efc 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts @@ -11,6 +11,8 @@ import { Space } from '../../common/model/space'; import { GetSpacePurpose } from '../../common/model/types'; import { CopySavedObjectsToSpaceResponse } from '../copy_saved_objects_to_space/types'; +type SavedObject = Pick; + export class SpacesManager { private activeSpace$: BehaviorSubject = new BehaviorSubject(null); @@ -72,9 +74,10 @@ export class SpacesManager { } public async copySavedObjects( - objects: Array>, + objects: SavedObject[], spaces: string[], includeReferences: boolean, + createNewCopies: boolean, overwrite: boolean ): Promise { return this.http.post('/api/spaces/_copy_saved_objects', { @@ -82,25 +85,39 @@ export class SpacesManager { objects, spaces, includeReferences, - overwrite, + ...(createNewCopies ? { createNewCopies } : { overwrite }), }), }); } public async resolveCopySavedObjectsErrors( - objects: Array>, + objects: SavedObject[], retries: unknown, - includeReferences: boolean + includeReferences: boolean, + createNewCopies: boolean ): Promise { return this.http.post(`/api/spaces/_resolve_copy_saved_objects_errors`, { body: JSON.stringify({ objects, includeReferences, + createNewCopies, retries, }), }); } + public async shareSavedObjectAdd(object: SavedObject, spaces: string[]): Promise { + return this.http.post(`/api/spaces/_share_saved_object_add`, { + body: JSON.stringify({ object, spaces }), + }); + } + + public async shareSavedObjectRemove(object: SavedObject, spaces: string[]): Promise { + return this.http.post(`/api/spaces/_share_saved_object_remove`, { + body: JSON.stringify({ object, spaces }), + }); + } + public redirectToSpaceSelector() { window.location.href = `${this.serverBasePath}/spaces/space_selector`; } diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap b/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap index c2df94a0a2936..9544d7e8bb481 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap +++ b/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap @@ -28,6 +28,8 @@ exports[`#getAll useRbacForRequest is true with purpose='copySavedObjectsIntoSpa exports[`#getAll useRbacForRequest is true with purpose='findSavedObjects' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; +exports[`#getAll useRbacForRequest is true with purpose='shareSavedObjectsIntoSpace' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; + exports[`#getAll useRbacForRequest is true with purpose='undefined' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; exports[`#update useRbacForRequest is true throws Boom.forbidden when user isn't authorized at space 1`] = `"Unauthorized to update spaces"`; diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts index 61b1985c5a0b9..90ce2b01bfd20 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts @@ -242,6 +242,11 @@ describe('#getAll', () => { expectedPrivilege: (mockAuthorization: SecurityPluginSetup['authz']) => mockAuthorization.actions.savedObject.get('config', 'find'), }, + { + purpose: 'shareSavedObjectsIntoSpace' as GetSpacePurpose, + expectedPrivilege: (mockAuthorization: SecurityPluginSetup['authz']) => + mockAuthorization.actions.ui.get('savedObjectsManagement', 'shareIntoSpace'), + }, ].forEach((scenario) => { describe(`with purpose='${scenario.purpose}'`, () => { test(`throws Boom.forbidden when user isn't authorized for any spaces`, async () => { diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index dd2e0d40f31ed..b1d6e3200ab3a 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -17,6 +17,7 @@ const SUPPORTED_GET_SPACE_PURPOSES: GetSpacePurpose[] = [ 'any', 'copySavedObjectsIntoSpace', 'findSavedObjects', + 'shareSavedObjectsIntoSpace', ]; const PURPOSE_PRIVILEGE_MAP: Record< @@ -30,6 +31,9 @@ const PURPOSE_PRIVILEGE_MAP: Record< findSavedObjects: (authorization) => { return [authorization.actions.savedObject.get('config', 'find')]; }, + shareSavedObjectsIntoSpace: (authorization) => [ + authorization.actions.ui.get('savedObjectsManagement', 'shareIntoSpace'), + ], }; export class SpacesClient { diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts index 804820cd42b92..fef1646067fde 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts @@ -124,6 +124,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { overwrite: schema.boolean({ defaultValue: false }), destinationId: schema.maybe(schema.string()), createNewCopy: schema.maybe(schema.boolean()), + ignoreMissingReferences: schema.maybe(schema.boolean()), }) ) ), diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts index ec841808f771d..a9b701a8ea395 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts @@ -119,6 +119,22 @@ describe('GET /spaces/space', () => { expect(response.payload).toEqual(spaces); }); + it(`returns all available spaces with the 'shareSavedObjectsIntoSpace' purpose`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + query: { + purpose: 'shareSavedObjectsIntoSpace', + }, + method: 'get', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload).toEqual(spaces); + }); + it(`returns http/403 when the license is invalid`, async () => { const { routeHandler } = await setup(); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.ts index cd1e03eb10b0a..088409471fa55 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.ts @@ -19,7 +19,11 @@ export function initGetAllSpacesApi(deps: ExternalRouteDeps) { validate: { query: schema.object({ purpose: schema.oneOf( - [schema.literal('any'), schema.literal('copySavedObjectsIntoSpace')], + [ + schema.literal('any'), + schema.literal('copySavedObjectsIntoSpace'), + schema.literal('shareSavedObjectsIntoSpace'), + ], { defaultValue: 'any', } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8c92e7359b2f7..b5355b8ec5159 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2960,10 +2960,6 @@ "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.retryingFailedObjectsLoadingMessage": "失敗したオブジェクトを再試行中…", "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savedSearchAreLinkedProperlyLoadingMessage": "保存された検索が正しくリンクされていることを確認してください…", "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savingConflictsLoadingMessage": "矛盾を保存中…", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteBody": "{title}を上書きしてよろしいですか?", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteCancelButtonText": "キャンセル", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteOverwriteButtonText": "上書き", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteTitle": "{type}を上書きしますか?", "savedObjectsManagement.objectsTable.flyout.errorCalloutTitle": "申し訳ございません、エラーが発生しました", "savedObjectsManagement.objectsTable.flyout.import.cancelButtonLabel": "キャンセル", "savedObjectsManagement.objectsTable.flyout.import.confirmButtonLabel": "インポート", @@ -2986,7 +2982,6 @@ "savedObjectsManagement.objectsTable.flyout.invalidFormatOfImportedFileErrorMessage": "保存されたオブジェクトのファイル形式が無効なため、インポートできません。", "savedObjectsManagement.objectsTable.flyout.legacyFileUsedBody": "最新のレポートでNDJSONファイルを作成すれば完了です。", "savedObjectsManagement.objectsTable.flyout.legacyFileUsedTitle": "JSONファイルのサポートが終了します", - "savedObjectsManagement.objectsTable.flyout.overwriteSavedObjectsLabel": "すべての保存されたオブジェクトを自動的に上書きしますか?", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountDescription": "影響されるオブジェクトの数です", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountName": "カウント", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription": "インデックスパターンのIDです", @@ -17934,16 +17929,8 @@ "xpack.spaces.management.confirmDeleteModal.deletingSpaceWarningMessage": "スペースを削除すると、スペースと {allContents} が永久に削除されます。この操作は元に戻すことができません。", "xpack.spaces.management.confirmDeleteModal.redirectAfterDeletingCurrentSpaceWarningMessage": "現在のスペース {name} を削除しようとしています。続行すると、別のスペースを選択する画面に移動します。", "xpack.spaces.management.confirmDeleteModal.spaceNamesDoNoMatchErrorMessage": "スペース名が一致していません。", - "xpack.spaces.management.copyToSpace.actionDescription": "この保存されたオブジェクトを1つまたは複数のスペースにコピーします。", - "xpack.spaces.management.copyToSpace.actionTitle": "スペースにコピー", - "xpack.spaces.management.copyToSpace.automaticallyOverwrite": "すべての保存されたオブジェクトを自動的に上書き", - "xpack.spaces.management.copyToSpace.copyDetail.overwriteButton": "上書き", - "xpack.spaces.management.copyToSpace.copyDetail.skipOverwriteButton": "スキップ", "xpack.spaces.management.copyToSpace.copyErrorTitle": "保存されたオブジェクトのコピー中にエラーが発生", - "xpack.spaces.management.copyToSpace.copyResultsLabel": "コピー結果", "xpack.spaces.management.copyToSpace.copyStatus.conflictsMessage": "このスペースには同じID({id})の保存されたオブジェクトが既に存在します。", - "xpack.spaces.management.copyToSpace.copyStatus.conflictsOverwriteMessage": "「上書き」をクリックしてこのバージョンをコピーされたバージョンに置き換えます。", - "xpack.spaces.management.copyToSpace.copyStatus.pendingOverwriteMessage": "保存されたオブジェクトは上書きされます。「スキップ」をクリックしてこの操作をキャンセルします。", "xpack.spaces.management.copyToSpace.copyStatus.successMessage": "保存されたオブジェクトがコピーされました。", "xpack.spaces.management.copyToSpace.copyStatus.unresolvableErrorMessage": "この保存されたオブジェクトのコピー中にエラーが発生しました。", "xpack.spaces.management.copyToSpace.copyStatusSummary.conflictsMessage": "{space}スペースに1つまたは複数の矛盾が検出されました。解決するにはこのセクションを拡張してください。", @@ -17951,26 +17938,18 @@ "xpack.spaces.management.copyToSpace.copyStatusSummary.successMessage": "{space}スペースにコピーされました。", "xpack.spaces.management.copyToSpace.copyToSpacesButton": "{spaceCount} {spaceCount, plural, one {スペース} other {スペース}}にコピー", "xpack.spaces.management.copyToSpace.disabledCopyToSpacesButton": "コピー", - "xpack.spaces.management.copyToSpace.dontIncludeRelatedLabel": "関連性のある保存されたオブジェクトを含みません", - "xpack.spaces.management.copyToSpace.dontOverwriteLabel": "保存されたオブジェクトを上書きしません", "xpack.spaces.management.copyToSpace.finishCopyToSpacesButton": "終了", "xpack.spaces.management.copyToSpace.finishedButtonLabel": "コピーが完了しました。", - "xpack.spaces.management.copyToSpace.finishPendingOverwritesCopyToSpacesButton": "{overwriteCount}件のオブジェクトを上書き", - "xpack.spaces.management.copyToSpace.includeRelatedFormLabel": "関連性のある保存されたオブジェクトを含みます", - "xpack.spaces.management.copyToSpace.includeRelatedLabel": "関連性のある保存されたオブジェクトを含みます", "xpack.spaces.management.copyToSpace.inProgressButtonLabel": "コピーが進行中です。お待ちください。", "xpack.spaces.management.copyToSpace.noSpacesBody": "コピーできるスペースがありません。", "xpack.spaces.management.copyToSpace.noSpacesTitle": "スペースがありません", "xpack.spaces.management.copyToSpace.overwriteLabel": "保存されたオブジェクトを自動的に上書きしています", "xpack.spaces.management.copyToSpace.resolveCopyErrorTitle": "保存されたオブジェクトの矛盾の解決中にエラーが発生", - "xpack.spaces.management.copyToSpace.resolveCopySuccessTitle": "上書き成功", - "xpack.spaces.management.copyToSpace.selectSpacesLabel": "コピー先のスペースを選択してください", "xpack.spaces.management.copyToSpace.spacesLoadErrorTitle": "利用可能なスペースを読み込み中にエラーが発生", "xpack.spaces.management.copyToSpaceFlyoutFooter.conflictCount": "スキップ", "xpack.spaces.management.copyToSpaceFlyoutFooter.errorCount": "エラー", "xpack.spaces.management.copyToSpaceFlyoutFooter.pendingCount": "保留中", "xpack.spaces.management.copyToSpaceFlyoutFooter.successCount": "コピー完了", - "xpack.spaces.management.copyToSpaceFlyoutHeader": "保存されたオブジェクトのスペースへのコピー", "xpack.spaces.management.createSpaceBreadcrumb": "作成", "xpack.spaces.management.customizeSpaceAvatar.colorFormRowLabel": "色", "xpack.spaces.management.customizeSpaceAvatar.imageUrl": "カスタム画像", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5ab70ff7a9d04..475d4083f82f6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2961,10 +2961,6 @@ "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.retryingFailedObjectsLoadingMessage": "正在重试失败的对象……", "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savedSearchAreLinkedProperlyLoadingMessage": "确保已保存搜索已正确链接……", "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savingConflictsLoadingMessage": "正在保存冲突……", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteBody": "确定要覆盖“{title}”?", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteCancelButtonText": "取消", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteOverwriteButtonText": "覆盖", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteTitle": "覆盖“{type}”?", "savedObjectsManagement.objectsTable.flyout.errorCalloutTitle": "抱歉,有错误", "savedObjectsManagement.objectsTable.flyout.import.cancelButtonLabel": "取消", "savedObjectsManagement.objectsTable.flyout.import.confirmButtonLabel": "导入", @@ -2987,7 +2983,6 @@ "savedObjectsManagement.objectsTable.flyout.invalidFormatOfImportedFileErrorMessage": "已保存对象文件格式无效,无法导入。", "savedObjectsManagement.objectsTable.flyout.legacyFileUsedBody": "只需使用更新的导出功能生成 NDJSON 文件,便万事俱备。", "savedObjectsManagement.objectsTable.flyout.legacyFileUsedTitle": "将不再支持 JSON 文件", - "savedObjectsManagement.objectsTable.flyout.overwriteSavedObjectsLabel": "自动覆盖所有已保存对象?", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountDescription": "受影响对象数目", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountName": "计数", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription": "索引模式的 ID", @@ -17941,16 +17936,8 @@ "xpack.spaces.management.confirmDeleteModal.deletingSpaceWarningMessage": "删除空间会永久删除空间及其 {allContents}。此操作无法撤消。", "xpack.spaces.management.confirmDeleteModal.redirectAfterDeletingCurrentSpaceWarningMessage": "您即将删除当前空间 {name}。如果继续,系统会将您重定向到选择其他空间的位置。", "xpack.spaces.management.confirmDeleteModal.spaceNamesDoNoMatchErrorMessage": "空间名称不匹配。", - "xpack.spaces.management.copyToSpace.actionDescription": "将此已保存对象复制到一个或多个工作区", - "xpack.spaces.management.copyToSpace.actionTitle": "复制到工作区", - "xpack.spaces.management.copyToSpace.automaticallyOverwrite": "自动覆盖所有已保存对象", - "xpack.spaces.management.copyToSpace.copyDetail.overwriteButton": "覆盖", - "xpack.spaces.management.copyToSpace.copyDetail.skipOverwriteButton": "跳过", "xpack.spaces.management.copyToSpace.copyErrorTitle": "复制已保存对象时出错", - "xpack.spaces.management.copyToSpace.copyResultsLabel": "复制结果", "xpack.spaces.management.copyToSpace.copyStatus.conflictsMessage": "具有匹配 ID ({id}) 的已保存对象在此工作区中已存在。", - "xpack.spaces.management.copyToSpace.copyStatus.conflictsOverwriteMessage": "单击“覆盖”可将此版本替换为复制的版本。", - "xpack.spaces.management.copyToSpace.copyStatus.pendingOverwriteMessage": "已保存对象将被覆盖。单击“跳过”可取消此操作。", "xpack.spaces.management.copyToSpace.copyStatus.successMessage": "已保存对象成功复制。", "xpack.spaces.management.copyToSpace.copyStatus.unresolvableErrorMessage": "复制此已保存对象时出错。", "xpack.spaces.management.copyToSpace.copyStatusSummary.conflictsMessage": "在 {space} 工作区中检测到一个或多个冲突。展开此部分以进行解决。", @@ -17958,26 +17945,18 @@ "xpack.spaces.management.copyToSpace.copyStatusSummary.successMessage": "已成功复制到 {space} 工作区。", "xpack.spaces.management.copyToSpace.copyToSpacesButton": "复制到 {spaceCount} {spaceCount, plural, one {个工作区} other {个工作区}}", "xpack.spaces.management.copyToSpace.disabledCopyToSpacesButton": "复制", - "xpack.spaces.management.copyToSpace.dontIncludeRelatedLabel": "不包括相关已保存对象", - "xpack.spaces.management.copyToSpace.dontOverwriteLabel": "未覆盖已保存对象", "xpack.spaces.management.copyToSpace.finishCopyToSpacesButton": "完成", "xpack.spaces.management.copyToSpace.finishedButtonLabel": "复制已完成。", - "xpack.spaces.management.copyToSpace.finishPendingOverwritesCopyToSpacesButton": "覆盖 {overwriteCount} 个对象", - "xpack.spaces.management.copyToSpace.includeRelatedFormLabel": "包括相关已保存对象", - "xpack.spaces.management.copyToSpace.includeRelatedLabel": "包括相关已保存对象", "xpack.spaces.management.copyToSpace.inProgressButtonLabel": "复制正在进行中。请稍候。", "xpack.spaces.management.copyToSpace.noSpacesBody": "没有可向其中进行复制的合格工作区。", "xpack.spaces.management.copyToSpace.noSpacesTitle": "没有可用的工作区", "xpack.spaces.management.copyToSpace.overwriteLabel": "正在自动覆盖已保存对象", "xpack.spaces.management.copyToSpace.resolveCopyErrorTitle": "解决已保存对象冲突时出错", - "xpack.spaces.management.copyToSpace.resolveCopySuccessTitle": "覆盖成功", - "xpack.spaces.management.copyToSpace.selectSpacesLabel": "选择要向其中进行复制的工作区", "xpack.spaces.management.copyToSpace.spacesLoadErrorTitle": "加载可用工作区时出错", "xpack.spaces.management.copyToSpaceFlyoutFooter.conflictCount": "已跳过", "xpack.spaces.management.copyToSpaceFlyoutFooter.errorCount": "错误", "xpack.spaces.management.copyToSpaceFlyoutFooter.pendingCount": "待处理", "xpack.spaces.management.copyToSpaceFlyoutFooter.successCount": "已复制", - "xpack.spaces.management.copyToSpaceFlyoutHeader": "将已保存对象复制到工作区", "xpack.spaces.management.createSpaceBreadcrumb": "创建", "xpack.spaces.management.customizeSpaceAvatar.colorFormRowLabel": "颜色", "xpack.spaces.management.customizeSpaceAvatar.imageUrl": "定制图像", diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts index 0482711ad2bf1..6d294aed9b4de 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts @@ -98,6 +98,9 @@ export function resolveImportErrorsTestSuiteFactory( const expectResponseBody = ( testCases: ResolveImportErrorsTestCase | ResolveImportErrorsTestCase[], statusCode: 200 | 403, + singleRequest: boolean, + overwrite: boolean, + createNewCopies: boolean, spaceId = SPACES.DEFAULT.spaceId ): ExpectResponseBody => async (response: Record) => { const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; @@ -150,13 +153,15 @@ export function resolveImportErrorsTestSuiteFactory( expect(createNewCopy).to.be(undefined); } - const { _source } = await expectResponses.successCreated( - es, - spaceId, - type, - destinationId ?? id - ); - expect(_source[type][NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + if (!singleRequest || overwrite || createNewCopies) { + const { _source } = await expectResponses.successCreated( + es, + spaceId, + type, + destinationId ?? id + ); + expect(_source[type][NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + } } for (let i = 0; i < expectedFailures.length; i++) { const { type, id, failure, expectedNewId } = expectedFailures[i]; @@ -204,7 +209,9 @@ export function resolveImportErrorsTestSuiteFactory( title: getTestTitle(x, responseStatusCode), request: createRequest(x, overwrite), responseStatusCode, - responseBody: responseBodyOverride || expectResponseBody(x, responseStatusCode, spaceId), + responseBody: + responseBodyOverride || + expectResponseBody(x, responseStatusCode, false, overwrite, createNewCopies, spaceId), overwrite, createNewCopies, })); @@ -221,7 +228,8 @@ export function resolveImportErrorsTestSuiteFactory( })), responseStatusCode, responseBody: - responseBodyOverride || expectResponseBody(cases, responseStatusCode, spaceId), + responseBodyOverride || + expectResponseBody(cases, responseStatusCode, true, overwrite, createNewCopies, spaceId), overwrite, createNewCopies, }, diff --git a/x-pack/test/spaces_api_integration/common/fixtures/spaces_test_plugin/server/plugin.ts b/x-pack/test/spaces_api_integration/common/fixtures/spaces_test_plugin/server/plugin.ts index ed45f0870a45c..0e63e1bc19954 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/spaces_test_plugin/server/plugin.ts +++ b/x-pack/test/spaces_api_integration/common/fixtures/spaces_test_plugin/server/plugin.ts @@ -16,6 +16,7 @@ export class Plugin { hidden: false, namespaceType: 'multiple', management: { + icon: 'beaker', importableAndExportable: true, getTitle(obj) { return obj.attributes.title; diff --git a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts index 2af2020794db4..26c736034501f 100644 --- a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts @@ -134,32 +134,38 @@ export function copyToSpaceTestSuiteFactory( const createExpectNoConflictsWithoutReferencesForSpace = ( spaceId: string, + destination: string, expectedDashboardCount: number ) => async (resp: TestResponse) => { const result = resp.body as CopyResponse; expect(result).to.eql({ - [spaceId]: { + [destination]: { success: true, successCount: 1, - successResults: [{ id: 'cts_dashboard', type: 'dashboard' }], + successResults: [ + { + id: 'cts_dashboard', + type: 'dashboard', + meta: { + title: `This is the ${spaceId} test space CTS dashboard`, + icon: 'dashboardApp', + }, + }, + ], }, } as CopyResponse); // Query ES to ensure that we copied everything we expected - await assertSpaceCounts(spaceId, { + await assertSpaceCounts(destination, { dashboard: expectedDashboardCount, }); }; - const expectNoConflictsWithoutReferencesResult = createExpectNoConflictsWithoutReferencesForSpace( - getDestinationWithoutConflicts(), - 2 - ); + const expectNoConflictsWithoutReferencesResult = (spaceId: string = DEFAULT_SPACE_ID) => + createExpectNoConflictsWithoutReferencesForSpace(spaceId, getDestinationWithoutConflicts(), 2); - const expectNoConflictsForNonExistentSpaceResult = createExpectNoConflictsWithoutReferencesForSpace( - 'non_existent_space', - 1 - ); + const expectNoConflictsForNonExistentSpaceResult = (spaceId: string = DEFAULT_SPACE_ID) => + createExpectNoConflictsWithoutReferencesForSpace(spaceId, 'non_existent_space', 1); const expectNoConflictsWithReferencesResult = (spaceId: string = DEFAULT_SPACE_ID) => async ( resp: TestResponse @@ -171,11 +177,37 @@ export function copyToSpaceTestSuiteFactory( success: true, successCount: 5, successResults: [ - { id: 'cts_ip_1', type: 'index-pattern' }, - { id: `cts_vis_1_${spaceId}`, type: 'visualization' }, - { id: `cts_vis_2_${spaceId}`, type: 'visualization' }, - { id: 'cts_vis_3', type: 'visualization' }, - { id: 'cts_dashboard', type: 'dashboard' }, + { + id: 'cts_ip_1', + type: 'index-pattern', + meta: { + icon: 'indexPatternApp', + title: `Copy to Space index pattern 1 from ${spaceId} space`, + }, + }, + { + id: `cts_vis_1_${spaceId}`, + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 1 from ${spaceId} space` }, + }, + { + id: `cts_vis_2_${spaceId}`, + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 2 from ${spaceId} space` }, + }, + { + id: 'cts_vis_3', + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 3 from ${spaceId} space` }, + }, + { + id: 'cts_dashboard', + type: 'dashboard', + meta: { + icon: 'dashboardApp', + title: `This is the ${spaceId} test space CTS dashboard`, + }, + }, ], }, } as CopyResponse); @@ -261,11 +293,40 @@ export function copyToSpaceTestSuiteFactory( success: true, successCount: 5, successResults: [ - { id: 'cts_ip_1', type: 'index-pattern' }, - { id: `cts_vis_1_${spaceId}`, type: 'visualization' }, - { id: `cts_vis_2_${spaceId}`, type: 'visualization' }, - { id: 'cts_vis_3', type: 'visualization' }, - { id: 'cts_dashboard', type: 'dashboard' }, + { + id: 'cts_ip_1', + type: 'index-pattern', + meta: { + icon: 'indexPatternApp', + title: `Copy to Space index pattern 1 from ${spaceId} space`, + }, + overwrite: true, + }, + { + id: `cts_vis_1_${spaceId}`, + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 1 from ${spaceId} space` }, + }, + { + id: `cts_vis_2_${spaceId}`, + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 2 from ${spaceId} space` }, + }, + { + id: 'cts_vis_3', + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 3 from ${spaceId} space` }, + overwrite: true, + }, + { + id: 'cts_dashboard', + type: 'dashboard', + meta: { + icon: 'dashboardApp', + title: `This is the ${spaceId} test space CTS dashboard`, + }, + overwrite: true, + }, ], }, } as CopyResponse); @@ -289,8 +350,16 @@ export function copyToSpaceTestSuiteFactory( result[destination].errors!.sort(errorSorter); const expectedSuccessResults = [ - { id: `cts_vis_1_${spaceId}`, type: 'visualization' }, - { id: `cts_vis_2_${spaceId}`, type: 'visualization' }, + { + id: `cts_vis_1_${spaceId}`, + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 1 from ${spaceId} space` }, + }, + { + id: `cts_vis_2_${spaceId}`, + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 2 from ${spaceId} space` }, + }, ]; const expectedErrors = [ { @@ -298,18 +367,30 @@ export function copyToSpaceTestSuiteFactory( id: 'cts_dashboard', title: `This is the ${spaceId} test space CTS dashboard`, type: 'dashboard', + meta: { + title: `This is the ${spaceId} test space CTS dashboard`, + icon: 'dashboardApp', + }, }, { error: { type: 'conflict' }, id: 'cts_ip_1', title: `Copy to Space index pattern 1 from ${spaceId} space`, type: 'index-pattern', + meta: { + title: `Copy to Space index pattern 1 from ${spaceId} space`, + icon: 'indexPatternApp', + }, }, { error: { type: 'conflict' }, id: 'cts_vis_3', title: `CTS vis 3 from ${spaceId} space`, type: 'visualization', + meta: { + title: `CTS vis 3 from ${spaceId} space`, + icon: 'visualizeApp', + }, }, ]; expectedErrors.sort(errorSorter); @@ -371,7 +452,8 @@ export function copyToSpaceTestSuiteFactory( expect(successCount).to.eql(1); const destinationId = successResults![0].destinationId; expect(destinationId).to.match(v4); - expect(successResults).to.eql([{ type, id: noConflictId, destinationId }]); + const meta = { title: 'A shared saved-object in one space', icon: 'beaker' }; + expect(successResults).to.eql([{ type, id: noConflictId, meta, destinationId }]); expect(errors).to.be(undefined); } else if (outcome === 'noAccess') { expectNotFoundResponse(response); @@ -388,22 +470,19 @@ export function copyToSpaceTestSuiteFactory( response: async (response: TestResponse) => { if (outcome === 'authorized') { const { success, successCount, successResults, errors } = getResult(response); + const title = 'A shared saved-object in the default, space_1, and space_2 spaces'; + const meta = { title, icon: 'beaker' }; if (overwrite) { expect(success).to.eql(true); expect(successCount).to.eql(1); - expect(successResults).to.eql([{ type, id: exactMatchId }]); + expect(successResults).to.eql([{ type, id: exactMatchId, meta, overwrite: true }]); expect(errors).to.be(undefined); } else { expect(success).to.eql(false); expect(successCount).to.eql(0); expect(successResults).to.be(undefined); expect(errors).to.eql([ - { - error: { type: 'conflict' }, - type, - id: exactMatchId, - title: 'A shared saved-object in the default, space_1, and space_2 spaces', - }, + { error: { type: 'conflict' }, type, id: exactMatchId, title, meta }, ]); } } else if (outcome === 'noAccess') { @@ -421,11 +500,15 @@ export function copyToSpaceTestSuiteFactory( response: async (response: TestResponse) => { if (outcome === 'authorized') { const { success, successCount, successResults, errors } = getResult(response); + const title = 'A shared saved-object in one space'; + const meta = { title, icon: 'beaker' }; const destinationId = 'conflict_1_space_2'; if (overwrite) { expect(success).to.eql(true); expect(successCount).to.eql(1); - expect(successResults).to.eql([{ type, id: inexactMatchId, destinationId }]); + expect(successResults).to.eql([ + { type, id: inexactMatchId, meta, overwrite: true, destinationId }, + ]); expect(errors).to.be(undefined); } else { expect(success).to.eql(false); @@ -436,7 +519,8 @@ export function copyToSpaceTestSuiteFactory( error: { type: 'conflict', destinationId }, type, id: inexactMatchId, - title: 'A shared saved-object in one space', + title, + meta, }, ]); } @@ -457,9 +541,9 @@ export function copyToSpaceTestSuiteFactory( const { success, successCount, successResults, errors } = getResult(response); const updatedAt = '2017-09-21T18:59:16.270Z'; const destinations = [ - // response should be sorted by ID in ascending order - { id: 'conflict_2_all', title: 'A shared saved-object in all spaces', updatedAt }, + // response should be sorted by updatedAt in descending order { id: 'conflict_2_space_2', title: 'A shared saved-object in one space', updatedAt }, + { id: 'conflict_2_all', title: 'A shared saved-object in all spaces', updatedAt }, ]; expect(success).to.eql(false); expect(successCount).to.eql(0); @@ -470,6 +554,10 @@ export function copyToSpaceTestSuiteFactory( type, id: ambiguousConflictId, title: 'A shared saved-object in one space', + meta: { + title: 'A shared saved-object in one space', + icon: 'beaker', + }, }, ]); } else if (outcome === 'noAccess') { diff --git a/x-pack/test/spaces_api_integration/common/suites/get_all.ts b/x-pack/test/spaces_api_integration/common/suites/get_all.ts index b6fb449e7b087..d41d73bba90bc 100644 --- a/x-pack/test/spaces_api_integration/common/suites/get_all.ts +++ b/x-pack/test/spaces_api_integration/common/suites/get_all.ts @@ -16,6 +16,7 @@ interface GetAllTest { interface GetAllTests { exists: GetAllTest; copySavedObjectsPurpose: GetAllTest; + shareSavedObjectsPurpose: GetAllTest; } interface GetAllTestDefinition { @@ -88,6 +89,17 @@ export function getAllTestSuiteFactory(esArchiver: any, supertest: SuperTest { + it(`should return ${tests.shareSavedObjectsPurpose.statusCode}`, async () => { + return supertest + .get(`${getUrlPrefix(spaceId)}/api/spaces/space`) + .query({ purpose: 'shareSavedObjectsIntoSpace' }) + .auth(user.username, user.password) + .expect(tests.copySavedObjectsPurpose.statusCode) + .then(tests.copySavedObjectsPurpose.response); + }); + }); }); }; diff --git a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts index 12fc3c371d208..cb9219b1ba2ed 100644 --- a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts @@ -83,7 +83,17 @@ export function resolveCopyToSpaceConflictsSuite( [destination]: { success: true, successCount: 1, - successResults: [{ id: 'cts_vis_3', type: 'visualization' }], + successResults: [ + { + id: 'cts_vis_3', + type: 'visualization', + meta: { + title: `CTS vis 3 from ${sourceSpaceId} space`, + icon: 'visualizeApp', + }, + overwrite: true, + }, + ], }, }); const [dashboard, visualization] = await getObjectsAtSpace(destination); @@ -102,7 +112,17 @@ export function resolveCopyToSpaceConflictsSuite( [destinationSpaceId]: { success: true, successCount: 1, - successResults: [{ id: 'cts_dashboard', type: 'dashboard' }], + successResults: [ + { + id: 'cts_dashboard', + type: 'dashboard', + meta: { + title: `This is the ${sourceSpaceId} test space CTS dashboard`, + icon: 'dashboardApp', + }, + overwrite: true, + }, + ], }, }); const [dashboard, visualization] = await getObjectsAtSpace(destinationSpaceId); @@ -131,6 +151,10 @@ export function resolveCopyToSpaceConflictsSuite( error: { type: 'conflict' }, id: 'cts_vis_3', title: `CTS vis 3 from ${sourceSpaceId} space`, + meta: { + title: `CTS vis 3 from ${sourceSpaceId} space`, + icon: 'visualizeApp', + }, type: 'visualization', }, ], @@ -158,8 +182,12 @@ export function resolveCopyToSpaceConflictsSuite( { error: { type: 'conflict' }, id: 'cts_dashboard', - title: `This is the ${sourceSpaceId} test space CTS dashboard`, type: 'dashboard', + title: `This is the ${sourceSpaceId} test space CTS dashboard`, + meta: { + title: `This is the ${sourceSpaceId} test space CTS dashboard`, + icon: 'dashboardApp', + }, }, ], }, @@ -304,7 +332,14 @@ export function resolveCopyToSpaceConflictsSuite( expect(success).to.eql(true); expect(successCount).to.eql(1); expect(errors).to.be(undefined); - expect(successResults).to.eql([{ type, id, ...(destinationId && { destinationId }) }]); + const title = + id === exactMatchId + ? 'A shared saved-object in the default, space_1, and space_2 spaces' + : 'A shared saved-object in one space'; + const meta = { title, icon: 'beaker' }; + expect(successResults).to.eql([ + { type, id, meta, overwrite: true, ...(destinationId && { destinationId }) }, + ]); }; return [ diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/copy_to_space.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/copy_to_space.ts index 2c4fc6d38d79d..0f1c27098af92 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/copy_to_space.ts @@ -139,7 +139,7 @@ export default function copyToSpaceSpacesAndSecuritySuite({ getService }: TestIn tests: { noConflictsWithoutReferences: { statusCode: 200, - response: expectNoConflictsWithoutReferencesResult, + response: expectNoConflictsWithoutReferencesResult(spaceId), }, noConflictsWithReferences: { statusCode: 200, @@ -160,7 +160,7 @@ export default function copyToSpaceSpacesAndSecuritySuite({ getService }: TestIn }, nonExistentSpace: { statusCode: 200, - response: expectNoConflictsForNonExistentSpaceResult, + response: expectNoConflictsForNonExistentSpaceResult(spaceId), }, multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'authorized'), }, diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts index e64f721825089..bf1d90bfc3556 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts @@ -88,6 +88,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -103,6 +107,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default', 'space_1', 'space_2'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, }, }); @@ -118,6 +126,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default', 'space_1', 'space_2'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, }, }); @@ -133,6 +145,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default', 'space_1', 'space_2'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, }, }); @@ -148,6 +164,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -163,6 +183,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -178,6 +202,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -193,6 +221,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('space_1'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('space_1'), + }, }, }); @@ -208,6 +240,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -225,6 +261,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default'), + }, }, } ); @@ -243,6 +283,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, } ); @@ -261,6 +305,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default'), + }, }, } ); @@ -279,6 +327,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, } ); @@ -297,6 +349,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('space_1'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('space_1'), + }, }, } ); @@ -315,6 +371,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, } ); @@ -331,6 +391,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -346,6 +410,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -361,6 +429,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -376,6 +448,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); }); diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/copy_to_space.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/copy_to_space.ts index 85efe797c7402..cc5bb9cf8c739 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/copy_to_space.ts @@ -31,7 +31,7 @@ export default function copyToSpacesOnlySuite({ getService }: FtrProviderContext tests: { noConflictsWithoutReferences: { statusCode: 200, - response: expectNoConflictsWithoutReferencesResult, + response: expectNoConflictsWithoutReferencesResult(spaceId), }, noConflictsWithReferences: { statusCode: 200, @@ -52,7 +52,7 @@ export default function copyToSpacesOnlySuite({ getService }: FtrProviderContext }, nonExistentSpace: { statusCode: 200, - response: expectNoConflictsForNonExistentSpaceResult, + response: expectNoConflictsForNonExistentSpaceResult(spaceId), }, multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId), }, diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.ts index 1e56a583eca1f..14c98aff262fe 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.ts @@ -38,6 +38,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default', 'space_1', 'space_2'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, }, }); });