From f2cb1a469a87cf934bfd3639c4e7baa5c37352d8 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 26 Jan 2021 14:18:28 -0500 Subject: [PATCH 01/29] Add namespaceType 'multiple-isolated' This will be used to convert saved objects in the 8.0 release. It will allow us to regenerate object IDs, create aliases, and force objects to use unique IDs across namespaces. However, objects of this type are "share-capable" but not shareable across multiple namespaces. --- ...migrating-legacy-plugins-examples.asciidoc | 2 +- .../core/public/kibana-plugin-core-public.md | 2 +- ...n-core-public.savedobjectsnamespacetype.md | 4 +- .../core/server/kibana-plugin-core-server.md | 2 +- ...n-core-server.savedobjectsnamespacetype.md | 4 +- ...type.converttomultinamespacetypeversion.md | 24 +- ...ana-plugin-core-server.savedobjectstype.md | 19 +- ...avedobjecttyperegistry.ismultinamespace.md | 2 +- ...ver.savedobjecttyperegistry.isshareable.md | 24 ++ ...gin-core-server.savedobjecttyperegistry.md | 3 +- src/core/public/public.api.md | 2 +- .../migrations/core/document_migrator.test.ts | 4 +- .../migrations/core/document_migrator.ts | 4 +- .../saved_objects_type_registry.mock.ts | 2 + .../saved_objects_type_registry.test.ts | 26 ++ .../saved_objects_type_registry.ts | 11 +- .../service/lib/repository.test.js | 238 ++++++++++++------ .../saved_objects/service/lib/repository.ts | 8 +- src/core/server/saved_objects/types.ts | 38 ++- src/core/server/server.api.md | 3 +- .../apis/saved_objects/migrations.ts | 2 +- .../saved_objects/spaces/data.json | 34 +++ .../saved_objects/spaces/mappings.json | 13 + .../saved_object_test_plugin/server/plugin.ts | 7 + .../common/lib/saved_object_test_cases.ts | 10 + .../common/lib/saved_object_test_utils.ts | 2 +- .../common/suites/export.ts | 21 ++ .../common/suites/find.ts | 7 + .../security_and_spaces/apis/bulk_create.ts | 10 + .../security_and_spaces/apis/bulk_get.ts | 5 + .../security_and_spaces/apis/bulk_update.ts | 5 + .../security_and_spaces/apis/create.ts | 8 + .../security_and_spaces/apis/delete.ts | 5 + .../security_and_spaces/apis/export.ts | 2 + .../security_and_spaces/apis/find.ts | 1 + .../security_and_spaces/apis/get.ts | 5 + .../security_and_spaces/apis/import.ts | 11 + .../apis/resolve_import_errors.ts | 11 + .../security_and_spaces/apis/update.ts | 5 + .../security_only/apis/bulk_create.ts | 2 + .../security_only/apis/bulk_get.ts | 2 + .../security_only/apis/bulk_update.ts | 2 + .../security_only/apis/create.ts | 2 + .../security_only/apis/delete.ts | 2 + .../security_only/apis/export.ts | 2 + .../security_only/apis/find.ts | 1 + .../security_only/apis/get.ts | 2 + .../security_only/apis/import.ts | 3 + .../apis/resolve_import_errors.ts | 2 + .../security_only/apis/update.ts | 2 + .../spaces_only/apis/bulk_create.ts | 10 + .../spaces_only/apis/bulk_get.ts | 5 + .../spaces_only/apis/bulk_update.ts | 5 + .../spaces_only/apis/create.ts | 8 + .../spaces_only/apis/delete.ts | 5 + .../spaces_only/apis/get.ts | 5 + .../spaces_only/apis/import.ts | 10 + .../spaces_only/apis/resolve_import_errors.ts | 10 + .../spaces_only/apis/update.ts | 5 + 59 files changed, 546 insertions(+), 125 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md diff --git a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc index 92a624649d3c5..6361b3c921128 100644 --- a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc +++ b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc @@ -800,7 +800,7 @@ However, there are some minor changes: * The `schema.isNamespaceAgnostic` property has been renamed: `SavedObjectsType.namespaceType`. It no longer accepts a boolean but -instead an enum of `single`, `multiple`, or `agnostic` (see +instead an enum of `single`, `multiple`, `multiple-isolated`, or `agnostic` (see {kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md[SavedObjectsNamespaceType]). * The `schema.indexPattern` was accepting either a `string` or a `(config: LegacyConfig) => string`. `SavedObjectsType.indexPattern` only diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index e307b5c9971b0..9322fdbe0740b 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -167,7 +167,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [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) | | [SavedObjectsImportWarning](./kibana-plugin-core-public.savedobjectsimportwarning.md) | Composite type of all the possible types of import warnings.See [SavedObjectsImportSimpleWarning](./kibana-plugin-core-public.savedobjectsimportsimplewarning.md) and [SavedObjectsImportActionRequiredWarning](./kibana-plugin-core-public.savedobjectsimportactionrequiredwarning.md) for more details. | -| [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. | +| [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. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* 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.savedobjectsnamespacetype.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md index f2205d2cee424..cf5e6cb29a532 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md @@ -4,10 +4,10 @@ ## 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. +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. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. Signature: ```typescript -export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 5fe5eda7a8172..72dc3cdeab26c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -305,7 +305,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [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 | | [SavedObjectsImportHook](./kibana-plugin-core-server.savedobjectsimporthook.md) | A hook associated with a specific saved object type, that will be invoked during the import process. The hook will have access to the objects of the registered type.Currently, the only supported feature for import hooks is to return warnings to be displayed in the UI when the import succeeds. The only interactions the hook can have with the import process is via the hook's response. Mutating the objects inside the hook's code will have no effect. | | [SavedObjectsImportWarning](./kibana-plugin-core-server.savedobjectsimportwarning.md) | Composite type of all the possible types of import warnings.See [SavedObjectsImportSimpleWarning](./kibana-plugin-core-server.savedobjectsimportsimplewarning.md) and [SavedObjectsImportActionRequiredWarning](./kibana-plugin-core-server.savedobjectsimportactionrequiredwarning.md) for more details. | -| [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. | +| [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. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* 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.savedobjectsnamespacetype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md index 9075a780bd2c7..01a712aa89aa9 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md @@ -4,10 +4,10 @@ ## 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. +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. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. Signature: ```typescript -export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md index 064bd0b35699d..20346919fc652 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md @@ -4,13 +4,13 @@ ## SavedObjectsType.convertToMultiNamespaceTypeVersion property -If defined, objects of this type will be converted to multi-namespace objects when migrating to this version. +If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this version. Requirements: -1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) +1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) \*or\* [\`namespaceType: 'multiple-isolated'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) -Example of a single-namespace type in 7.10: +Example of a single-namespace type in 7.12: ```ts { @@ -21,7 +21,19 @@ Example of a single-namespace type in 7.10: } ``` -Example after converting to a multi-namespace type in 7.11: +Example after converting to a multi-namespace (isolated) type in 8.0: + +```ts +{ + name: 'foo', + hidden: false, + namespaceType: 'multiple-isolated', + mappings: {...}, + convertToMultiNamespaceTypeVersion: '8.0.0' +} + +``` +Example after converting to a multi-namespace (shareable) type in 8.1: ```ts { @@ -29,11 +41,11 @@ Example after converting to a multi-namespace type in 7.11: hidden: false, namespaceType: 'multiple', mappings: {...}, - convertToMultiNamespaceTypeVersion: '7.11.0' + convertToMultiNamespaceTypeVersion: '8.0.0' } ``` -Note: a migration function can be optionally specified for the same version. +Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md index eacad53be39fe..d882938d731c8 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md @@ -19,7 +19,7 @@ This is only internal for now, and will only be public when we expose the regist | Property | Type | Description | | --- | --- | --- | | [convertToAliasScript](./kibana-plugin-core-server.savedobjectstype.converttoaliasscript.md) | string | If defined, will be used to convert the type to an alias. | -| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | string | If defined, objects of this type will be converted to multi-namespace objects when migrating to this version.Requirements:1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md)Example of a single-namespace type in 7.10: +| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | string | If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this version.Requirements:1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) \*or\* [\`namespaceType: 'multiple-isolated'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md)Example of a single-namespace type in 7.12: ```ts { name: 'foo', @@ -29,18 +29,29 @@ This is only internal for now, and will only be public when we expose the regist } ``` -Example after converting to a multi-namespace type in 7.11: +Example after converting to a multi-namespace (isolated) type in 8.0: +```ts +{ + name: 'foo', + hidden: false, + namespaceType: 'multiple-isolated', + mappings: {...}, + convertToMultiNamespaceTypeVersion: '8.0.0' +} + +``` +Example after converting to a multi-namespace (shareable) type in 8.1: ```ts { name: 'foo', hidden: false, namespaceType: 'multiple', mappings: {...}, - convertToMultiNamespaceTypeVersion: '7.11.0' + convertToMultiNamespaceTypeVersion: '8.0.0' } ``` -Note: a migration function can be optionally specified for the same version. | +Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. | | [hidden](./kibana-plugin-core-server.savedobjectstype.hidden.md) | boolean | Is the type hidden by default. If true, repositories will not have access to this type unless explicitly declared as an extraType when creating the repository.See [createInternalRepository](./kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md). | | [indexPattern](./kibana-plugin-core-server.savedobjectstype.indexpattern.md) | string | If defined, the type instances will be stored in the given index instead of the default one. | | [management](./kibana-plugin-core-server.savedobjectstype.management.md) | SavedObjectsTypeManagementDefinition | An optional [saved objects management section](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) definition for the type. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md index 6532c5251d816..0ff07ae2804ff 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md @@ -4,7 +4,7 @@ ## SavedObjectTypeRegistry.isMultiNamespace() method -Returns whether the type is multi-namespace (shareable); resolves to `false` if the type is not registered +Returns whether the type is multi-namespace (shareable \*or\* isolated); resolves to `false` if the type is not registered Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md new file mode 100644 index 0000000000000..ee240268f9d67 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistry.md) > [isShareable](./kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md) + +## SavedObjectTypeRegistry.isShareable() method + +Returns whether the type is multi-namespace (shareable); resolves to `false` if the type is not registered + +Signature: + +```typescript +isShareable(type: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md index 55ad7ca137de0..0f2de8c8ef9b3 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md @@ -23,8 +23,9 @@ export declare class SavedObjectTypeRegistry | [getVisibleTypes()](./kibana-plugin-core-server.savedobjecttyperegistry.getvisibletypes.md) | | Returns all visible [types](./kibana-plugin-core-server.savedobjectstype.md).A visible type is a type that doesn't explicitly define hidden=true during registration. | | [isHidden(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ishidden.md) | | Returns the hidden property for given type, or false if the type is not registered. | | [isImportableAndExportable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isimportableandexportable.md) | | Returns the management.importableAndExportable property for given type, or false if the type is not registered or does not define a management section. | -| [isMultiNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) | | Returns whether the type is multi-namespace (shareable); resolves to false if the type is not registered | +| [isMultiNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) | | Returns whether the type is multi-namespace (shareable \*or\* isolated); resolves to false if the type is not registered | | [isNamespaceAgnostic(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md) | | Returns whether the type is namespace-agnostic (global); resolves to false if the type is not registered | +| [isShareable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md) | | Returns whether the type is multi-namespace (shareable); resolves to false if the type is not registered | | [isSingleNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md) | | Returns whether the type is single-namespace (isolated); resolves to true if the type is not registered | | [registerType(type)](./kibana-plugin-core-server.savedobjecttyperegistry.registertype.md) | | Register a [type](./kibana-plugin-core-server.savedobjectstype.md) inside the registry. A type can only be registered once. subsequent calls with the same type name will throw an error. | diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 99579ada8ec58..5ed0e51eb265a 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1369,7 +1369,7 @@ export interface SavedObjectsMigrationVersion { } // @public -export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; // @public (undocumented) export interface SavedObjectsStart { diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 776c7b195922e..f29a8b61b4885 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -143,7 +143,7 @@ describe('DocumentMigrator', () => { ).toThrow(/Migrations are not ready. Make sure prepareMigrations is called first./i); }); - it(`validates convertToMultiNamespaceTypeVersion can only be used with namespaceType 'multiple'`, () => { + it(`validates convertToMultiNamespaceTypeVersion can only be used with namespaceType 'multiple' or 'multiple-isolated'`, () => { const invalidDefinition = { kibanaVersion: '3.2.3', typeRegistry: createRegistry({ @@ -154,7 +154,7 @@ describe('DocumentMigrator', () => { log: mockLogger, }; expect(() => new DocumentMigrator(invalidDefinition)).toThrow( - `Invalid convertToMultiNamespaceTypeVersion for type foo. Expected namespaceType to be 'multiple', but got 'single'.` + `Invalid convertToMultiNamespaceTypeVersion for type foo. Expected namespaceType to be 'multiple' or 'multiple-isolated', but got 'single'.` ); }); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index b61c4cfe967e7..fd71c0b18c043 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -312,9 +312,9 @@ function validateMigrationDefinition( convertToMultiNamespaceTypeVersion: string, type: string ) { - if (namespaceType !== 'multiple') { + if (namespaceType !== 'multiple' && namespaceType !== 'multiple-isolated') { throw new Error( - `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Expected namespaceType to be 'multiple', but got '${namespaceType}'.` + `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Expected namespaceType to be 'multiple' or 'multiple-isolated', but got '${namespaceType}'.` ); } else if (!Semver.valid(convertToMultiNamespaceTypeVersion)) { throw new Error( diff --git a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts index 79b9c2feb1cbb..d53a53d745c0c 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts @@ -20,6 +20,7 @@ const createRegistryMock = (): jest.Mocked< isNamespaceAgnostic: jest.fn(), isSingleNamespace: jest.fn(), isMultiNamespace: jest.fn(), + isShareable: jest.fn(), isHidden: jest.fn(), getIndex: jest.fn(), isImportableAndExportable: jest.fn(), @@ -36,6 +37,7 @@ const createRegistryMock = (): jest.Mocked< (type: string) => type !== 'global' && type !== 'shared' ); mock.isMultiNamespace.mockImplementation((type: string) => type === 'shared'); + mock.isShareable.mockImplementation((type: string) => type === 'shared'); mock.isImportableAndExportable.mockReturnValue(true); return mock; diff --git a/src/core/server/saved_objects/saved_objects_type_registry.test.ts b/src/core/server/saved_objects/saved_objects_type_registry.test.ts index c0eb7891cd7d4..872b61706c526 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.test.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.test.ts @@ -239,6 +239,7 @@ describe('SavedObjectTypeRegistry', () => { it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'multiple' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); expectResult(false, { namespaceType: 'single' }); expectResult(false, { namespaceType: undefined }); }); @@ -263,6 +264,7 @@ describe('SavedObjectTypeRegistry', () => { it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'agnostic' }); expectResult(false, { namespaceType: 'multiple' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); }); }); @@ -277,12 +279,36 @@ describe('SavedObjectTypeRegistry', () => { expect(registry.isMultiNamespace('unknownType')).toEqual(false); }); + it(`returns true for namespaceType 'multiple' and 'multiple-isolated'`, () => { + expectResult(true, { namespaceType: 'multiple' }); + expectResult(true, { namespaceType: 'multiple-isolated' }); + }); + + it(`returns false for other namespaceType`, () => { + expectResult(false, { namespaceType: 'agnostic' }); + expectResult(false, { namespaceType: 'single' }); + expectResult(false, { namespaceType: undefined }); + }); + }); + + describe('#isShareable', () => { + const expectResult = (expected: boolean, schemaDefinition?: Partial) => { + registry = new SavedObjectTypeRegistry(); + registry.registerType(createType({ name: 'foo', ...schemaDefinition })); + expect(registry.isShareable('foo')).toBe(expected); + }; + + it(`returns false when the type is not registered`, () => { + expect(registry.isShareable('unknownType')).toEqual(false); + }); + it(`returns true for namespaceType 'multiple'`, () => { expectResult(true, { namespaceType: 'multiple' }); }); it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'agnostic' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); expectResult(false, { namespaceType: 'single' }); expectResult(false, { namespaceType: undefined }); }); diff --git a/src/core/server/saved_objects/saved_objects_type_registry.ts b/src/core/server/saved_objects/saved_objects_type_registry.ts index 8a50beda83d2a..a63837132b652 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.ts @@ -86,10 +86,19 @@ export class SavedObjectTypeRegistry { } /** - * Returns whether the type is multi-namespace (shareable); + * Returns whether the type is multi-namespace (shareable *or* isolated); * resolves to `false` if the type is not registered */ public isMultiNamespace(type: string) { + const namespaceType = this.types.get(type)?.namespaceType; + return namespaceType === 'multiple' || namespaceType === 'multiple-isolated'; + } + + /** + * Returns whether the type is multi-namespace (shareable); + * resolves to `false` if the type is not registered + */ + public isShareable(type: string) { return this.types.get(type)?.namespaceType === 'multiple'; } diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index aac508fb5b909..d79ade22eb559 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -48,9 +48,29 @@ describe('SavedObjectsRepository', () => { const KIBANA_VERSION = '2.0.0'; const CUSTOM_INDEX_TYPE = 'customIndex'; + /** This type has namespaceType: 'agnostic'. */ const NAMESPACE_AGNOSTIC_TYPE = 'globalType'; - const MULTI_NAMESPACE_TYPE = 'shareableType'; - const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'shareableTypeCustomIndex'; + /** + * This type has namespaceType: 'multiple'. + * + * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is shareable across + * namespaces. + **/ + const MULTI_NAMESPACE_TYPE = 'multiNamespaceType'; + /** + * This type has namespaceType: 'multiple-isolated'. + * + * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is NOT shareable + * across namespaces. This distinction only matters when using the `addToNamespaces` and `deleteFromNamespaces` APIs, or when using the + * `initialNamespaces` argument with the `create` and `bulkCreate` APIs. Those allow you to define or change what namespaces an object + * exists in. + * + * In a nutshell, this type is more restrictive than `MULTI_NAMESPACE_TYPE`, so we use `MULTI_NAMESPACE_ISOLATED_TYPE` for any test cases + * where `MULTI_NAMESPACE_TYPE` would also satisfy the test case. + **/ + const MULTI_NAMESPACE_ISOLATED_TYPE = 'multiNamespaceIsolatedType'; + /** This type has namespaceType: 'multiple', and it uses a custom index. */ + const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'multiNamespaceTypeCustomIndex'; const HIDDEN_TYPE = 'hiddenType'; const mappings = { @@ -93,6 +113,13 @@ describe('SavedObjectsRepository', () => { }, }, }, + [MULTI_NAMESPACE_ISOLATED_TYPE]: { + properties: { + evenYetAnotherField: { + type: 'keyword', + }, + }, + }, [MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]: { properties: { evenYetAnotherField: { @@ -132,6 +159,10 @@ describe('SavedObjectsRepository', () => { ...createType(MULTI_NAMESPACE_TYPE), namespaceType: 'multiple', }); + registry.registerType({ + ...createType(MULTI_NAMESPACE_ISOLATED_TYPE), + namespaceType: 'multiple-isolated', + }); registry.registerType({ ...createType(MULTI_NAMESPACE_CUSTOM_INDEX_TYPE), namespaceType: 'multiple', @@ -345,13 +376,14 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }); - it(`throws when type is not multi-namespace`, async () => { + it(`throws when type is not shareable`, async () => { const test = async (type) => { const message = `${type} doesn't support multiple namespaces`; await expectBadRequestError(type, id, [newNs1, newNs2], message); expect(client.update).not.toHaveBeenCalled(); }; await test('index-pattern'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); @@ -518,11 +550,13 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES mget action before bulk action for any types that are multi-namespace, when id is defined`, async () => { - const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; await bulkCreateSuccess(objects); expect(client.bulk).toHaveBeenCalledTimes(1); expect(client.mget).toHaveBeenCalledTimes(1); - const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + const docs = [ + expect.objectContaining({ _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj2.id}` }), + ]; expect(client.mget.mock.calls[0][0].body).toEqual({ docs }); }); @@ -601,7 +635,7 @@ describe('SavedObjectsRepository', () => { it(`doesn't add namespace to request body for any types that are not single-namespace`, async () => { const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); const expected = expect.not.objectContaining({ namespace: expect.anything() }); @@ -614,7 +648,7 @@ describe('SavedObjectsRepository', () => { it(`adds namespaces to request body for any types that are multi-namespace`, async () => { const test = async (namespace) => { - const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_ISOLATED_TYPE })); const namespaces = [namespace ?? 'default']; await bulkCreateSuccess(objects, { namespace, overwrite: true }); const expected = expect.objectContaining({ namespaces }); @@ -706,7 +740,7 @@ describe('SavedObjectsRepository', () => { const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); expectClientCallArgsAction(objects, { method: 'create', getId }); @@ -753,7 +787,7 @@ describe('SavedObjectsRepository', () => { ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); }); - it(`returns error when initialNamespaces is used with a non-multi-namespace object`, async () => { + it(`returns error when initialNamespaces is used with a non-shareable object`, async () => { const test = async (objType) => { const obj = { ...obj3, type: objType, initialNamespaces: [] }; await bulkCreateError( @@ -767,9 +801,10 @@ describe('SavedObjectsRepository', () => { }; await test('dashboard'); await test(NAMESPACE_AGNOSTIC_TYPE); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); }); - it(`throws when options.initialNamespaces is used with a multi-namespace type and is empty`, async () => { + it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; await bulkCreateError( obj, @@ -792,7 +827,7 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE }; + const obj = { ...obj3, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const response1 = { status: 200, docs: [ @@ -884,7 +919,7 @@ describe('SavedObjectsRepository', () => { it(`doesn't add namespace to body when not using single-namespace type`, async () => { const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); expectMigrationArgs({ namespace: expect.anything() }, false, 1); @@ -892,14 +927,20 @@ describe('SavedObjectsRepository', () => { }); it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); await bulkCreateSuccess(objects, { namespace }); expectMigrationArgs({ namespaces: [namespace] }, true, 1); expectMigrationArgs({ namespaces: [namespace] }, true, 2); }); it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); await bulkCreateSuccess(objects); expectMigrationArgs({ namespaces: ['default'] }, true, 1); expectMigrationArgs({ namespaces: ['default'] }, true, 2); @@ -1070,7 +1111,7 @@ describe('SavedObjectsRepository', () => { _expectClientCallArgs(objects, { getId }); client.mget.mockClear(); - objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE })); await bulkGetSuccess(objects, { namespace }); _expectClientCallArgs(objects, { getId }); }); @@ -1130,7 +1171,7 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const response = getMockMgetResponse([obj1, obj, obj2]); response.docs[1].namespaces = ['bar-namespace']; await bulkGetErrorNotFound([obj1, obj, obj2], { namespace }, response); @@ -1189,7 +1230,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkGetSuccess([obj1, obj]); expect(result).toEqual({ saved_objects: [ @@ -1291,12 +1332,14 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES mget action before bulk action for any types that are multi-namespace`, async () => { - const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; await bulkUpdateSuccess(objects); expect(client.bulk).toHaveBeenCalled(); expect(client.mget).toHaveBeenCalled(); - const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + const docs = [ + expect.objectContaining({ _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj2.id}` }), + ]; expect(client.mget).toHaveBeenCalledWith( expect.objectContaining({ body: { docs } }), expect.anything() @@ -1313,7 +1356,7 @@ describe('SavedObjectsRepository', () => { }); it(`formats the ES request for any types that are multi-namespace`, async () => { - const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; + const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; await bulkUpdateSuccess([obj1, _obj2]); const body = [...expectObjArgs(obj1), ...expectObjArgs(_obj2)]; expect(client.bulk).toHaveBeenCalledWith( @@ -1384,8 +1427,8 @@ describe('SavedObjectsRepository', () => { it(`defaults to the version of the existing document for multi-namespace types`, async () => { // only multi-namespace documents are obtained using a pre-flight mget request const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkUpdateSuccess(objects); const overrides = { @@ -1406,7 +1449,7 @@ describe('SavedObjectsRepository', () => { // test with both non-multi-namespace and multi-namespace types const objects = [ { ...obj1, version }, - { ...obj2, type: MULTI_NAMESPACE_TYPE, version }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, version }, ]; await bulkUpdateSuccess(objects); const overrides = { if_seq_no: 100, if_primary_term: 200 }; @@ -1459,7 +1502,7 @@ describe('SavedObjectsRepository', () => { if_seq_no: expect.any(Number), }; const _obj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }; - const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; + const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; await bulkUpdateSuccess([_obj1], { namespace }); expectClientCallArgsAction([_obj1], { method: 'update', getId }); @@ -1558,19 +1601,19 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when ES is unable to find the document (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE, found: false }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE, found: false }; const mgetResponse = getMockMgetResponse([_obj]); await bulkUpdateMultiError([obj1, _obj, obj2], undefined, mgetResponse); }); it(`returns error when ES is unable to find the index (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const mgetResponse = { statusCode: 404 }; await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); }); it(`returns error when there is a conflict with an existing multi-namespace saved object (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const mgetResponse = getMockMgetResponse([_obj], 'bar-namespace'); await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); }); @@ -1643,7 +1686,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkUpdateSuccess([obj1, obj]); expect(result).toEqual({ saved_objects: [ @@ -1654,7 +1697,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes originId property if present in cluster call response`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkUpdateSuccess([obj1, obj], {}, true); expect(result).toEqual({ saved_objects: [ @@ -1669,9 +1712,9 @@ describe('SavedObjectsRepository', () => { describe('#checkConflicts', () => { const obj1 = { type: 'dashboard', id: 'one' }; const obj2 = { type: 'dashboard', id: 'two' }; - const obj3 = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; - const obj4 = { type: MULTI_NAMESPACE_TYPE, id: 'four' }; - const obj5 = { type: MULTI_NAMESPACE_TYPE, id: 'five' }; + const obj3 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; + const obj4 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'four' }; + const obj5 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'five' }; const obj6 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'six' }; const obj7 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'seven' }; const namespace = 'foo-namespace'; @@ -1854,7 +1897,7 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES get action then index action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, overwrite: true }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, overwrite: true }); expect(client.get).toHaveBeenCalled(); expect(client.index).toHaveBeenCalled(); }); @@ -1975,10 +2018,10 @@ describe('SavedObjectsRepository', () => { }); it(`doesn't prepend namespace to the id and adds namespaces to body when using multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, namespace }); expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, body: expect.objectContaining({ namespaces: [namespace] }), }), expect.anything() @@ -2013,7 +2056,7 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { - it(`throws when options.initialNamespaces is used with a non-multi-namespace object`, async () => { + it(`throws when options.initialNamespaces is used with a non-shareable object`, async () => { const test = async (objType) => { await expect( savedObjectsRepository.create(objType, attributes, { initialNamespaces: [namespace] }) @@ -2024,10 +2067,11 @@ describe('SavedObjectsRepository', () => { ); }; await test('dashboard'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); - it(`throws when options.initialNamespaces is used with a multi-namespace type and is empty`, async () => { + it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { await expect( savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { initialNamespaces: [] }) ).rejects.toThrowError( @@ -2056,17 +2100,20 @@ describe('SavedObjectsRepository', () => { }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace'); + const response = getMockGetResponse( + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, + 'bar-namespace' + ); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { + savedObjectsRepository.create(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, overwrite: true, namespace, }) - ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); + ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_ISOLATED_TYPE, id)); expect(client.get).toHaveBeenCalled(); }); @@ -2105,17 +2152,17 @@ describe('SavedObjectsRepository', () => { expectMigrationArgs({ namespace: expect.anything() }, false, 1); client.create.mockClear(); - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id }); expectMigrationArgs({ namespace: expect.anything() }, false, 2); }); it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, namespace }); expectMigrationArgs({ namespaces: [namespace] }); }); it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id }); expectMigrationArgs({ namespaces: ['default'] }); }); @@ -2181,13 +2228,13 @@ describe('SavedObjectsRepository', () => { }); it(`should use ES get action then delete action when using a multi-namespace type`, async () => { - await deleteSuccess(MULTI_NAMESPACE_TYPE, id); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); expect(client.delete).toHaveBeenCalledTimes(1); }); it(`includes the version of the existing document when using a multi-namespace type`, async () => { - await deleteSuccess(MULTI_NAMESPACE_TYPE, id); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, @@ -2238,9 +2285,9 @@ describe('SavedObjectsRepository', () => { ); client.delete.mockClear(); - await deleteSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); expect(client.delete).toHaveBeenCalledWith( - expect.objectContaining({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }), + expect.objectContaining({ id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}` }), expect.anything() ); }); @@ -2273,7 +2320,7 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -2281,27 +2328,29 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the type is multi-namespace and the document has multiple namespaces and the force option is not enabled`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }); response._source.namespaces = [namespace, 'bar-namespace']; client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id, { namespace }) + savedObjectsRepository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) ).rejects.toThrowError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); @@ -2309,13 +2358,13 @@ describe('SavedObjectsRepository', () => { }); it(`throws when the type is multi-namespace and the document has all namespaces and the force option is not enabled`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }); response._source.namespaces = ['*']; client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id, { namespace }) + savedObjectsRepository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) ).rejects.toThrowError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); @@ -3167,10 +3216,10 @@ describe('SavedObjectsRepository', () => { ); client.get.mockClear(); - await getSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); + await getSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); expect(client.get).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, }), expect.anything() ); @@ -3217,11 +3266,13 @@ describe('SavedObjectsRepository', () => { }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); }); @@ -3243,7 +3294,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces if type is multi-namespace`, async () => { - const result = await getSuccess(MULTI_NAMESPACE_TYPE, id); + const result = await getSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(result).toMatchObject({ namespaces: expect.any(Array), }); @@ -3418,8 +3469,12 @@ describe('SavedObjectsRepository', () => { it('but alias target does not exist in this namespace', async () => { const objects = [ - { type: MULTI_NAMESPACE_TYPE, id }, // correct namespace field is added by getMockMgetResponse - { type: MULTI_NAMESPACE_TYPE, id: aliasTargetId, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, // correct namespace field is added by getMockMgetResponse + { + type: MULTI_NAMESPACE_ISOLATED_TYPE, + id: aliasTargetId, + namespace: `not-${namespace}`, + }, // overrides namespace field that would otherwise be added by getMockMgetResponse ]; await expectExactMatchResult(objects); }); @@ -3455,8 +3510,8 @@ describe('SavedObjectsRepository', () => { it('because actual target does not exist in this namespace', async () => { const objects = [ - { type: MULTI_NAMESPACE_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse - { type: MULTI_NAMESPACE_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse ]; await expectAliasMatchResult(objects); }); @@ -3537,7 +3592,9 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES get action then update action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { - await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, counterFields, { namespace }); + await incrementCounterSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, counterFields, { + namespace, + }); expect(client.get).toHaveBeenCalledTimes(1); expect(client.update).toHaveBeenCalledTimes(1); }); @@ -3592,10 +3649,12 @@ describe('SavedObjectsRepository', () => { ); client.update.mockClear(); - await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, counterFields, { namespace }); + await incrementCounterSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, counterFields, { + namespace, + }); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, }), expect.anything() ); @@ -3660,15 +3719,23 @@ describe('SavedObjectsRepository', () => { }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace'); + const response = getMockGetResponse( + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, + 'bar-namespace' + ); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.incrementCounter(MULTI_NAMESPACE_TYPE, id, counterFields, { - namespace, - }) - ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); + savedObjectsRepository.incrementCounter( + MULTI_NAMESPACE_ISOLATED_TYPE, + id, + counterFields, + { + namespace, + } + ) + ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_ISOLATED_TYPE, id)); expect(client.get).toHaveBeenCalledTimes(1); }); }); @@ -3976,7 +4043,7 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }); - it(`throws when type is not multi-namespace`, async () => { + it(`throws when type is not shareable`, async () => { const test = async (type) => { const message = `${type} doesn't support multiple namespaces`; await expectBadRequestError(type, id, [namespace1, namespace2], message); @@ -3984,6 +4051,7 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }; await test('index-pattern'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); @@ -4148,7 +4216,7 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`should use the ES get action then update action when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(client.get).toHaveBeenCalledTimes(1); expect(client.update).toHaveBeenCalledTimes(1); }); @@ -4212,7 +4280,7 @@ describe('SavedObjectsRepository', () => { }); it(`defaults to the version of the existing document when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { references }); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, { references }); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, @@ -4267,15 +4335,17 @@ describe('SavedObjectsRepository', () => { ); client.update.mockClear(); - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { namespace }); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, { namespace }); expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ id: expect.stringMatching(`${MULTI_NAMESPACE_TYPE}:${id}`) }), + expect.objectContaining({ + id: expect.stringMatching(`${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`), + }), expect.anything() ); }); it(`includes _source_includes when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ _source_includes: ['namespace', 'namespaces', 'originId'] }), expect.anything() @@ -4320,7 +4390,7 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -4328,16 +4398,18 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -4374,7 +4446,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces if type is multi-namespace`, async () => { - const result = await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + const result = await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(result).toMatchObject({ namespaces: expect.any(Array), }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index fcd72aa4326a2..70ec7e5ea8b0a 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -247,7 +247,7 @@ export class SavedObjectsRepository { const namespace = normalizeNamespace(options.namespace); if (initialNamespaces) { - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( '"options.initialNamespaces" can only be used on multi-namespace types' ); @@ -336,7 +336,7 @@ export class SavedObjectsRepository { if (!this._allowedTypes.includes(object.type)) { error = SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type); } else if (object.initialNamespaces) { - if (!this._registry.isMultiNamespace(object.type)) { + if (!this._registry.isShareable(object.type)) { error = SavedObjectsErrorHelpers.createBadRequestError( '"initialNamespaces" can only be used on multi-namespace types' ); @@ -1176,7 +1176,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( `${type} doesn't support multiple namespaces` ); @@ -1239,7 +1239,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( `${type} doesn't support multiple namespaces` ); diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index d122e92aba398..05d91f88c01e9 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -197,13 +197,17 @@ export type SavedObjectsClientContract = Pick SavedObjectMigrationMap); /** - * If defined, objects of this type will be converted to multi-namespace objects when migrating to this version. + * If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this + * version. * * Requirements: * * 1. This string value must be a valid semver version * 2. This type must have previously specified {@link SavedObjectsNamespaceType | `namespaceType: 'single'`} - * 3. This type must also specify {@link SavedObjectsNamespaceType | `namespaceType: 'multiple'`} + * 3. This type must also specify {@link SavedObjectsNamespaceType | `namespaceType: 'multiple'`} *or* + * {@link SavedObjectsNamespaceType | `namespaceType: 'multiple-isolated'`} * - * Example of a single-namespace type in 7.10: + * Example of a single-namespace type in 7.12: * * ```ts * { @@ -262,7 +268,19 @@ export interface SavedObjectsType { * } * ``` * - * Example after converting to a multi-namespace type in 7.11: + * Example after converting to a multi-namespace (isolated) type in 8.0: + * + * ```ts + * { + * name: 'foo', + * hidden: false, + * namespaceType: 'multiple-isolated', + * mappings: {...}, + * convertToMultiNamespaceTypeVersion: '8.0.0' + * } + * ``` + * + * Example after converting to a multi-namespace (shareable) type in 8.1: * * ```ts * { @@ -270,11 +288,11 @@ export interface SavedObjectsType { * hidden: false, * namespaceType: 'multiple', * mappings: {...}, - * convertToMultiNamespaceTypeVersion: '7.11.0' + * convertToMultiNamespaceTypeVersion: '8.0.0' * } * ``` * - * Note: a migration function can be optionally specified for the same version. + * Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. */ convertToMultiNamespaceTypeVersion?: string; /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 09207608908a4..564b5b12ce765 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2740,7 +2740,7 @@ export interface SavedObjectsMigrationVersion { } // @public -export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; // @public export interface SavedObjectsRawDoc { @@ -2924,6 +2924,7 @@ export class SavedObjectTypeRegistry { isImportableAndExportable(type: string): boolean; isMultiNamespace(type: string): boolean; isNamespaceAgnostic(type: string): boolean; + isShareable(type: string): boolean; isSingleNamespace(type: string): boolean; registerType(type: SavedObjectsType): void; } diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index f2f9d24488ac0..5a5158825a224 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -440,7 +440,7 @@ export default ({ getService }: FtrProviderContext) => { }, { ...BAR_TYPE, - namespaceType: 'multiple', + namespaceType: 'multiple-isolated', convertToMultiNamespaceTypeVersion: '2.0.0', }, BAZ_TYPE, // must be registered for reference transforms to be applied to objects of this type diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 32cae675dea74..5fac012d5e8b9 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -618,3 +618,37 @@ } } } + +{ + "type": "doc", + "value": { + "id": "sharecapabletype:only_default_space", + "index": ".kibana", + "source": { + "sharecapabletype": { + "title": "A share-capable (isolated) saved-object only in the default space" + }, + "type": "sharecapabletype", + "namespaces": ["default"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharecapabletype:only_space_1", + "index": ".kibana", + "source": { + "sharecapabletype": { + "title": "A share-capable (isolated) saved-object only in space_1" + }, + "type": "sharecapabletype", + "namespaces": ["space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 561c2ecc56fa2..50c4fb305a6d0 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -263,6 +263,19 @@ } } }, + "sharecapabletype": { + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, "space": { "properties": { "_reserved": { diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts index d05a08eeeedd1..e29bbc0db56b6 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts +++ b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts @@ -52,6 +52,13 @@ export class Plugin { management, mappings, }); + core.savedObjects.registerType({ + name: 'sharecapabletype', + hidden: false, + namespaceType: 'multiple-isolated', + management, + mappings, + }); core.savedObjects.registerType({ name: 'globaltype', hidden: false, diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts index c16d26d834b33..8506611f24560 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts @@ -53,6 +53,16 @@ export const SAVED_OBJECT_TEST_CASES: Record = Object.fr id: 'only_space_2', expectedNamespaces: [SPACE_2_ID], }), + MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE: Object.freeze({ + type: 'sharecapabletype', + id: 'only_default_space', + expectedNamespaces: [DEFAULT_SPACE_ID], + }), + MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1: Object.freeze({ + type: 'sharecapabletype', + id: 'only_space_1', + expectedNamespaces: [SPACE_1_ID], + }), NAMESPACE_AGNOSTIC: Object.freeze({ type: 'globaltype', id: 'globaltype-id', diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts index 6dfe257f21c0b..43e92cc21c469 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts @@ -115,7 +115,7 @@ export const createRequest = ({ type, id }: TestCase) => ({ type, id }); const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); const isNamespaceAgnostic = (type: string) => type === 'globaltype'; -const isMultiNamespace = (type: string) => type === 'sharedtype'; +const isMultiNamespace = (type: string) => type === 'sharedtype' || type === 'sharecapabletype'; export const expectResponses = { forbiddenTypes: (action: string) => ( typeOrTypes: string | string[] diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts index f46fdcf01367c..94b75f1fd536d 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/export.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts @@ -89,6 +89,27 @@ export const getTestCases = (spaceId?: string): { [key: string]: ExportTestCase .flat(), ], }, + ...(spaceId !== SPACE_2_ID && { + // we do not have a multi-namespace isolated object in Space 2 + multiNamespaceIsolatedObject: { + title: 'multi-namespace isolated object', + ...(spaceId === SPACE_1_ID + ? CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1 + : CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE), + }, + }), + multiNamespaceIsolatedType: { + title: 'multi-namespace isolated type', + type: 'sharecapabletype', + successResult: [ + ...(spaceId === SPACE_1_ID + ? [CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1] + : spaceId === SPACE_2_ID + ? [] + : [CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE] + ).flat(), + ], + }, namespaceAgnosticObject: { title: 'namespace-agnostic object', ...CASES.NAMESPACE_AGNOSTIC, diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts index cdeb210dddffb..27905459c29b7 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/find.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -107,6 +107,13 @@ export const getTestCases = ( savedObjects: getExpectedSavedObjects((t) => t.type === 'sharedtype'), }, } as FindTestCase, + multiNamespaceIsolatedType: { + title: buildTitle('find multi-namespace isolated type'), + query: `type=sharecapabletype&fields=title${namespacesQueryParam}`, + successResult: { + savedObjects: getExpectedSavedObjects((t) => t.type === 'sharecapabletype'), + }, + } as FindTestCase, namespaceAgnosticType: { title: buildTitle('find namespace-agnostic type'), query: `type=globaltype&fields=title${namespacesQueryParam}`, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts index f8742b5d6a2fc..b6d1ae23120a4 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -60,6 +60,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite || spaceId !== SPACE_2_ID), ...unresolvableConflict(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + ...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + ...unresolvableConflict(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts index 89a791b06dc5d..d547b95d34f7e 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts index 9cc6cbc967c32..b818a4b6bf33c 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts index eb221fc314ae3..7f5f0b453ff25 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts @@ -49,6 +49,14 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts index 13c6b418d3033..6a6fc8a15decf 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts @@ -45,6 +45,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts index 788e8e92a9d43..774d7f98f1635 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts @@ -19,11 +19,13 @@ const createTestCases = (spaceId: string) => { const exportableObjects = [ cases.singleNamespaceObject, cases.multiNamespaceObject, + cases.multiNamespaceIsolatedObject, cases.namespaceAgnosticObject, ]; const exportableTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, ]; const nonExportableObjectsAndTypes = [cases.hiddenObject, cases.hiddenType]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts index 78c38967f6e1d..6d9c38ecca596 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts @@ -27,6 +27,7 @@ const createTestCases = (currentSpace: string, crossSpaceSearch?: string[]) => { const normalTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, cases.eachType, cases.pageBeyondTotal, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts index e493af65257c1..e61d5c10c2dbb 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts index c40d8c3140c6e..659ee2c2e2363 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts @@ -75,6 +75,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict @@ -124,6 +134,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts index 0ba8c171b3e25..3f213e519e57d 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts @@ -72,6 +72,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict @@ -112,6 +122,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts index 5007497df5005..44296597d52ea 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts index bacade65153b2..b8b57289212da 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts @@ -33,6 +33,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(), ...unresolvableConflict() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(), ...unresolvableConflict() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail409(), ...unresolvableConflict() }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts index b80eb7ed347e0..18edb7502c65a 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts @@ -27,6 +27,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts index 9b3bc39c64d11..59da44dcd8ec4 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts @@ -33,6 +33,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts index 3ffb9b2d6705a..0aae9ebe7c914 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts @@ -32,6 +32,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail409() }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts index e176c25458914..7d9ec0b152174 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts @@ -31,6 +31,8 @@ const createTestCases = () => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, force: true }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts index 5cd6ea9242e12..a1580c85a3680 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts @@ -19,11 +19,13 @@ const createTestCases = () => { const exportableObjects = [ cases.singleNamespaceObject, cases.multiNamespaceObject, + cases.multiNamespaceIsolatedObject, cases.namespaceAgnosticObject, ]; const exportableTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, ]; const nonExportableObjectsAndTypes = [cases.hiddenObject, cases.hiddenType]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts index 5a52402fcdf59..eb30024015fbb 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts @@ -24,6 +24,7 @@ const createTestCases = (crossSpaceSearch?: string[]) => { const normalTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, cases.eachType, cases.pageBeyondTotal, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts index 5f5417761dbd1..9910900c2f51b 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts @@ -27,6 +27,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts index 0cf5cdd98efa8..b46e3fabff95b 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts @@ -54,6 +54,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...destinationId() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...destinationId() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...destinationId() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict @@ -103,6 +105,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts index 7df930d508664..1d20de4f620fe 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts @@ -46,6 +46,7 @@ const createTestCases = (overwrite: boolean) => { const group2 = [ { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict @@ -85,6 +86,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts index bafc90c710ac3..c0ec36fcf75c4 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts @@ -27,6 +27,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index aa771d7c48dda..6bb7828e12f23 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -55,6 +55,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite || spaceId !== SPACE_2_ID), ...unresolvableConflict(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + ...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + ...unresolvableConflict(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts index 0f78983953bba..e1d0243377b8e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts @@ -30,6 +30,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts index 164ecdd299274..30dc034715ed4 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts @@ -31,6 +31,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts index ff192530b47cf..39c97be1b6285 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts @@ -44,6 +44,14 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts index 1d38a50a96d19..1a168bac948be 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts @@ -39,6 +39,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts index b34ee15174e99..374bf4f0c2577 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts @@ -30,6 +30,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts index ffe302883b43a..b1f30657dd9c0 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts @@ -61,6 +61,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts index dde99164bd38c..35f5d3dabde88 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts @@ -65,6 +65,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts index 3940c815aa353..bf5d635a11d8a 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts @@ -30,6 +30,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, From 5216a3a85477274bf25d28cc25bdd065d080d76d Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 27 Jan 2021 21:24:10 -0500 Subject: [PATCH 02/29] Support converting encrypted saved objects to multi-namespace types ESO uses object "descriptors" as part of additionally authenticated data (AAD) when encrypting and decrypting objects. Historically the descriptors for single-namespace objects have included the object namespace, but in a world where saved objects can be shared across spaces, that no longer makes sense. This commit allows consumers to define an ESO migration that would allow for flexible decryption of a saved object using a legacy descriptor that includes a namespace, then encrypts the object with a new descriptor that omits the object's namespace. --- .../server/create_migration.test.ts | 71 +++++++ .../server/create_migration.ts | 22 ++- .../encrypted_saved_object_type_definition.ts | 9 + .../encrypted_saved_objects_service.test.ts | 176 ++++++++++++++++++ .../crypto/encrypted_saved_objects_service.ts | 65 ++++--- .../saved_objects/get_descriptor_namespace.ts | 2 +- 6 files changed, 312 insertions(+), 33 deletions(-) diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts index 508879c3596e5..c17ab36517fff 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts @@ -163,6 +163,77 @@ describe('createMigration()', () => { attributes ); }); + + describe('uses the object `namespaces` field to populate the descriptor when the input type has the `convertToMultiNamespaceType` option enabled', () => { + const doTest = async ({ + objectNamespace, + decryptDescriptorNamespace, + }: { + objectNamespace: string | undefined; + decryptDescriptorNamespace: string | undefined; + }) => { + const serviceWithLegacyType = encryptedSavedObjectsServiceMock.create(); + const instantiateServiceWithLegacyType = jest.fn(() => serviceWithLegacyType); + + const migrationCreator = getCreateMigration( + encryptionSavedObjectService, + instantiateServiceWithLegacyType + ); + const noopMigration = migrationCreator( + function (doc): doc is SavedObjectUnsanitizedDoc { + return true; + }, + (doc) => doc, + { ...inputType, convertToMultiNamespaceType: true } + ); + + const attributes = { + firstAttr: 'first_attr', + }; + + serviceWithLegacyType.decryptAttributesSync.mockReturnValueOnce(attributes); + encryptionSavedObjectService.encryptAttributesSync.mockReturnValueOnce(attributes); + + noopMigration( + { + id: '123', + type: 'known-type-1', + namespaces: objectNamespace ? [objectNamespace] : [], + attributes, + }, + { log } + ); + + expect(serviceWithLegacyType.decryptAttributesSync).toHaveBeenCalledWith( + { + id: '123', + type: 'known-type-1', + namespace: decryptDescriptorNamespace, + }, + attributes + ); + + expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith( + { + id: '123', + type: 'known-type-1', + }, + attributes + ); + }; + + it('when namespaces is an empty array', async () => { + doTest({ objectNamespace: undefined, decryptDescriptorNamespace: undefined }); + }); + + it('when the first namespace element is "default"', async () => { + doTest({ objectNamespace: 'default', decryptDescriptorNamespace: undefined }); + }); + + it('when the first namespace element is another string', async () => { + doTest({ objectNamespace: 'foo', decryptDescriptorNamespace: 'foo' }); + }); + }); }); describe('migration across two legacy types', () => { diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts index eb262997a8e45..fa4d70a741481 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts @@ -11,6 +11,7 @@ import { SavedObjectMigrationContext, } from 'src/core/server'; import { EncryptedSavedObjectTypeRegistration, EncryptedSavedObjectsService } from './crypto'; +import { normalizeNamespace } from './saved_objects/get_descriptor_namespace'; type SavedObjectOptionalMigrationFn = ( doc: SavedObjectUnsanitizedDoc | SavedObjectUnsanitizedDoc, @@ -63,11 +64,18 @@ export const getCreateMigration = ( return encryptedDoc; } - const descriptor = { - id: encryptedDoc.id!, - type: encryptedDoc.type, - namespace: encryptedDoc.namespace, - }; + // If a document has been converted right before this migration function is called, it will no longer have a `namespace` field, but it + // will have a `namespaces` field; in that case, the first/only element in that array should be used as the namespace in the descriptor + // during decryption. + const { convertToMultiNamespaceType } = inputType ?? {}; + const decryptDescriptorNamespace = convertToMultiNamespaceType + ? normalizeNamespace(encryptedDoc.namespaces?.[0]) // `namespaces` contains string values, but we need to normalize this to the namespace ID representation + : encryptedDoc.namespace; + + const { id, type } = encryptedDoc; + // These descriptors might have a `namespace` that is undefined. That is expected for multi-namespace and namespace-agnostic types. + const decryptDescriptor = { id, type, namespace: decryptDescriptorNamespace }; + const encryptDescriptor = { id, type, namespace: encryptedDoc.namespace }; // decrypt the attributes using the input type definition // then migrate the document @@ -75,12 +83,12 @@ export const getCreateMigration = ( return mapAttributes( migration( mapAttributes(encryptedDoc, (inputAttributes) => - inputService.decryptAttributesSync(descriptor, inputAttributes) + inputService.decryptAttributesSync(decryptDescriptor, inputAttributes) ), context ), (migratedAttributes) => - migratedService.encryptAttributesSync(descriptor, migratedAttributes) + migratedService.encryptAttributesSync(encryptDescriptor, migratedAttributes) ); }; }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts index b2b6bd16c12cd..03df0487ef3cc 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts @@ -16,6 +16,14 @@ export class EncryptedSavedObjectAttributesDefinition { public readonly attributesToEncrypt: ReadonlySet; private readonly attributesToExcludeFromAAD: ReadonlySet | undefined; private readonly attributesToStrip: ReadonlySet; + /** + * Indicates whether objects of this type are being converted from a single-namespace type to a multi-namespace type. In this case, we may + * need to attempt decryption twice: once with a namespace in the descriptor (for use during index migration), and again without a + * namespace in the descriptor (for use during object migration). In other words, if the object is being decrypted during index migration, + * the object was previously encrypted with its namespace in the descriptor portion of the AAD; on the other hand, if the object is being + * decrypted during object migration, the object was never encrypted with its namespace in the descriptor portion of the AAD. + */ + public readonly convertToMultiNamespaceType: boolean; constructor(typeRegistration: EncryptedSavedObjectTypeRegistration) { const attributesToEncrypt = new Set(); @@ -35,6 +43,7 @@ export class EncryptedSavedObjectAttributesDefinition { this.attributesToEncrypt = attributesToEncrypt; this.attributesToStrip = attributesToStrip; this.attributesToExcludeFromAAD = typeRegistration.attributesToExcludeFromAAD; + this.convertToMultiNamespaceType = !!typeRegistration.convertToMultiNamespaceType; } /** diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts index 1760a85806786..1f1c33b8f9857 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts @@ -701,6 +701,56 @@ describe('#decryptAttributes', () => { ); }); + it('retries decryption without namespace if incorrect namespace is provided and convertToMultiNamespaceType option is enabled', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrThree']), + convertToMultiNamespaceType: true, + }); + + const encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, // namespace was not included in descriptor during encryption + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + const mockUser = mockAuthenticatedUser(); + await expect( + service.decryptAttributes( + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { user: mockUser } + ) + ).resolves.toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + }); + expect(mockNodeCrypto.decrypt).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (success) + expect.anything(), + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); + }); + it('decrypts even if no attributes are included into AAD', async () => { const attributes = { attrOne: 'one', attrThree: 'three' }; service.registerType({ @@ -899,6 +949,44 @@ describe('#decryptAttributes', () => { ); }); + it('fails if retry decryption without namespace is not correct', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ + type: 'known-type-3', + attributesToEncrypt: new Set(['attrThree']), + convertToMultiNamespaceType: true, + }); + + encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-3', id: 'object-id', namespace: 'some-other-ns' }, + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + await expect(() => + service.decryptAttributes( + { type: 'known-type-3', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes + ) + ).rejects.toThrowError(EncryptionError); + expect(mockNodeCrypto.decrypt).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-3","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (fail) + expect.anything(), + `["known-type-3","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + }); + it('fails to decrypt if encrypted attribute is defined, but not a string', async () => { const mockUser = mockAuthenticatedUser(); await expect( @@ -1455,6 +1543,56 @@ describe('#decryptAttributesSync', () => { }); }); + it('retries decryption without namespace if incorrect namespace is provided and convertToMultiNamespaceType option is enabled', () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrThree']), + convertToMultiNamespaceType: true, + }); + + const encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, // namespace was not included in descriptor during encryption + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + const mockUser = mockAuthenticatedUser(); + expect( + service.decryptAttributesSync( + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { user: mockUser } + ) + ).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + }); + expect(mockNodeCrypto.decryptSync).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (success) + expect.anything(), + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); + }); + it('decrypts even if no attributes are included into AAD', () => { const attributes = { attrOne: 'one', attrThree: 'three' }; service.registerType({ @@ -1600,6 +1738,44 @@ describe('#decryptAttributesSync', () => { ).toThrowError(EncryptionError); }); + it('fails if retry decryption without namespace is not correct', () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ + type: 'known-type-3', + attributesToEncrypt: new Set(['attrThree']), + convertToMultiNamespaceType: true, + }); + + encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-3', id: 'object-id', namespace: 'some-other-ns' }, + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + expect(() => + service.decryptAttributesSync( + { type: 'known-type-3', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes + ) + ).toThrowError(EncryptionError); + expect(mockNodeCrypto.decryptSync).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-3","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (fail) + expect.anything(), + `["known-type-3","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + }); + it('fails to decrypt if encrypted attribute is defined, but not a string', () => { expect(() => service.decryptAttributesSync( diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index 91a3cfc921624..835082cc6b96a 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -32,6 +32,12 @@ export interface EncryptedSavedObjectTypeRegistration { readonly type: string; readonly attributesToEncrypt: ReadonlySet; readonly attributesToExcludeFromAAD?: ReadonlySet; + /** + * This should only be used in the `inputType` argument in an encrypted saved objects migration function. Consumers should only use it if + * they have also defined a conversion for this object in the core SavedObjectsTypeRegistry using the `convertToMultiNamespaceTypeVersion` + * field. + */ + readonly convertToMultiNamespaceType?: boolean; } /** @@ -356,18 +362,20 @@ export class EncryptedSavedObjectsService { let iteratorResult = iterator.next(); while (!iteratorResult.done) { - const [attributeValue, encryptionAAD] = iteratorResult.value; + const [attributeValue, encryptionAADs] = iteratorResult.value; let decryptionError; - for (const decrypter of decrypters) { - try { - iteratorResult = iterator.next(await decrypter.decrypt(attributeValue, encryptionAAD)); - decryptionError = undefined; - break; - } catch (err) { - // Remember the error thrown when we tried to decrypt with the primary key. - if (!decryptionError) { - decryptionError = err; + loop: for (const decrypter of decrypters) { + for (const encryptionAAD of encryptionAADs) { + try { + iteratorResult = iterator.next(await decrypter.decrypt(attributeValue, encryptionAAD)); + decryptionError = undefined; + break loop; + } catch (err) { + // Remember the error thrown when we tried to decrypt with the primary key. + if (!decryptionError) { + decryptionError = err; + } } } } @@ -400,18 +408,20 @@ export class EncryptedSavedObjectsService { let iteratorResult = iterator.next(); while (!iteratorResult.done) { - const [attributeValue, encryptionAAD] = iteratorResult.value; + const [attributeValue, encryptionAADs] = iteratorResult.value; let decryptionError; - for (const decrypter of decrypters) { - try { - iteratorResult = iterator.next(decrypter.decryptSync(attributeValue, encryptionAAD)); - decryptionError = undefined; - break; - } catch (err) { - // Remember the error thrown when we tried to decrypt with the primary key. - if (!decryptionError) { - decryptionError = err; + loop: for (const decrypter of decrypters) { + for (const encryptionAAD of encryptionAADs) { + try { + iteratorResult = iterator.next(decrypter.decryptSync(attributeValue, encryptionAAD)); + decryptionError = undefined; + break loop; + } catch (err) { + // Remember the error thrown when we tried to decrypt with the primary key. + if (!decryptionError) { + decryptionError = err; + } } } } @@ -428,12 +438,12 @@ export class EncryptedSavedObjectsService { descriptor: SavedObjectDescriptor, attributes: T, params?: CommonParameters - ): Iterator<[string, string], T, EncryptOutput> { + ): Iterator<[string, string[]], T, EncryptOutput> { const typeDefinition = this.typeDefinitions.get(descriptor.type); if (typeDefinition === undefined) { return attributes; } - let encryptionAAD: string | undefined; + const encryptionAADs: string[] = []; const decryptedAttributes: Record = {}; for (const attributeName of typeDefinition.attributesToEncrypt) { const attributeValue = attributes[attributeName]; @@ -449,11 +459,16 @@ export class EncryptedSavedObjectsService { )}` ); } - if (!encryptionAAD) { - encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes); + if (!encryptionAADs.length) { + encryptionAADs.push(this.getAAD(typeDefinition, descriptor, attributes)); + if (typeDefinition.convertToMultiNamespaceType && descriptor.namespace) { + // This is happening during a migration; create an alternate AAD for decrypting the object attributes by stripping out the namespace from the descriptor. + const { namespace, ...alternateDescriptor } = descriptor; + encryptionAADs.push(this.getAAD(typeDefinition, alternateDescriptor, attributes)); + } } try { - decryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!; + decryptedAttributes[attributeName] = (yield [attributeValue, encryptionAADs])!; } catch (err) { this.options.logger.error( `Failed to decrypt "${attributeName}" attribute: ${err.message || err}` diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts index 627e15e591a81..0f737995e8d2a 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts @@ -24,5 +24,5 @@ export const getDescriptorNamespace = ( * Ensure that a namespace is always in its namespace ID representation. * This allows `'default'` to be used interchangeably with `undefined`. */ -const normalizeNamespace = (namespace?: string) => +export const normalizeNamespace = (namespace?: string) => namespace === undefined ? namespace : SavedObjectsUtils.namespaceStringToId(namespace); From f5c1001314d81893891fb21921527c07bc4964c5 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 28 Jan 2021 12:42:39 -0500 Subject: [PATCH 03/29] Add extra info to saved object migration context The saved object migration context now describes what migration version is currently being run, and the object type's registered `convertToMultiNamespaceTypeVersion` field (if it exists). This allows the ESO migration function to more intelligently make decisions about how to handle object descriptors for additionally authenticated data (AAD). --- ...text.converttomultinamespacetypeversion.md | 13 ++++++ ...core-server.savedobjectmigrationcontext.md | 2 + ...objectmigrationcontext.migrationversion.md | 13 ++++++ .../migrations/core/document_migrator.ts | 14 ++++--- .../server/saved_objects/migrations/mocks.ts | 10 ++++- .../server/saved_objects/migrations/types.ts | 8 ++++ src/core/server/server.api.md | 2 + .../server/saved_objects/migrations.test.ts | 30 +++++++------- .../server/create_migration.test.ts | 32 +++++++++------ .../server/create_migration.ts | 9 +++-- .../encrypted_saved_object_type_definition.ts | 9 ----- .../encrypted_saved_objects_service.test.ts | 40 +++++++------------ .../crypto/encrypted_saved_objects_service.ts | 18 +++++---- 13 files changed, 120 insertions(+), 80 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md new file mode 100644 index 0000000000000..2a30693f4da84 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) > [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md) + +## SavedObjectMigrationContext.convertToMultiNamespaceTypeVersion property + +The version in which this object type is being converted to a multi-namespace type + +Signature: + +```typescript +convertToMultiNamespaceTypeVersion?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md index 901f2dde0944c..c8a291e502845 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md @@ -16,5 +16,7 @@ export interface SavedObjectMigrationContext | Property | Type | Description | | --- | --- | --- | +| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md) | string | The version in which this object type is being converted to a multi-namespace type | | [log](./kibana-plugin-core-server.savedobjectmigrationcontext.log.md) | SavedObjectsMigrationLogger | logger instance to be used by the migration handler | +| [migrationVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md) | string | The migration version that this migration function is defined for | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md new file mode 100644 index 0000000000000..7b20ae41048f6 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) > [migrationVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md) + +## SavedObjectMigrationContext.migrationVersion property + +The migration version that this migration function is defined for + +Signature: + +```typescript +migrationVersion: string; +``` diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index fd71c0b18c043..47f4dda75cdcd 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -374,7 +374,7 @@ function buildActiveMigrations( const migrationTransforms = Object.entries(migrationsMap ?? {}).map( ([version, transform]) => ({ version, - transform: wrapWithTry(version, type.name, transform, log), + transform: wrapWithTry(version, type, transform, log), transformType: 'migrate', }) ); @@ -655,24 +655,28 @@ function transformComparator(a: Transform, b: Transform) { */ function wrapWithTry( version: string, - type: string, + type: SavedObjectsType, migrationFn: SavedObjectMigrationFn, log: Logger ) { return function tryTransformDoc(doc: SavedObjectUnsanitizedDoc) { try { - const context = { log: new MigrationLogger(log) }; + const context = { + log: new MigrationLogger(log), + migrationVersion: version, + convertToMultiNamespaceTypeVersion: type.convertToMultiNamespaceTypeVersion, + }; const result = migrationFn(doc, context); // A basic sanity check to help migration authors detect basic errors // (e.g. forgetting to return the transformed doc) if (!result || !result.type) { - throw new Error(`Invalid saved object returned from migration ${type}:${version}.`); + throw new Error(`Invalid saved object returned from migration ${type.name}:${version}.`); } return { transformedDoc: result, additionalDocs: [] }; } catch (error) { - const failedTransform = `${type}:${version}`; + const failedTransform = `${type.name}:${version}`; const failedDoc = JSON.stringify(doc); log.warn( `Failed to transform document ${doc?.id}. Transform: ${failedTransform}\nDoc: ${failedDoc}` diff --git a/src/core/server/saved_objects/migrations/mocks.ts b/src/core/server/saved_objects/migrations/mocks.ts index f0360ec180d6e..4a62fcc95997b 100644 --- a/src/core/server/saved_objects/migrations/mocks.ts +++ b/src/core/server/saved_objects/migrations/mocks.ts @@ -21,9 +21,17 @@ export const createSavedObjectsMigrationLoggerMock = (): jest.Mocked => { +const createContextMock = ({ + migrationVersion = '8.0.0', + convertToMultiNamespaceTypeVersion, +}: { + migrationVersion?: string; + convertToMultiNamespaceTypeVersion?: string; +} = {}): jest.Mocked => { const mock = { log: createSavedObjectsMigrationLoggerMock(), + migrationVersion, + convertToMultiNamespaceTypeVersion, }; return mock; }; diff --git a/src/core/server/saved_objects/migrations/types.ts b/src/core/server/saved_objects/migrations/types.ts index 630be58eb047d..619a7f85a327b 100644 --- a/src/core/server/saved_objects/migrations/types.ts +++ b/src/core/server/saved_objects/migrations/types.ts @@ -57,6 +57,14 @@ export interface SavedObjectMigrationContext { * logger instance to be used by the migration handler */ log: SavedObjectsMigrationLogger; + /** + * The migration version that this migration function is defined for + */ + migrationVersion: string; + /** + * The version in which this object type is being converted to a multi-namespace type + */ + convertToMultiNamespaceTypeVersion?: string; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 564b5b12ce765..df6d1cca2fc21 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2093,7 +2093,9 @@ export interface SavedObjectExportBaseOptions { // @public export interface SavedObjectMigrationContext { + convertToMultiNamespaceTypeVersion?: string; log: SavedObjectsMigrationLogger; + migrationVersion: string; } // @public diff --git a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts index 2f9187b1ccc6a..36e228ead31da 100644 --- a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts @@ -12,7 +12,7 @@ import { SavedObjectUnsanitizedDoc } from 'kibana/server'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; import { migrationMocks } from 'src/core/server/mocks'; -const { log } = migrationMocks.createContext(); +const migrationContext = migrationMocks.createContext(); const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup(); describe('7.10.0', () => { @@ -26,7 +26,7 @@ describe('7.10.0', () => { test('marks alerts as legacy', () => { const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0']; const alert = getMockData({}); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -42,7 +42,7 @@ describe('7.10.0', () => { const alert = getMockData({ consumer: 'metrics', }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -59,7 +59,7 @@ describe('7.10.0', () => { const alert = getMockData({ consumer: 'securitySolution', }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -76,7 +76,7 @@ describe('7.10.0', () => { const alert = getMockData({ consumer: 'alerting', }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -104,7 +104,7 @@ describe('7.10.0', () => { }, ], }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -142,7 +142,7 @@ describe('7.10.0', () => { }, ], }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -179,7 +179,7 @@ describe('7.10.0', () => { }, ], }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -206,7 +206,7 @@ describe('7.10.0', () => { const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0']; const alert = getMockData(); const dateStart = Date.now(); - const migratedAlert = migration710(alert, { log }); + const migratedAlert = migration710(alert, migrationContext); const dateStop = Date.now(); const dateExecutionStatus = Date.parse( migratedAlert.attributes.executionStatus.lastExecutionDate @@ -242,14 +242,14 @@ describe('7.10.0 migrates with failure', () => { const alert = getMockData({ consumer: 'alerting', }); - const res = migration710(alert, { log }); + const res = migration710(alert, migrationContext); expect(res).toMatchObject({ ...alert, attributes: { ...alert.attributes, }, }); - expect(log.error).toHaveBeenCalledWith( + expect(migrationContext.log.error).toHaveBeenCalledWith( `encryptedSavedObject 7.10.0 migration failed for alert ${alert.id} with error: Can't migrate!`, { alertDocument: { @@ -274,7 +274,7 @@ describe('7.11.0', () => { test('add updatedAt field to alert - set to SavedObject updated_at attribute', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({}, true); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, @@ -287,7 +287,7 @@ describe('7.11.0', () => { test('add updatedAt field to alert - set to createdAt when SavedObject updated_at is not defined', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({}); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, @@ -300,7 +300,7 @@ describe('7.11.0', () => { test('add notifyWhen=onActiveAlert when throttle is null', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({}); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, @@ -313,7 +313,7 @@ describe('7.11.0', () => { test('add notifyWhen=onActiveAlert when throttle is set', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({ throttle: '5m' }); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts index c17ab36517fff..5fc355e2ef6de 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts @@ -15,7 +15,7 @@ afterEach(() => { }); describe('createMigration()', () => { - const { log } = migrationMocks.createContext(); + const migrationContext = migrationMocks.createContext(); const inputType = { type: 'known-type-1', attributesToEncrypt: new Set(['firstAttr']) }; const migrationType = { type: 'known-type-1', @@ -88,7 +88,7 @@ describe('createMigration()', () => { namespace: 'namespace', attributes, }, - { log } + migrationContext ); expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith( @@ -97,7 +97,8 @@ describe('createMigration()', () => { type: 'known-type-1', namespace: 'namespace', }, - attributes + attributes, + { convertToMultiNamespaceType: false } ); expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith( @@ -142,7 +143,7 @@ describe('createMigration()', () => { namespace: 'namespace', attributes, }, - { log } + migrationContext ); expect(serviceWithLegacyType.decryptAttributesSync).toHaveBeenCalledWith( @@ -151,7 +152,8 @@ describe('createMigration()', () => { type: 'known-type-1', namespace: 'namespace', }, - attributes + attributes, + { convertToMultiNamespaceType: false } ); expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith( @@ -164,7 +166,7 @@ describe('createMigration()', () => { ); }); - describe('uses the object `namespaces` field to populate the descriptor when the input type has the `convertToMultiNamespaceType` option enabled', () => { + describe('uses the object `namespaces` field to populate the descriptor when the migration context indicates this type is being converted', () => { const doTest = async ({ objectNamespace, decryptDescriptorNamespace, @@ -183,8 +185,7 @@ describe('createMigration()', () => { function (doc): doc is SavedObjectUnsanitizedDoc { return true; }, - (doc) => doc, - { ...inputType, convertToMultiNamespaceType: true } + (doc) => doc ); const attributes = { @@ -201,7 +202,10 @@ describe('createMigration()', () => { namespaces: objectNamespace ? [objectNamespace] : [], attributes, }, - { log } + migrationMocks.createContext({ + migrationVersion: '8.0.0', + convertToMultiNamespaceTypeVersion: '8.0.0', + }) ); expect(serviceWithLegacyType.decryptAttributesSync).toHaveBeenCalledWith( @@ -210,7 +214,8 @@ describe('createMigration()', () => { type: 'known-type-1', namespace: decryptDescriptorNamespace, }, - attributes + attributes, + { convertToMultiNamespaceType: true } ); expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith( @@ -287,7 +292,7 @@ describe('createMigration()', () => { firstAttr: '#####', }, }, - { log } + migrationContext ) ).toMatchObject({ id: '123', @@ -328,7 +333,7 @@ describe('createMigration()', () => { nonEncryptedAttr: 'non encrypted', }, }, - { log } + migrationContext ) ).toMatchObject({ id: '123', @@ -349,7 +354,8 @@ describe('createMigration()', () => { { firstAttr: '#####', nonEncryptedAttr: 'non encrypted', - } + }, + { convertToMultiNamespaceType: false } ); expect(serviceWithMigrationLegacyType.encryptAttributesSync).toHaveBeenCalledWith( diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts index fa4d70a741481..abfb9e1d839b4 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts @@ -64,10 +64,11 @@ export const getCreateMigration = ( return encryptedDoc; } - // If a document has been converted right before this migration function is called, it will no longer have a `namespace` field, but it + // If an object has been converted right before this migration function is called, it will no longer have a `namespace` field, but it // will have a `namespaces` field; in that case, the first/only element in that array should be used as the namespace in the descriptor // during decryption. - const { convertToMultiNamespaceType } = inputType ?? {}; + const convertToMultiNamespaceType = + context.convertToMultiNamespaceTypeVersion === context.migrationVersion; const decryptDescriptorNamespace = convertToMultiNamespaceType ? normalizeNamespace(encryptedDoc.namespaces?.[0]) // `namespaces` contains string values, but we need to normalize this to the namespace ID representation : encryptedDoc.namespace; @@ -83,7 +84,9 @@ export const getCreateMigration = ( return mapAttributes( migration( mapAttributes(encryptedDoc, (inputAttributes) => - inputService.decryptAttributesSync(decryptDescriptor, inputAttributes) + inputService.decryptAttributesSync(decryptDescriptor, inputAttributes, { + convertToMultiNamespaceType, + }) ), context ), diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts index 03df0487ef3cc..b2b6bd16c12cd 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts @@ -16,14 +16,6 @@ export class EncryptedSavedObjectAttributesDefinition { public readonly attributesToEncrypt: ReadonlySet; private readonly attributesToExcludeFromAAD: ReadonlySet | undefined; private readonly attributesToStrip: ReadonlySet; - /** - * Indicates whether objects of this type are being converted from a single-namespace type to a multi-namespace type. In this case, we may - * need to attempt decryption twice: once with a namespace in the descriptor (for use during index migration), and again without a - * namespace in the descriptor (for use during object migration). In other words, if the object is being decrypted during index migration, - * the object was previously encrypted with its namespace in the descriptor portion of the AAD; on the other hand, if the object is being - * decrypted during object migration, the object was never encrypted with its namespace in the descriptor portion of the AAD. - */ - public readonly convertToMultiNamespaceType: boolean; constructor(typeRegistration: EncryptedSavedObjectTypeRegistration) { const attributesToEncrypt = new Set(); @@ -43,7 +35,6 @@ export class EncryptedSavedObjectAttributesDefinition { this.attributesToEncrypt = attributesToEncrypt; this.attributesToStrip = attributesToStrip; this.attributesToExcludeFromAAD = typeRegistration.attributesToExcludeFromAAD; - this.convertToMultiNamespaceType = !!typeRegistration.convertToMultiNamespaceType; } /** diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts index 1f1c33b8f9857..a5765fc09ccb4 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts @@ -707,7 +707,6 @@ describe('#decryptAttributes', () => { service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attrThree']), - convertToMultiNamespaceType: true, }); const encryptedAttributes = service.encryptAttributesSync( @@ -725,7 +724,7 @@ describe('#decryptAttributes', () => { service.decryptAttributes( { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, encryptedAttributes, - { user: mockUser } + { user: mockUser, convertToMultiNamespaceType: true } ) ).resolves.toEqual({ attrOne: 'one', @@ -952,14 +951,8 @@ describe('#decryptAttributes', () => { it('fails if retry decryption without namespace is not correct', async () => { const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; - service.registerType({ - type: 'known-type-3', - attributesToEncrypt: new Set(['attrThree']), - convertToMultiNamespaceType: true, - }); - encryptedAttributes = service.encryptAttributesSync( - { type: 'known-type-3', id: 'object-id', namespace: 'some-other-ns' }, + { type: 'known-type-1', id: 'object-id', namespace: 'some-other-ns' }, attributes ); expect(encryptedAttributes).toEqual({ @@ -970,20 +963,21 @@ describe('#decryptAttributes', () => { await expect(() => service.decryptAttributes( - { type: 'known-type-3', id: 'object-id', namespace: 'object-ns' }, - encryptedAttributes + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { convertToMultiNamespaceType: true } ) ).rejects.toThrowError(EncryptionError); expect(mockNodeCrypto.decrypt).toHaveBeenCalledTimes(2); expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( 1, // first attempted to decrypt with the namespace in the descriptor (fail) expect.anything(), - `["object-ns","known-type-3","object-id",{"attrOne":"one","attrTwo":"two"}]` + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` ); expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( 2, // then attempted to decrypt without the namespace in the descriptor (fail) expect.anything(), - `["known-type-3","object-id",{"attrOne":"one","attrTwo":"two"}]` + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` ); }); @@ -1549,7 +1543,6 @@ describe('#decryptAttributesSync', () => { service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attrThree']), - convertToMultiNamespaceType: true, }); const encryptedAttributes = service.encryptAttributesSync( @@ -1567,7 +1560,7 @@ describe('#decryptAttributesSync', () => { service.decryptAttributesSync( { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, encryptedAttributes, - { user: mockUser } + { user: mockUser, convertToMultiNamespaceType: true } ) ).toEqual({ attrOne: 'one', @@ -1741,14 +1734,8 @@ describe('#decryptAttributesSync', () => { it('fails if retry decryption without namespace is not correct', () => { const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; - service.registerType({ - type: 'known-type-3', - attributesToEncrypt: new Set(['attrThree']), - convertToMultiNamespaceType: true, - }); - encryptedAttributes = service.encryptAttributesSync( - { type: 'known-type-3', id: 'object-id', namespace: 'some-other-ns' }, + { type: 'known-type-1', id: 'object-id', namespace: 'some-other-ns' }, attributes ); expect(encryptedAttributes).toEqual({ @@ -1759,20 +1746,21 @@ describe('#decryptAttributesSync', () => { expect(() => service.decryptAttributesSync( - { type: 'known-type-3', id: 'object-id', namespace: 'object-ns' }, - encryptedAttributes + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { convertToMultiNamespaceType: true } ) ).toThrowError(EncryptionError); expect(mockNodeCrypto.decryptSync).toHaveBeenCalledTimes(2); expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( 1, // first attempted to decrypt with the namespace in the descriptor (fail) expect.anything(), - `["object-ns","known-type-3","object-id",{"attrOne":"one","attrTwo":"two"}]` + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` ); expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( 2, // then attempted to decrypt without the namespace in the descriptor (fail) expect.anything(), - `["known-type-3","object-id",{"attrOne":"one","attrTwo":"two"}]` + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` ); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index 835082cc6b96a..73898ea86bbfe 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -32,12 +32,6 @@ export interface EncryptedSavedObjectTypeRegistration { readonly type: string; readonly attributesToEncrypt: ReadonlySet; readonly attributesToExcludeFromAAD?: ReadonlySet; - /** - * This should only be used in the `inputType` argument in an encrypted saved objects migration function. Consumers should only use it if - * they have also defined a conversion for this object in the core SavedObjectsTypeRegistry using the `convertToMultiNamespaceTypeVersion` - * field. - */ - readonly convertToMultiNamespaceType?: boolean; } /** @@ -67,6 +61,14 @@ interface DecryptParameters extends CommonParameters { * Indicates whether decryption should only be performed using secondary decryption-only keys. */ omitPrimaryEncryptionKey?: boolean; + /** + * Indicates whether the object to be decrypted is being converted from a single-namespace type to a multi-namespace type. In this case, + * we may need to attempt decryption twice: once with a namespace in the descriptor (for use during index migration), and again without a + * namespace in the descriptor (for use during object migration). In other words, if the object is being decrypted during index migration, + * the object was previously encrypted with its namespace in the descriptor portion of the AAD; on the other hand, if the object is being + * decrypted during object migration, the object was never encrypted with its namespace in the descriptor portion of the AAD. + */ + convertToMultiNamespaceType?: boolean; } interface EncryptedSavedObjectsServiceOptions { @@ -437,7 +439,7 @@ export class EncryptedSavedObjectsService { private *attributesToDecryptIterator>( descriptor: SavedObjectDescriptor, attributes: T, - params?: CommonParameters + params?: DecryptParameters ): Iterator<[string, string[]], T, EncryptOutput> { const typeDefinition = this.typeDefinitions.get(descriptor.type); if (typeDefinition === undefined) { @@ -461,7 +463,7 @@ export class EncryptedSavedObjectsService { } if (!encryptionAADs.length) { encryptionAADs.push(this.getAAD(typeDefinition, descriptor, attributes)); - if (typeDefinition.convertToMultiNamespaceType && descriptor.namespace) { + if (params?.convertToMultiNamespaceType && descriptor.namespace) { // This is happening during a migration; create an alternate AAD for decrypting the object attributes by stripping out the namespace from the descriptor. const { namespace, ...alternateDescriptor } = descriptor; encryptionAADs.push(this.getAAD(typeDefinition, alternateDescriptor, attributes)); From baac7bdfb0bd550784887d2652e5e0bec5dbfb78 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 29 Jan 2021 09:54:24 -0500 Subject: [PATCH 04/29] Refactor ShareToSpaceFlyout The existing component is now called ShareToSpaceFlyoutInternal, which implies that it should not be used by external plugins. --- .../public/share_saved_objects_to_space/components/index.ts | 2 +- ...lyout.test.tsx => share_to_space_flyout_internal.test.tsx} | 4 ++-- ...to_space_flyout.tsx => share_to_space_flyout_internal.tsx} | 2 +- .../share_saved_objects_to_space_action.tsx | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) rename x-pack/plugins/spaces/public/share_saved_objects_to_space/components/{share_to_space_flyout.test.tsx => share_to_space_flyout_internal.test.tsx} (99%) rename x-pack/plugins/spaces/public/share_saved_objects_to_space/components/{share_to_space_flyout.tsx => share_to_space_flyout_internal.tsx} (99%) diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts index 1fca0980e9d8b..fcc7441424e84 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts @@ -6,4 +6,4 @@ */ export { ContextWrapper } from './context_wrapper'; -export { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout'; +export { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal'; 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_internal.test.tsx similarity index 99% rename from x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx rename to x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx index 59b8d47e40e02..d62a8c4f332f0 100644 --- 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_internal.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import Boom from '@hapi/boom'; import { mountWithIntl, nextTick } from '@kbn/test/jest'; -import { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout'; +import { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal'; import { ShareToSpaceForm } from './share_to_space_form'; import { EuiLoadingSpinner, EuiSelectable } from '@elastic/eui'; import { Space } from '../../../../../../src/plugins/spaces_oss/common'; @@ -100,7 +100,7 @@ const setup = async (opts: SetupOpts = {}) => { // the context wrapper is only split into a separate component to avoid recreating the context upon every flyout state change const wrapper = mountWithIntl( - a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); -export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { +export const ShareToSpaceFlyoutInternal = (props: Props) => { const { onClose, onObjectUpdated, savedObject, spacesManager, toastNotifications } = props; const { namespaces: currentNamespaces = [] } = savedObject; const [shareOptions, setShareOptions] = useState({ selectedSpaceIds: [] }); 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 index f115119275abd..18456298bf686 100644 --- 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 @@ -12,7 +12,7 @@ import { SavedObjectsManagementAction, SavedObjectsManagementRecord, } from '../../../../../src/plugins/saved_objects_management/public'; -import { ContextWrapper, ShareSavedObjectsToSpaceFlyout } from './components'; +import { ContextWrapper, ShareToSpaceFlyoutInternal } from './components'; import { SpacesManager } from '../spaces_manager'; import { PluginsStart } from '../plugin'; @@ -58,7 +58,7 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage return ( - (this.isDataChanged = true)} savedObject={this.record} From f3ea298bfa63987cf23bd5b9d157ae07be08ffea Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Mon, 1 Feb 2021 09:39:05 -0500 Subject: [PATCH 05/29] Remove unnecessary dependency on NotificationsSetup The ShareToSpaceFlyout depended on NotificationsSetup, when it already had the ability to access the notifications service via the KibanaReactContextProvider that it uses. --- x-pack/plugins/spaces/public/plugin.tsx | 1 - .../components/context_wrapper.tsx | 3 ++- .../share_to_space_flyout_internal.test.tsx | 7 +------ .../share_to_space_flyout_internal.tsx | 9 ++++++--- .../share_saved_objects_to_space_action.test.tsx | 9 ++------- .../share_saved_objects_to_space_action.tsx | 4 +--- .../share_saved_objects_to_space_service.test.ts | 3 +-- .../share_saved_objects_to_space_service.ts | 16 +++------------- 8 files changed, 16 insertions(+), 36 deletions(-) diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 151157180ae49..384654bb59766 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -70,7 +70,6 @@ export class SpacesPlugin implements Plugin, }); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx index 17132d291a612..f04da4f52c2c5 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx @@ -30,10 +30,11 @@ export const ContextWrapper = (props: PropsWithChildren) => { return null; } - const { application, docLinks } = coreStart; + const { application, docLinks, notifications } = coreStart; const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ application, docLinks, + notifications, }); return {children}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx index d62a8c4f332f0..e13c52101f2e8 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx @@ -18,7 +18,6 @@ import { act } from '@testing-library/react'; import { spacesManagerMock } from '../../spaces_manager/mocks'; import { SpacesManager } from '../../spaces_manager'; import { coreMock } from '../../../../../../src/core/public/mocks'; -import { ToastsApi } from 'src/core/public'; import { EuiCallOut } from '@elastic/eui'; import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; import { NoSpacesAvailable } from './no_spaces_available'; @@ -70,10 +69,6 @@ const setup = async (opts: SetupOpts = {}) => { mockSpacesManager.getShareSavedObjectPermissions.mockResolvedValue({ shareToAllSpaces: true }); - const mockToastNotifications = { - addError: jest.fn(), - addSuccess: jest.fn(), - }; const savedObjectToShare = { type: 'dashboard', id: 'my-dash', @@ -94,6 +89,7 @@ const setup = async (opts: SetupOpts = {}) => { ...startServices.application.capabilities, spaces: { manage: true }, }; + const mockToastNotifications = startServices.notifications.toasts; getStartServices.mockResolvedValue([startServices, , ,]); // the flyout depends upon the Kibana React Context, and it cannot be used without the context wrapper @@ -103,7 +99,6 @@ const setup = async (opts: SetupOpts = {}) => { diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx index 1c121c142370f..b7868b7e3188d 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -23,7 +23,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ToastsStart } from 'src/core/public'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; import { GetSpaceResult } from '../../../common'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; @@ -37,14 +37,17 @@ interface Props { 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 ShareToSpaceFlyoutInternal = (props: Props) => { - const { onClose, onObjectUpdated, savedObject, spacesManager, toastNotifications } = props; + const { services } = useKibana(); + const { notifications } = services; + const toastNotifications = notifications!.toasts; + + const { onClose, onObjectUpdated, savedObject, spacesManager } = props; const { namespaces: currentNamespaces = [] } = savedObject; const [shareOptions, setShareOptions] = useState({ selectedSpaceIds: [] }); const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx index abe1579f2058f..57869fed94a05 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { coreMock, notificationServiceMock } from 'src/core/public/mocks'; +import { coreMock } from 'src/core/public/mocks'; import { SavedObjectsManagementRecord } from '../../../../../src/plugins/saved_objects_management/public'; import { spacesManagerMock } from '../spaces_manager/mocks'; import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; @@ -13,13 +13,8 @@ import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_ describe('ShareToSpaceSavedObjectsManagementAction', () => { const createAction = () => { const spacesManager = spacesManagerMock.create(); - const notificationsStart = notificationServiceMock.createStartContract(); const { getStartServices } = coreMock.createSetup(); - return new ShareToSpaceSavedObjectsManagementAction( - spacesManager, - notificationsStart, - getStartServices - ); + return new ShareToSpaceSavedObjectsManagementAction(spacesManager, getStartServices); }; describe('#euiAction.available', () => { describe('with an object type that has a namespaceType of "multiple"', () => { 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 index 18456298bf686..cb024f66de8ef 100644 --- 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 @@ -7,7 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { NotificationsStart, StartServicesAccessor } from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { SavedObjectsManagementAction, SavedObjectsManagementRecord, @@ -45,7 +45,6 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage constructor( private readonly spacesManager: SpacesManager, - private readonly notifications: NotificationsStart, private readonly getStartServices: StartServicesAccessor ) { super(); @@ -63,7 +62,6 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage onObjectUpdated={() => (this.isDataChanged = true)} savedObject={this.record} spacesManager={this.spacesManager} - toastNotifications={this.notifications.toasts} /> ); 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 index eeadb157b5187..47958477ad70f 100644 --- 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 @@ -9,7 +9,7 @@ import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_ // import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; import { spacesManagerMock } from '../spaces_manager/mocks'; import { ShareSavedObjectsToSpaceService } from '.'; -import { coreMock, notificationServiceMock } from 'src/core/public/mocks'; +import { coreMock } from 'src/core/public/mocks'; import { savedObjectsManagementPluginMock } from '../../../../../src/plugins/saved_objects_management/public/mocks'; describe('ShareSavedObjectsToSpaceService', () => { @@ -17,7 +17,6 @@ describe('ShareSavedObjectsToSpaceService', () => { it('registers the ShareToSpaceSavedObjectsManagement Action and Column', () => { const deps = { spacesManager: spacesManagerMock.create(), - notificationsSetup: notificationServiceMock.createSetupContract(), savedObjectsManagementSetup: savedObjectsManagementPluginMock.createSetupContract(), getStartServices: coreMock.createSetup().getStartServices, }; 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 index 08a7db106d6bb..268c2fbe34d2f 100644 --- 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { NotificationsSetup, StartServicesAccessor } from 'src/core/public'; +import { StartServicesAccessor } 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'; @@ -15,22 +15,12 @@ import { PluginsStart } from '../plugin'; interface SetupDeps { spacesManager: SpacesManager; savedObjectsManagementSetup: SavedObjectsManagementPluginSetup; - notificationsSetup: NotificationsSetup; getStartServices: StartServicesAccessor; } export class ShareSavedObjectsToSpaceService { - public setup({ - spacesManager, - savedObjectsManagementSetup, - notificationsSetup, - getStartServices, - }: SetupDeps) { - const action = new ShareToSpaceSavedObjectsManagementAction( - spacesManager, - notificationsSetup, - getStartServices - ); + public setup({ spacesManager, savedObjectsManagementSetup, getStartServices }: SetupDeps) { + const action = new ShareToSpaceSavedObjectsManagementAction(spacesManager, getStartServices); savedObjectsManagementSetup.actions.register(action); // Note: this column is hidden for now because no saved objects are shareable. It should be uncommented when at least one saved object type is multi-namespace. // const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); From 4ac23ded0f98f5d9fa032b860e21b720a2014123 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Mon, 1 Feb 2021 16:04:02 -0500 Subject: [PATCH 06/29] Add SpacesApiUi with reusable ShareToSpaceFlyout Includes changes to labels and i18n. Also adds configurable options for whether or not to display the "create new copy" callout and/or the "create new space" link text, and adds new test cases accordingly. --- src/plugins/spaces_oss/public/api.mock.ts | 25 ++- src/plugins/spaces_oss/public/api.ts | 113 ++++++++++ src/plugins/spaces_oss/public/index.ts | 8 +- .../components/copy_to_space_flyout.test.tsx | 17 +- .../components/copy_to_space_flyout.tsx | 31 ++- .../components/copy_to_space_form.tsx | 9 +- .../components/processing_copy_to_space.tsx | 15 +- .../components/space_result.tsx | 4 - .../components/space_result_details.tsx | 2 - .../copy_saved_objects_to_space_action.tsx | 11 +- .../summarize_copy_result.test.ts | 20 +- .../summarize_copy_result.ts | 21 +- .../copy_saved_objects_to_space/types.ts | 27 +++ x-pack/plugins/spaces/public/plugin.tsx | 23 ++- .../components/index.ts | 1 + .../components/selectable_spaces_control.tsx | 5 +- .../components/share_mode_control.tsx | 73 +++---- .../components/share_to_space_flyout.tsx | 39 ++++ .../share_to_space_flyout_internal.test.tsx | 195 +++++++++++------- .../share_to_space_flyout_internal.tsx | 183 +++++++++------- .../components/share_to_space_form.tsx | 24 ++- .../share_saved_objects_to_space/index.ts | 1 + ...are_saved_objects_to_space_action.test.tsx | 8 +- .../share_saved_objects_to_space_action.tsx | 34 +-- ...are_saved_objects_to_space_service.test.ts | 4 +- .../share_saved_objects_to_space_service.ts | 9 +- .../spaces/public/ui_api/components.ts | 26 +++ x-pack/plugins/spaces/public/ui_api/index.ts | 25 +++ x-pack/plugins/spaces/public/ui_api/mocks.ts | 27 +++ .../translations/translations/ja-JP.json | 8 - .../translations/translations/zh-CN.json | 8 - 31 files changed, 695 insertions(+), 301 deletions(-) create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx create mode 100644 x-pack/plugins/spaces/public/ui_api/components.ts create mode 100644 x-pack/plugins/spaces/public/ui_api/index.ts create mode 100644 x-pack/plugins/spaces/public/ui_api/mocks.ts diff --git a/src/plugins/spaces_oss/public/api.mock.ts b/src/plugins/spaces_oss/public/api.mock.ts index 4c6b8bb6ee338..cc8e075b2bd01 100644 --- a/src/plugins/spaces_oss/public/api.mock.ts +++ b/src/plugins/spaces_oss/public/api.mock.ts @@ -7,13 +7,36 @@ */ import { of } from 'rxjs'; -import { SpacesApi } from './api'; +import { SpacesApi, SpacesApiUi, SpacesApiUiComponent } from './api'; const createApiMock = (): jest.Mocked => ({ activeSpace$: of(), getActiveSpace: jest.fn(), + ui: createApiUiMock(), }); +type SpacesApiUiMock = Omit, 'components'> & { + components: SpacesApiUiComponentMock; +}; + +const createApiUiMock = () => { + const mock: SpacesApiUiMock = { + components: createApiUiComponentsMock(), + }; + + return mock; +}; + +type SpacesApiUiComponentMock = jest.Mocked; + +const createApiUiComponentsMock = () => { + const mock: SpacesApiUiComponentMock = { + ShareToSpaceFlyout: jest.fn(), + }; + + return mock; +}; + export const spacesApiMock = { create: createApiMock, }; diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index 5fa8b4fc29719..dbbdda006d6cd 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -7,6 +7,7 @@ */ import { Observable } from 'rxjs'; +import type { FunctionComponent } from 'react'; import { Space } from '../common'; /** @@ -15,4 +16,116 @@ import { Space } from '../common'; export interface SpacesApi { readonly activeSpace$: Observable; getActiveSpace(): Promise; + /** + * UI API to use to add spaces capabilities to an application + */ + ui: SpacesApiUi; +} + +/** + * @public + */ +export interface SpacesApiUi { + /** + * {@link SpacesApiUiComponent | React components} to support the spaces feature. + */ + components: SpacesApiUiComponent; +} + +/** + * React UI components to be used to display the spaces feature in any application. + * + * @public + */ +export interface SpacesApiUiComponent { + /** + * Displays the tags for given saved object. + */ + ShareToSpaceFlyout: FunctionComponent; +} + +/** + * @public + */ +export interface ShareToSpaceFlyoutProps { + /** + * The object to render the flyout for. + */ + savedObjectTarget: ShareToSpaceSavedObjectTarget; + /** + * The EUI icon that is rendered in the flyout's title. + * + * Default is 'share'. + */ + flyoutIcon?: string; + /** + * The string that is rendered in the flyout's title. + * + * Default is 'Edit spaces for object'. + */ + flyoutTitle?: string; + /** + * When enabled, if the object is not yet shared to multiple spaces, a callout will be displayed that suggests the user might want to + * create a copy instead. + * + * Default value is false. + */ + enableCreateCopyCallout?: boolean; + /** + * When enabled, if no other spaces exist _and_ the user has the appropriate privileges, a sentence will be displayed that suggests the + * user might want to create a space. + * + * Default value is false. + */ + enableCreateNewSpaceLink?: boolean; + /** + * Optional handler that is called when the user has saved changes and there are spaces to be added to and/or removed from the object. If + * this is not defined, a default handler will be used that calls `/api/spaces/_share_saved_object_add` and/or + * `/api/spaces/_share_saved_object_remove` and displays toast(s) indicating what occurred. + */ + changeSpacesHandler?: (spacesToAdd: string[], spacesToRemove: string[]) => Promise; + /** + * Optional callback when the target object is updated. + */ + onUpdate?: () => void; + /** + * Optional callback when the flyout is closed. + */ + onClose?: () => void; +} + +/** + * @public + */ +export interface ShareToSpaceSavedObjectTarget { + /** + * The object's type. + */ + type: string; + /** + * The object's ID. + */ + id: string; + /** + * The namespaces that the object currently exists in. + */ + namespaces: string[]; + /** + * The EUI icon that is rendered in the flyout's subtitle. + * + * Default is 'empty'. + */ + icon?: string; + /** + * The string that is rendered in the flyout's subtitle. + * + * Default is `${type} [id=${id}]`. + */ + title?: string; + /** + * The string that is used to describe the object in several places, e.g., _Make **object** available in selected spaces only_. + * + * Default value is 'object'. + */ + noun?: string; } diff --git a/src/plugins/spaces_oss/public/index.ts b/src/plugins/spaces_oss/public/index.ts index 70172f620d043..999f20c93463c 100644 --- a/src/plugins/spaces_oss/public/index.ts +++ b/src/plugins/spaces_oss/public/index.ts @@ -10,6 +10,12 @@ import { SpacesOssPlugin } from './plugin'; export { SpacesOssPluginSetup, SpacesOssPluginStart } from './types'; -export { SpacesApi } from './api'; +export { + SpacesApi, + SpacesApiUi, + SpacesApiUiComponent, + ShareToSpaceFlyoutProps, + ShareToSpaceSavedObjectTarget, +} from './api'; export const plugin = () => new SpacesOssPlugin(); 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 d89997042a3d8..d5d05795200a4 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 @@ -20,7 +20,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'; +import { SavedObjectTarget } from '../types'; interface SetupOpts { mockSpaces?: Space[]; @@ -70,19 +70,14 @@ const setup = async (opts: SetupOpts = {}) => { const savedObjectToCopy = { type: 'dashboard', id: 'my-dash', - references: [ - { - type: 'visualization', - id: 'my-viz', - name: 'My Viz', - }, - ], - meta: { icon: 'dashboard', title: 'foo', namespaceType: 'single' }, - } as SavedObjectsManagementRecord; + namespaces: ['default'], + icon: 'dashboard', + title: 'foo', + } as SavedObjectTarget; const wrapper = mountWithIntl( void; - savedObject: SavedObjectsManagementRecord; + savedObjectTarget: SavedObjectTarget; spacesManager: SpacesManager; toastNotifications: ToastsStart; } @@ -48,7 +47,17 @@ const CREATE_NEW_COPIES_DEFAULT = true; const OVERWRITE_ALL_DEFAULT = true; export const CopySavedObjectsToSpaceFlyout = (props: Props) => { - const { onClose, savedObject, spacesManager, toastNotifications } = props; + const { onClose, savedObjectTarget: object, spacesManager, toastNotifications } = props; + const savedObjectTarget = useMemo( + () => ({ + type: object.type, + id: object.id, + namespaces: object.namespaces, + icon: object.icon || 'apps', + title: object.title || `${object.type} [id=${object.id}]`, + }), + [object] + ); const [copyOptions, setCopyOptions] = useState({ includeRelated: INCLUDE_RELATED_DEFAULT, createNewCopies: CREATE_NEW_COPIES_DEFAULT, @@ -100,7 +109,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { setCopyResult({}); try { const copySavedObjectsResult = await spacesManager.copySavedObjects( - [{ type: savedObject.type, id: savedObject.id }], + [{ type: savedObjectTarget.type, id: savedObjectTarget.id }], copyOptions.selectedSpaceIds, copyOptions.includeRelated, copyOptions.createNewCopies, @@ -160,7 +169,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { setConflictResolutionInProgress(true); try { await spacesManager.resolveCopySavedObjectsErrors( - [{ type: savedObject.type, id: savedObject.id }], + [{ type: savedObjectTarget.type, id: savedObjectTarget.id }], retries, copyOptions.includeRelated, copyOptions.createNewCopies @@ -220,7 +229,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { if (!copyInProgress) { return ( { // Step3: Copy operation is in progress return ( { - + -

{savedObject.meta.title}

+

{savedObjectTarget.title}

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 5bf171874d5a8..6c0ab695d94d8 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 @@ -8,27 +8,26 @@ import React from 'react'; import { EuiSpacer, EuiTitle, EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { CopyOptions } from '../types'; -import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; +import { CopyOptions, SavedObjectTarget } from '../types'; import { Space } from '../../../../../../src/plugins/spaces_oss/common'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { CopyModeControl, CopyMode } from './copy_mode_control'; interface Props { - savedObject: SavedObjectsManagementRecord; + savedObjectTarget: Required; spaces: Space[]; onUpdate: (copyOptions: CopyOptions) => void; copyOptions: CopyOptions; } export const CopyToSpaceForm = (props: Props) => { - const { savedObject, spaces, onUpdate, copyOptions } = props; + const { savedObjectTarget, 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()); + : savedObjectTarget.namespaces.reduce((acc, cur) => acc.add(cur), new Set()); const changeCopyMode = ({ createNewCopies, overwrite }: CopyMode) => { const disabled = getDisabledSpaceIds(createNewCopies); 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 b30e996dbd0c1..08c72b595a61d 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 @@ -14,17 +14,14 @@ import { EuiHorizontalRule, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - ProcessedImportResponse, - SavedObjectsManagementRecord, -} from 'src/plugins/saved_objects_management/public'; +import { ProcessedImportResponse } from 'src/plugins/saved_objects_management/public'; import { Space } from '../../../../../../src/plugins/spaces_oss/common'; -import { CopyOptions, ImportRetry } from '../types'; +import { CopyOptions, ImportRetry, SavedObjectTarget } from '../types'; import { SpaceResult, SpaceResultProcessing } from './space_result'; import { summarizeCopyResult } from '..'; interface Props { - savedObject: SavedObjectsManagementRecord; + savedObjectTarget: Required; copyInProgress: boolean; conflictResolutionInProgress: boolean; copyResult: Record; @@ -98,7 +95,10 @@ 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); + const summarizedSpaceCopyResult = summarizeCopyResult( + props.savedObjectTarget, + spaceCopyResult + ); return ( @@ -106,7 +106,6 @@ export const ProcessingCopyToSpace = (props: Props) => { ) : ( { summarizedCopyResult, retries, onRetriesChange, - savedObject, conflictResolutionInProgress, } = props; const { objects } = summarizedCopyResult; @@ -109,7 +106,6 @@ export const SpaceResult = (props: Props) => { > 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 346bafceabf66..525efc4158f72 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 @@ -11,6 +11,7 @@ import { FailedImport, SavedObjectsManagementRecord, } from 'src/plugins/saved_objects_management/public'; +import { SavedObjectTarget } from './types'; // Sample data references: // @@ -21,6 +22,13 @@ import { // Dashboard has references to visualizations, and transitive references to index patterns const OBJECTS = { + COPY_TARGET: { + type: 'dashboard', + id: 'foo', + namespaces: [], + icon: 'dashboardApp', + title: 'my-dashboard-title', + } as Required, MY_DASHBOARD: { type: 'dashboard', id: 'foo', @@ -132,7 +140,7 @@ const createCopyResult = ( describe('summarizeCopyResult', () => { it('indicates the result is processing when not provided', () => { const copyResult = undefined; - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -155,7 +163,7 @@ describe('summarizeCopyResult', () => { it('processes failedImports to extract conflicts, including transitive conflicts', () => { const copyResult = createCopyResult({ withConflicts: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -235,7 +243,7 @@ describe('summarizeCopyResult', () => { it('processes failedImports to extract missing references errors', () => { const copyResult = createCopyResult({ withMissingReferencesError: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -292,7 +300,7 @@ describe('summarizeCopyResult', () => { it('processes failedImports to extract unresolvable errors', () => { const copyResult = createCopyResult({ withUnresolvableError: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -359,7 +367,7 @@ describe('summarizeCopyResult', () => { it('processes a result without errors', () => { const copyResult = createCopyResult(); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -426,7 +434,7 @@ describe('summarizeCopyResult', () => { it('indicates when successes and failures have been overwritten', () => { const copyResult = createCopyResult({ withMissingReferencesError: true, overwrite: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult.objects).toHaveLength(4); for (const obj of summarizedResult.objects) { 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 1e5282436a491..0986f5723a6de 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 @@ -5,15 +5,12 @@ * 2.0. */ -import { - SavedObjectsManagementRecord, - ProcessedImportResponse, - FailedImport, -} from 'src/plugins/saved_objects_management/public'; +import { ProcessedImportResponse, FailedImport } from 'src/plugins/saved_objects_management/public'; import { SavedObjectsImportConflictError, SavedObjectsImportAmbiguousConflictError, } from 'kibana/public'; +import { SavedObjectTarget } from './types'; export interface SummarizedSavedObjectResult { type: string; @@ -67,7 +64,7 @@ export type SummarizedCopyToSpaceResult = | ProcessingResponse; export function summarizeCopyResult( - savedObject: SavedObjectsManagementRecord, + savedObjectTarget: Required, copyResult: ProcessedImportResponse | undefined ): SummarizedCopyToSpaceResult { const conflicts = copyResult?.failedImports.filter(isAnyConflict) ?? []; @@ -95,12 +92,12 @@ export function summarizeCopyResult( }; const objectMap = new Map(); - objectMap.set(`${savedObject.type}:${savedObject.id}`, { - type: savedObject.type, - id: savedObject.id, - name: savedObject.meta.title, - icon: savedObject.meta.icon, - ...getExtraFields(savedObject), + objectMap.set(`${savedObjectTarget.type}:${savedObjectTarget.id}`, { + type: savedObjectTarget.type, + id: savedObjectTarget.id, + name: savedObjectTarget.title, + icon: savedObjectTarget.icon, + ...getExtraFields(savedObjectTarget), }); const addObjectsToMap = ( 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 1e3293df8f258..676b8ee460751 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 @@ -19,3 +19,30 @@ export type ImportRetry = Omit; export interface CopySavedObjectsToSpaceResponse { [spaceId: string]: SavedObjectsImportResponse; } + +export interface SavedObjectTarget { + /** + * The object's type. + */ + type: string; + /** + * The object's ID. + */ + id: string; + /** + * The namespaces that the object currently exists in. + */ + namespaces: string[]; + /** + * The EUI icon that is rendered in the flyout's subtitle. + * + * Default is 'apps'. + */ + icon?: string; + /** + * The string that is rendered in the flyout's subtitle. + * + * Default is `${type} [id=${id}]`. + */ + title?: string; +} diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 384654bb59766..5c345f97fbca1 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -20,6 +20,7 @@ import { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space' import { AdvancedSettingsService } from './advanced_settings'; import { ManagementService } from './management'; import { spaceSelectorApp } from './space_selector'; +import { getUiApi } from './ui_api'; export interface PluginsSetup { spacesOss: SpacesOssPluginSetup; @@ -39,11 +40,20 @@ export type SpacesPluginStart = ReturnType; export class SpacesPlugin implements Plugin { private spacesManager!: SpacesManager; + private spacesApi!: SpacesApi; private managementService?: ManagementService; public setup(core: CoreSetup<{}, SpacesPluginStart>, plugins: PluginsSetup) { this.spacesManager = new SpacesManager(core.http); + this.spacesApi = { + ui: getUiApi({ + spacesManager: this.spacesManager, + getStartServices: core.getStartServices as StartServicesAccessor, + }), + activeSpace$: this.spacesManager.onActiveSpaceChange$, + getActiveSpace: () => this.spacesManager.getActiveSpace(), + }; if (plugins.home) { plugins.home.featureCatalogue.register(createSpacesFeatureCatalogueEntry()); @@ -71,7 +81,7 @@ export class SpacesPlugin implements Plugin, + spacesApiUi: this.spacesApi.ui, }); const copySavedObjectsToSpaceService = new CopySavedObjectsToSpaceService(); copySavedObjectsToSpaceService.setup({ @@ -87,7 +97,7 @@ export class SpacesPlugin implements Plugin this.spacesManager.getActiveSpace(), - }; - } } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts index fcc7441424e84..6aaf5d45e4aa2 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts @@ -7,3 +7,4 @@ export { ContextWrapper } from './context_wrapper'; export { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal'; +export { getShareToSpaceFlyoutComponent } from './share_to_space_flyout'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index 707f60d5979a1..f6cc2d7d8dbf0 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -32,6 +32,7 @@ interface Props { spaces: SpaceTarget[]; selectedSpaceIds: string[]; onChange: (selectedSpaceIds: string[]) => void; + enableCreateNewSpaceLink: boolean; } type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; @@ -64,7 +65,7 @@ const activeSpaceProps = { }; export const SelectableSpacesControl = (props: Props) => { - const { spaces, selectedSpaceIds, onChange } = props; + const { spaces, selectedSpaceIds, onChange, enableCreateNewSpaceLink } = props; const { services } = useKibana(); const { application, docLinks } = services; @@ -130,7 +131,7 @@ export const SelectableSpacesControl = (props: Props) => { ); }; const getNoSpacesAvailable = () => { - if (spaces.length < 2) { + if (enableCreateNewSpaceLink && spaces.length < 2) { return ; } return null; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx index 1f71434de577d..d1ce553873e10 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx @@ -11,12 +11,10 @@ import { EuiCheckableCard, EuiFlexGroup, EuiFlexItem, - EuiFormFieldset, EuiIconTip, EuiLoadingSpinner, EuiSpacer, EuiText, - EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SelectableSpacesControl } from './selectable_spaces_control'; @@ -25,10 +23,11 @@ import { SpaceTarget } from '../types'; interface Props { spaces: SpaceTarget[]; + objectNoun: string; canShareToAllSpaces: boolean; selectedSpaceIds: string[]; onChange: (selectedSpaceIds: string[]) => void; - disabled?: boolean; + enableCreateNewSpaceLink: boolean; } function createLabel({ @@ -63,7 +62,14 @@ function createLabel({ } export const ShareModeControl = (props: Props) => { - const { spaces, canShareToAllSpaces, selectedSpaceIds, onChange } = props; + const { + spaces, + objectNoun, + canShareToAllSpaces, + selectedSpaceIds, + onChange, + enableCreateNewSpaceLink, + } = props; if (spaces.length === 0) { return ; @@ -78,7 +84,10 @@ export const ShareModeControl = (props: Props) => { ), text: i18n.translate( 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text', - { defaultMessage: 'Make object available in all current and future spaces.' } + { + defaultMessage: 'Make {objectNoun} available in all current and future spaces.', + values: { objectNoun }, + } ), ...(!canShareToAllSpaces && { tooltip: isGlobalControlChecked @@ -101,14 +110,13 @@ export const ShareModeControl = (props: Props) => { ), text: i18n.translate( 'xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text', - { defaultMessage: 'Make object available in selected spaces only.' } + { + defaultMessage: 'Make {objectNoun} available in selected spaces only.', + values: { objectNoun }, + } ), disabled: !canShareToAllSpaces && isGlobalControlChecked, }; - const shareOptionsTitle = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareOptionsTitle', - { defaultMessage: 'Share options' } - ); const toggleShareOption = (allSpaces: boolean) => { const updatedSpaceIds = allSpaces @@ -119,33 +127,28 @@ export const ShareModeControl = (props: Props) => { return ( <> - - {shareOptionsTitle} - - ), - }} + toggleShareOption(false)} + disabled={shareToExplicitSpaces.disabled} > - toggleShareOption(false)} - disabled={shareToExplicitSpaces.disabled} - > - - - - toggleShareOption(true)} - disabled={shareToAllSpaces.disabled} + - + + + toggleShareOption(true)} + disabled={shareToAllSpaces.disabled} + /> ); }; 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..7ad4063f1407b --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { StartServicesAccessor } from 'src/core/public'; +import type { ShareToSpaceFlyoutProps } from '../../../../../../src/plugins/spaces_oss/public'; +import { PluginsStart } from '../../plugin'; +import { SpacesManager } from '../../spaces_manager'; +import { ContextWrapper } from './context_wrapper'; +import { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal'; + +const ShareToSpaceFlyout: FC = ({ + spacesManager, + getStartServices, + ...props +}) => { + return ( + + + + ); +}; + +interface GetShareToSpaceFlyoutOptions { + spacesManager: SpacesManager; + getStartServices: StartServicesAccessor; +} + +export const getShareToSpaceFlyoutComponent = ( + options: GetShareToSpaceFlyoutOptions +): FC => { + return (props: ShareToSpaceFlyoutProps) => { + return ; + }; +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx index e13c52101f2e8..989a4616be99a 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import Boom from '@hapi/boom'; import { mountWithIntl, nextTick } from '@kbn/test/jest'; -import { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal'; import { ShareToSpaceForm } from './share_to_space_form'; import { EuiLoadingSpinner, EuiSelectable } from '@elastic/eui'; import { Space } from '../../../../../../src/plugins/spaces_oss/common'; @@ -16,23 +15,23 @@ import { findTestSubject } from '@kbn/test/jest'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { act } from '@testing-library/react'; import { spacesManagerMock } from '../../spaces_manager/mocks'; -import { SpacesManager } from '../../spaces_manager'; import { coreMock } from '../../../../../../src/core/public/mocks'; import { EuiCallOut } from '@elastic/eui'; import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; import { NoSpacesAvailable } from './no_spaces_available'; -import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; -import { ContextWrapper } from '.'; +import { getShareToSpaceFlyoutComponent } from './share_to_space_flyout'; interface SetupOpts { mockSpaces?: Space[]; namespaces?: string[]; returnBeforeSpacesLoad?: boolean; + enableCreateCopyCallout?: boolean; + enableCreateNewSpaceLink?: boolean; } const setup = async (opts: SetupOpts = {}) => { const onClose = jest.fn(); - const onObjectUpdated = jest.fn(); + const onUpdate = jest.fn(); const mockSpacesManager = spacesManagerMock.create(); @@ -72,16 +71,10 @@ const setup = async (opts: SetupOpts = {}) => { 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; + icon: 'dashboard', + title: 'foo', + }; const { getStartServices } = coreMock.createSetup(); const startServices = coreMock.createStart(); @@ -92,17 +85,21 @@ const setup = async (opts: SetupOpts = {}) => { const mockToastNotifications = startServices.notifications.toasts; getStartServices.mockResolvedValue([startServices, , ,]); - // the flyout depends upon the Kibana React Context, and it cannot be used without the context wrapper + const ShareToSpaceFlyout = getShareToSpaceFlyoutComponent({ + getStartServices, + spacesManager: mockSpacesManager, + }); + // the internal flyout depends upon the Kibana React Context, and it cannot be used without the context wrapper // the context wrapper is only split into a separate component to avoid recreating the context upon every flyout state change + // the ShareToSpaceFlyout component renders the internal flyout inside of the context wrapper const wrapper = mountWithIntl( - - - + ); // wait for context wrapper to rerender @@ -144,72 +141,128 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); }); - it('shows a message within a NoSpacesAvailable when no spaces are available', async () => { - const { wrapper, onClose } = await setup({ - mockSpaces: [{ id: 'my-active-space', name: 'my active space', disabledFeatures: [] }], - }); + describe('without enableCreateCopyCallout', () => { + it('does not show 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(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); - expect(onClose).toHaveBeenCalledTimes(0); + 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 message within a NoSpacesAvailable when only the active space is available', async () => { - const { wrapper, onClose } = await setup({ - mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], + describe('with enableCreateCopyCallout', () => { + const enableCreateCopyCallout = true; + + it('does not show a warning callout when the saved object has multiple namespaces', async () => { + const { wrapper, onClose } = await setup({ enableCreateCopyCallout }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); }); - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); - expect(onClose).toHaveBeenCalledTimes(0); - }); + it('shows a warning callout when the saved object only has one namespace', async () => { + const { wrapper, onClose } = await setup({ + enableCreateCopyCallout, + namespaces: ['my-active-space'], + }); - 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(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiCallOut)).toHaveLength(0); - 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({ + enableCreateCopyCallout, + namespaces: ['my-active-space'], + }); - 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(CopySavedObjectsToSpaceFlyout)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiCallOut)).toHaveLength(1); - 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({ + enableCreateCopyCallout, + namespaces: ['my-active-space'], + }); - 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(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(0); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(onClose).toHaveBeenCalledTimes(0); + const copyButton = findTestSubject(wrapper, 'sts-copy-link'); // this link 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('shows the Copy flyout if the the "Make a copy" button is clicked', async () => { - const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); + describe('without enableCreateNewSpaceLink', () => { + it('does not render a NoSpacesAvailable component when no spaces are available', async () => { + const { wrapper, onClose } = await setup({ + mockSpaces: [{ id: 'my-active-space', name: 'my active space', disabledFeatures: [] }], + }); - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); - const copyButton = findTestSubject(wrapper, 'sts-copy-link'); // this link is only present in the warning callout + it('does not render a NoSpacesAvailable component when only the active space is available', async () => { + const { wrapper, onClose } = await setup({ + mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], + }); - await act(async () => { - copyButton.simulate('click'); - await nextTick(); - wrapper.update(); + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + }); + + describe('with enableCreateNewSpaceLink', () => { + const enableCreateNewSpaceLink = true; + + it('renders a NoSpacesAvailable component when no spaces are available', async () => { + const { wrapper, onClose } = await setup({ + enableCreateNewSpaceLink, + mockSpaces: [{ id: 'my-active-space', name: 'my active space', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); }); - expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(1); - expect(onClose).toHaveBeenCalledTimes(0); + it('renders a NoSpacesAvailable component when only the active space is available', async () => { + const { wrapper, onClose } = await setup({ + enableCreateNewSpaceLink, + mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); }); it('handles errors thrown from shareSavedObjectsAdd API call', async () => { diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx index b7868b7e3188d..a9a2694b02242 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { EuiFlyout, EuiIcon, @@ -23,8 +23,12 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { ToastsStart } from 'src/core/public'; +import type { + ShareToSpaceFlyoutProps, + ShareToSpaceSavedObjectTarget, +} from 'src/plugins/spaces_oss/public'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; import { GetSpaceResult } from '../../../common'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; import { SpacesManager } from '../../spaces_manager'; @@ -32,23 +36,101 @@ 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; +interface InternalProps extends ShareToSpaceFlyoutProps { spacesManager: SpacesManager; } +const DEFAULT_FLYOUT_ICON = 'share'; +const DEFAULT_OBJECT_ICON = 'empty'; +const DEFAULT_OBJECT_NOUN = i18n.translate('xpack.spaces.management.shareToSpace.objectNoun', { + defaultMessage: 'object', +}); + const arraysAreEqual = (a: unknown[], b: unknown[]) => a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); -export const ShareToSpaceFlyoutInternal = (props: Props) => { +function createDefaultChangeSpacesHandler( + object: Required, + spacesManager: SpacesManager, + toastNotifications: ToastsStart +) { + return async (spacesToAdd: string[], spacesToRemove: string[]) => { + const { type, id, title } = object; + const toastTitle = i18n.translate('xpack.spaces.management.shareToSpace.shareSuccessTitle', { + values: { objectNoun: object.noun }, + defaultMessage: 'Updated {objectNoun}', + }); + const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID); + if (spacesToAdd.length > 0) { + await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); + const spaceTargets = isSharedToAllSpaces ? 'all' : `${spacesToAdd.length}`; + const toastText = + !isSharedToAllSpaces && spacesToAdd.length === 1 + ? i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular', { + defaultMessage: `'{object}' was added to 1 space.`, + values: { object: title }, + }) + : i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural', { + defaultMessage: `'{object}' was added to {spaceTargets} spaces.`, + values: { object: title, spaceTargets }, + }); + toastNotifications.addSuccess({ title: toastTitle, text: toastText }); + } + if (spacesToRemove.length > 0) { + await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); + const isUnsharedFromAllSpaces = spacesToRemove.includes(ALL_SPACES_ID); + const spaceTargets = isUnsharedFromAllSpaces ? 'all' : `${spacesToRemove.length}`; + const toastText = + !isUnsharedFromAllSpaces && spacesToRemove.length === 1 + ? i18n.translate('xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular', { + defaultMessage: `'{object}' was removed from 1 space.`, + values: { object: title }, + }) + : i18n.translate('xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural', { + defaultMessage: `'{object}' was removed from {spaceTargets} spaces.`, + values: { object: title, spaceTargets }, + }); + if (!isSharedToAllSpaces) { + toastNotifications.addSuccess({ title: toastTitle, text: toastText }); + } + } + }; +} + +export const ShareToSpaceFlyoutInternal = (props: InternalProps) => { const { services } = useKibana(); const { notifications } = services; const toastNotifications = notifications!.toasts; - const { onClose, onObjectUpdated, savedObject, spacesManager } = props; - const { namespaces: currentNamespaces = [] } = savedObject; + const { savedObjectTarget: object, spacesManager } = props; + const savedObjectTarget = useMemo( + () => ({ + type: object.type, + id: object.id, + namespaces: object.namespaces, + icon: object.icon || DEFAULT_OBJECT_ICON, + title: object.title || `${object.type} [id=${object.id}]`, + noun: object.noun || DEFAULT_OBJECT_NOUN, + }), + [object] + ); + const { + flyoutIcon = DEFAULT_FLYOUT_ICON, + flyoutTitle = i18n.translate('xpack.spaces.management.shareToSpace.flyoutTitle', { + defaultMessage: 'Edit spaces for {objectNoun}', + values: { objectNoun: savedObjectTarget.noun }, + }), + enableCreateCopyCallout = false, + enableCreateNewSpaceLink = false, + changeSpacesHandler = createDefaultChangeSpacesHandler( + savedObjectTarget, + spacesManager, + toastNotifications + ), + onUpdate = () => null, + onClose = () => null, + } = props; + const [shareOptions, setShareOptions] = useState({ selectedSpaceIds: [] }); const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); const [showMakeCopy, setShowMakeCopy] = useState(false); @@ -60,11 +142,13 @@ export const ShareToSpaceFlyoutInternal = (props: Props) => { useEffect(() => { const getSpaces = spacesManager.getSpaces({ includeAuthorizedPurposes: true }); const getActiveSpace = spacesManager.getActiveSpace(); - const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObject.type); + const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObjectTarget.type); Promise.all([getSpaces, getActiveSpace, getPermissions]) .then(([allSpaces, activeSpace, permissions]) => { setShareOptions({ - selectedSpaceIds: currentNamespaces.filter((spaceId) => spaceId !== activeSpace.id), + selectedSpaceIds: savedObjectTarget.namespaces.filter( + (spaceId) => spaceId !== activeSpace.id + ), }); setCanShareToAllSpaces(permissions.shareToAllSpaces); const createSpaceTarget = (space: GetSpaceResult): SpaceTarget => ({ @@ -84,14 +168,14 @@ export const ShareToSpaceFlyoutInternal = (props: Props) => { }), }); }); - }, [currentNamespaces, spacesManager, savedObject, toastNotifications]); + }, [savedObjectTarget, spacesManager, toastNotifications]); const getSelectionChanges = () => { const activeSpace = spaces.find((space) => space.isActiveSpace); if (!activeSpace) { return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] }; } - const initialSelection = currentNamespaces.filter( + const initialSelection = savedObjectTarget.namespaces.filter( (spaceId) => spaceId !== activeSpace.id && spaceId !== UNKNOWN_SPACE ); const { selectedSpaceIds } = shareOptions; @@ -134,59 +218,15 @@ export const ShareToSpaceFlyoutInternal = (props: Props) => { async function startShare() { setShareInProgress(true); try { - const { type, id, meta } = savedObject; - const title = - currentNamespaces.length === 1 - ? i18n.translate('xpack.spaces.management.shareToSpace.shareNewSuccessTitle', { - defaultMessage: 'Object is now shared', - }) - : i18n.translate('xpack.spaces.management.shareToSpace.shareEditSuccessTitle', { - defaultMessage: 'Object was updated', - }); - const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID); - if (spacesToAdd.length > 0) { - await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); - const spaceTargets = isSharedToAllSpaces ? 'all' : `${spacesToAdd.length}`; - const text = - !isSharedToAllSpaces && spacesToAdd.length === 1 - ? i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular', { - defaultMessage: `'{object}' was added to 1 space.`, - values: { object: meta.title }, - }) - : i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural', { - defaultMessage: `'{object}' was added to {spaceTargets} spaces.`, - values: { object: meta.title, spaceTargets }, - }); - toastNotifications.addSuccess({ title, text }); - } - if (spacesToRemove.length > 0) { - await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); - const isUnsharedFromAllSpaces = spacesToRemove.includes(ALL_SPACES_ID); - const spaceTargets = isUnsharedFromAllSpaces ? 'all' : `${spacesToRemove.length}`; - const text = - !isUnsharedFromAllSpaces && spacesToRemove.length === 1 - ? i18n.translate( - 'xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular', - { - defaultMessage: `'{object}' was removed from 1 space.`, - values: { object: meta.title }, - } - ) - : i18n.translate('xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural', { - defaultMessage: `'{object}' was removed from {spaceTargets} spaces.`, - values: { object: meta.title, spaceTargets }, - }); - if (!isSharedToAllSpaces) { - toastNotifications.addSuccess({ title, text }); - } - } - onObjectUpdated(); + await changeSpacesHandler(spacesToAdd, spacesToRemove); + onUpdate(); onClose(); } catch (e) { setShareInProgress(false); toastNotifications.addError(e, { title: i18n.translate('xpack.spaces.management.shareToSpace.shareErrorTitle', { - defaultMessage: 'Error updating saved object', + values: { objectNoun: savedObjectTarget.noun }, + defaultMessage: 'Error updating {objectNoun}', }), }); } @@ -200,16 +240,20 @@ export const ShareToSpaceFlyoutInternal = (props: Props) => { const activeSpace = spaces.find((x) => x.isActiveSpace)!; const showShareWarning = - spaces.length > 1 && arraysAreEqual(currentNamespaces, [activeSpace.id]); + enableCreateCopyCallout && + spaces.length > 1 && + arraysAreEqual(savedObjectTarget.namespaces, [activeSpace.id]); // Step 2: Share has not been initiated yet; User must fill out form to continue. return ( setShowMakeCopy(true)} + enableCreateNewSpaceLink={enableCreateNewSpaceLink} /> ); }; @@ -218,7 +262,7 @@ export const ShareToSpaceFlyoutInternal = (props: Props) => { return ( @@ -230,16 +274,11 @@ export const ShareToSpaceFlyoutInternal = (props: Props) => { - + -

- -

+

{flyoutTitle}

@@ -247,11 +286,11 @@ export const ShareToSpaceFlyoutInternal = (props: Props) => { - + -

{savedObject.meta.title}

+

{savedObjectTarget.title}

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 index ef5b731375f49..7bfd246201241 100644 --- 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 @@ -7,22 +7,33 @@ import './share_to_space_form.scss'; import React, { Fragment } from 'react'; -import { EuiHorizontalRule, EuiCallOut, EuiLink } from '@elastic/eui'; +import { EuiSpacer, EuiCallOut, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { ShareOptions, SpaceTarget } from '../types'; import { ShareModeControl } from './share_mode_control'; interface Props { spaces: SpaceTarget[]; + objectNoun: string; onUpdate: (shareOptions: ShareOptions) => void; shareOptions: ShareOptions; showShareWarning: boolean; canShareToAllSpaces: boolean; makeCopy: () => void; + enableCreateNewSpaceLink: boolean; } export const ShareToSpaceForm = (props: Props) => { - const { spaces, onUpdate, shareOptions, showShareWarning, canShareToAllSpaces, makeCopy } = props; + const { + spaces, + objectNoun, + onUpdate, + shareOptions, + showShareWarning, + canShareToAllSpaces, + makeCopy, + enableCreateNewSpaceLink, + } = props; const setSelectedSpaceIds = (selectedSpaceIds: string[]) => onUpdate({ ...shareOptions, selectedSpaceIds }); @@ -39,15 +50,16 @@ export const ShareToSpaceForm = (props: Props) => { title={ } color="warning" > makeCopy()}> { /> - +
); }; @@ -71,9 +83,11 @@ export const ShareToSpaceForm = (props: Props) => { setSelectedSpaceIds(selection)} + enableCreateNewSpaceLink={enableCreateNewSpaceLink} /> ); 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 index 5f8d0dfc2e949..f9a593fb3c2aa 100644 --- 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 @@ -6,3 +6,4 @@ */ export { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space_service'; +export { getShareToSpaceFlyoutComponent } from './components'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx index 57869fed94a05..a8d503d306ee8 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx @@ -5,16 +5,14 @@ * 2.0. */ -import { coreMock } from 'src/core/public/mocks'; import { SavedObjectsManagementRecord } from '../../../../../src/plugins/saved_objects_management/public'; -import { spacesManagerMock } from '../spaces_manager/mocks'; +import { uiApiMock } from '../ui_api/mocks'; import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; describe('ShareToSpaceSavedObjectsManagementAction', () => { const createAction = () => { - const spacesManager = spacesManagerMock.create(); - const { getStartServices } = coreMock.createSetup(); - return new ShareToSpaceSavedObjectsManagementAction(spacesManager, getStartServices); + const spacesApiUi = uiApiMock.create(); + return new ShareToSpaceSavedObjectsManagementAction(spacesApiUi); }; describe('#euiAction.available', () => { describe('with an object type that has a namespaceType of "multiple"', () => { 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 index cb024f66de8ef..67738ac8f7384 100644 --- 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 @@ -7,14 +7,11 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { StartServicesAccessor } from 'src/core/public'; import { SavedObjectsManagementAction, SavedObjectsManagementRecord, } from '../../../../../src/plugins/saved_objects_management/public'; -import { ContextWrapper, ShareToSpaceFlyoutInternal } from './components'; -import { SpacesManager } from '../spaces_manager'; -import { PluginsStart } from '../plugin'; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManagementAction { public id: string = 'share_saved_objects_to_space'; @@ -43,10 +40,7 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage private isDataChanged: boolean = false; - constructor( - private readonly spacesManager: SpacesManager, - private readonly getStartServices: StartServicesAccessor - ) { + constructor(private readonly spacesApiUi: SpacesApiUi) { super(); } @@ -55,15 +49,23 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage throw new Error('No record available! `render()` was likely called before `start()`.'); } + const savedObjectTarget = { + type: this.record.type, + id: this.record.id, + namespaces: this.record.namespaces ?? [], + title: this.record.meta.title, + icon: this.record.meta.icon, + }; + const { ShareToSpaceFlyout } = this.spacesApiUi.components; + return ( - - (this.isDataChanged = true)} - savedObject={this.record} - spacesManager={this.spacesManager} - /> - + (this.isDataChanged = true)} + onClose={this.onClose} + enableCreateCopyCallout={true} + enableCreateNewSpaceLink={true} + /> ); }; 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 index 47958477ad70f..a6daf1b36fa95 100644 --- 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 @@ -9,8 +9,8 @@ import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_ // import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; import { spacesManagerMock } from '../spaces_manager/mocks'; import { ShareSavedObjectsToSpaceService } from '.'; -import { coreMock } from 'src/core/public/mocks'; import { savedObjectsManagementPluginMock } from '../../../../../src/plugins/saved_objects_management/public/mocks'; +import { uiApiMock } from '../ui_api/mocks'; describe('ShareSavedObjectsToSpaceService', () => { describe('#setup', () => { @@ -18,7 +18,7 @@ describe('ShareSavedObjectsToSpaceService', () => { const deps = { spacesManager: spacesManagerMock.create(), savedObjectsManagementSetup: savedObjectsManagementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + spacesApiUi: uiApiMock.create(), }; const service = new ShareSavedObjectsToSpaceService(); 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 index 268c2fbe34d2f..8fc723ce34338 100644 --- 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 @@ -5,22 +5,21 @@ * 2.0. */ -import { StartServicesAccessor } from 'src/core/public'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; // import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; import { SpacesManager } from '../spaces_manager'; -import { PluginsStart } from '../plugin'; interface SetupDeps { spacesManager: SpacesManager; savedObjectsManagementSetup: SavedObjectsManagementPluginSetup; - getStartServices: StartServicesAccessor; + spacesApiUi: SpacesApiUi; } export class ShareSavedObjectsToSpaceService { - public setup({ spacesManager, savedObjectsManagementSetup, getStartServices }: SetupDeps) { - const action = new ShareToSpaceSavedObjectsManagementAction(spacesManager, getStartServices); + public setup({ savedObjectsManagementSetup, spacesApiUi }: SetupDeps) { + const action = new ShareToSpaceSavedObjectsManagementAction(spacesApiUi); savedObjectsManagementSetup.actions.register(action); // Note: this column is hidden for now because no saved objects are shareable. It should be uncommented when at least one saved object type is multi-namespace. // const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); diff --git a/x-pack/plugins/spaces/public/ui_api/components.ts b/x-pack/plugins/spaces/public/ui_api/components.ts new file mode 100644 index 0000000000000..90bc2d0e49684 --- /dev/null +++ b/x-pack/plugins/spaces/public/ui_api/components.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { StartServicesAccessor } from 'src/core/public'; +import type { SpacesApiUiComponent } from '../../../../../src/plugins/spaces_oss/public'; +import { PluginsStart } from '../plugin'; +import { getShareToSpaceFlyoutComponent } from '../share_saved_objects_to_space'; +import { SpacesManager } from '../spaces_manager'; + +export interface GetComponentsOptions { + spacesManager: SpacesManager; + getStartServices: StartServicesAccessor; +} + +export const getComponents = ({ + spacesManager, + getStartServices, +}: GetComponentsOptions): SpacesApiUiComponent => { + return { + ShareToSpaceFlyout: getShareToSpaceFlyoutComponent({ spacesManager, getStartServices }), + }; +}; diff --git a/x-pack/plugins/spaces/public/ui_api/index.ts b/x-pack/plugins/spaces/public/ui_api/index.ts new file mode 100644 index 0000000000000..2b0ace3d64dfa --- /dev/null +++ b/x-pack/plugins/spaces/public/ui_api/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { StartServicesAccessor } from 'src/core/public'; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; +import { PluginsStart } from '../plugin'; +import { SpacesManager } from '../spaces_manager'; +import { getComponents } from './components'; + +interface GetUiApiOptions { + spacesManager: SpacesManager; + getStartServices: StartServicesAccessor; +} + +export const getUiApi = ({ spacesManager, getStartServices }: GetUiApiOptions): SpacesApiUi => { + const components = getComponents({ spacesManager, getStartServices }); + + return { + components, + }; +}; diff --git a/x-pack/plugins/spaces/public/ui_api/mocks.ts b/x-pack/plugins/spaces/public/ui_api/mocks.ts new file mode 100644 index 0000000000000..6a4707fde9c14 --- /dev/null +++ b/x-pack/plugins/spaces/public/ui_api/mocks.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + SpacesApiUi, + SpacesApiUiComponent, +} from '../../../../../src/plugins/spaces_oss/public'; + +function createComponentsMock(): jest.Mocked { + return { + ShareToSpaceFlyout: jest.fn(), + }; +} + +function createUiApiMock(): jest.Mocked { + return { + components: createComponentsMock(), + }; +} + +export const uiApiMock = { + create: createUiApiMock, +}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 6658671b84682..c616b612585c0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20684,23 +20684,16 @@ "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked": "このスペースを選択するには、追加の権限が必要です。", "xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural": "「{object}」は{spaceTargets}個のスペースに追加されました。", "xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular": "「{object}」は1つのスペースに追加されました。", - "xpack.spaces.management.shareToSpace.shareEditSuccessTitle": "オブジェクトが更新されました", - "xpack.spaces.management.shareToSpace.shareErrorTitle": "保存されたオブジェクトの更新エラー", "xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount}個が非表示", "xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount}個が選択済み", "xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel": "スペースを選択", - "xpack.spaces.management.shareToSpace.shareModeControl.shareOptionsTitle": "共有オプション", "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip": "このオプションを使用するには、追加権限が必要です。", "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip": "このオプションを変更するには、追加権限が必要です。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text": "現在と将来のすべてのスペースでオブジェクトを使用可能にします。", "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title": "すべてのスペース", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text": "選択したスペースでのみオブジェクトを使用可能にします。", "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "スペースを選択", - "xpack.spaces.management.shareToSpace.shareNewSuccessTitle": "オブジェクトは共有されています", "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural": "「{object}」は{spaceTargets}個のスペースから削除されました。", "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular": "「{object}」は1つのスペースから削除されました。", "xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存して閉じる", - "xpack.spaces.management.shareToSpace.shareWarningBody": "1つのスペースでのみ編集するには、{makeACopyLink}してください。", "xpack.spaces.management.shareToSpace.shareWarningLink": "コピーを作成", "xpack.spaces.management.shareToSpace.shareWarningTitle": "共有オブジェクトの編集は、すべてのスペースで変更を適用します。", "xpack.spaces.management.shareToSpace.showLessSpacesLink": "縮小表示", @@ -20708,7 +20701,6 @@ "xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "利用可能なスペースを読み込み中にエラーが発生", "xpack.spaces.management.shareToSpace.unknownSpacesLabel.additionalPrivilegesLink": "追加権限", "xpack.spaces.management.shareToSpace.unknownSpacesLabel.text": "非表示のスペースを表示するには、{additionalPrivilegesLink}が必要です。", - "xpack.spaces.management.shareToSpaceFlyoutHeader": "スペースと共有", "xpack.spaces.management.showAllFeaturesText": "すべて表示", "xpack.spaces.management.spaceIdentifier.customizeSpaceLinkText": "[カスタマイズ]", "xpack.spaces.management.spaceIdentifier.customizeSpaceNameLinkLabel": "URL 識別子をカスタマイズ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 9602583e8d215..70d93b9928841 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -20732,23 +20732,16 @@ "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked": "您需要额外权限才能选择此工作区。", "xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural": "“{object}”已添加到 {spaceTargets} 个工作区。", "xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular": "“{object}”已添加到 1 个工作区。", - "xpack.spaces.management.shareToSpace.shareEditSuccessTitle": "对象已更新", - "xpack.spaces.management.shareToSpace.shareErrorTitle": "更新已保存对象时出错", "xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount} 个已隐藏", "xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount} 个已选择", "xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel": "选择工作区", - "xpack.spaces.management.shareToSpace.shareModeControl.shareOptionsTitle": "共享选项", "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip": "您还需要其他权限,才能使用此选项。", "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip": "您还需要其他权限,才能更改此选项。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text": "使对象在当前和将来的所有空间中可用。", "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title": "所有工作区", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text": "仅使对象在选定工作区中可用。", "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "选择工作区", - "xpack.spaces.management.shareToSpace.shareNewSuccessTitle": "对象现已共享", "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural": "“{object}”已从 {spaceTargets} 个工作区中移除。", "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular": "“{object}”已从 1 个工作区中移除。", "xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存并关闭", - "xpack.spaces.management.shareToSpace.shareWarningBody": "要仅在一个工作区中编辑,请改为{makeACopyLink}。", "xpack.spaces.management.shareToSpace.shareWarningLink": "创建副本", "xpack.spaces.management.shareToSpace.shareWarningTitle": "编辑共享对象会在所有工作区中应用更改", "xpack.spaces.management.shareToSpace.showLessSpacesLink": "显示更少", @@ -20756,7 +20749,6 @@ "xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "加载可用工作区时出错", "xpack.spaces.management.shareToSpace.unknownSpacesLabel.additionalPrivilegesLink": "其他权限", "xpack.spaces.management.shareToSpace.unknownSpacesLabel.text": "要查看隐藏的工作区,您需要{additionalPrivilegesLink}。", - "xpack.spaces.management.shareToSpaceFlyoutHeader": "共享到工作区", "xpack.spaces.management.showAllFeaturesText": "全部显示", "xpack.spaces.management.spaceIdentifier.customizeSpaceLinkText": "[定制]", "xpack.spaces.management.spaceIdentifier.customizeSpaceNameLinkLabel": "定制 URL 标识符", From 62e40f55c08e6daf144439504a4043c79e70c4ec Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 3 Feb 2021 17:58:11 -0500 Subject: [PATCH 07/29] Add privilege warning callout to ShareToSpaceFlyout If the user cannot change the object's spaces, a warning callout is displayed in addition to the tooltip. Also added unit tests to exercise this functionality and the ShareModeControl in general. --- .../components/share_mode_control.tsx | 34 ++++++ .../share_to_space_flyout_internal.test.tsx | 109 +++++++++++++++++- 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx index d1ce553873e10..3cd94109f3107 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx @@ -8,6 +8,7 @@ import './share_mode_control.scss'; import React from 'react'; import { + EuiCallOut, EuiCheckableCard, EuiFlexGroup, EuiFlexItem, @@ -17,6 +18,7 @@ import { EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { ALL_SPACES_ID } from '../../../common/constants'; import { SpaceTarget } from '../types'; @@ -125,8 +127,40 @@ export const ShareModeControl = (props: Props) => { onChange(updatedSpaceIds); }; + const getPrivilegeWarning = () => { + if (!shareToExplicitSpaces.disabled) { + return null; + } + + return ( + <> + + } + color="warning" + > + + + + + + ); + }; + return ( <> + {getPrivilegeWarning()} + { ] ); - mockSpacesManager.getShareSavedObjectPermissions.mockResolvedValue({ shareToAllSpaces: true }); + mockSpacesManager.getShareSavedObjectPermissions.mockResolvedValue({ + shareToAllSpaces: opts.canShareToAllSpaces ?? true, + }); const savedObjectToShare = { type: 'dashboard', @@ -444,6 +456,97 @@ describe('ShareToSpaceFlyout', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + describe('correctly renders checkable cards', () => { + function getCheckableCardProps( + wrapper: ReactWrapper> + ) { + const iconTip = wrapper.find(EuiIconTip); + return { + checked: !!wrapper.prop('checked'), + disabled: !!wrapper.prop('disabled'), + ...(iconTip.length > 0 && { tooltip: iconTip.prop('content') as string }), + }; + } + function getCheckableCards(wrapper: ReactWrapper) { + return { + explicitSpacesCard: getCheckableCardProps( + wrapper.find('#shareToExplicitSpaces').find(EuiCheckableCard) + ), + allSpacesCard: getCheckableCardProps( + wrapper.find('#shareToAllSpaces').find(EuiCheckableCard) + ), + }; + } + + describe('when user has privileges to share to all spaces', () => { + const canShareToAllSpaces = true; + + it('and the object is not shared to all spaces', async () => { + const namespaces = ['my-active-space']; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: true, disabled: false }, + allSpacesCard: { checked: false, disabled: false }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + }); + + it('and the object is shared to all spaces', async () => { + const namespaces = [ALL_SPACES_ID]; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: false, disabled: false }, + allSpacesCard: { checked: true, disabled: false }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + }); + }); + + describe('when user does not have privileges to share to all spaces', () => { + const canShareToAllSpaces = false; + + it('and the object is not shared to all spaces', async () => { + const namespaces = ['my-active-space']; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: true, disabled: false }, + allSpacesCard: { + checked: false, + disabled: true, + tooltip: 'You need additional privileges to use this option.', + }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + }); + + it('and the object is shared to all spaces', async () => { + const namespaces = [ALL_SPACES_ID]; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: false, disabled: true }, + allSpacesCard: { + checked: true, + disabled: true, + tooltip: 'You need additional privileges to change this option.', + }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(1); // "Additional privileges required" callout + }); + }); + }); + describe('space selection', () => { const mockSpaces = [ { From dba46ac8218c6108b2a00b0ac09c9f3035676f1a Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 4 Feb 2021 09:45:15 -0500 Subject: [PATCH 08/29] Add "space agnostic" option to ShareToSpaceFlyout This will allow the flyout to behave in a space-agnostic manner (instead of the default, which is space-aware). In other words, it will no longer treat the active space differently -- allowing the user to freely deselect the active space if they desire. This will be useful for ML, and for the saved objects management page in the future when we eventually show objects from all spaces. --- src/plugins/spaces_oss/public/api.ts | 7 ++ .../components/selectable_spaces_control.tsx | 16 +++- .../components/share_mode_control.tsx | 3 + .../share_to_space_flyout_internal.test.tsx | 84 +++++++++++++------ .../share_to_space_flyout_internal.tsx | 29 +++++-- .../components/share_to_space_form.tsx | 3 + 6 files changed, 106 insertions(+), 36 deletions(-) diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index dbbdda006d6cd..572c74dc19b81 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -78,6 +78,13 @@ export interface ShareToSpaceFlyoutProps { * Default value is false. */ enableCreateNewSpaceLink?: boolean; + /** + * When enabled, the flyout will allow the user to remove the object from the current space. Otherwise, the current space is noted, and + * the user cannot interact with it. + * + * Default value is false. + */ + enableSpaceAgnosticBehavior?: boolean; /** * Optional handler that is called when the user has saved changes and there are spaces to be added to and/or removed from the object. If * this is not defined, a default handler will be used that calls `/api/spaces/_share_saved_object_add` and/or diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index f6cc2d7d8dbf0..0a27ddea4dcfa 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -33,6 +33,7 @@ interface Props { selectedSpaceIds: string[]; onChange: (selectedSpaceIds: string[]) => void; enableCreateNewSpaceLink: boolean; + enableSpaceAgnosticBehavior: boolean; } type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; @@ -65,11 +66,17 @@ const activeSpaceProps = { }; export const SelectableSpacesControl = (props: Props) => { - const { spaces, selectedSpaceIds, onChange, enableCreateNewSpaceLink } = props; + const { + spaces, + selectedSpaceIds, + onChange, + enableCreateNewSpaceLink, + enableSpaceAgnosticBehavior, + } = props; const { services } = useKibana(); const { application, docLinks } = services; - const activeSpaceId = spaces.find((space) => space.isActiveSpace)!.id; + const activeSpaceId = spaces.find((space) => space.isActiveSpace)?.id; const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); const options = spaces .sort((a, b) => (a.isActiveSpace ? -1 : b.isActiveSpace ? 1 : 0)) @@ -137,8 +144,11 @@ export const SelectableSpacesControl = (props: Props) => { return null; }; + // if space-agnostic behavior is not enabled, the active space is not selected or deselected by the user, so we have to artifically pad the count for this label + const selectedCountPad = enableSpaceAgnosticBehavior ? 0 : 1; const selectedCount = - selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID && id !== UNKNOWN_SPACE).length + 1; + selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID && id !== UNKNOWN_SPACE).length + + selectedCountPad; const hiddenCount = selectedSpaceIds.filter((id) => id === UNKNOWN_SPACE).length; const selectSpacesLabel = i18n.translate( 'xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel', diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx index 3cd94109f3107..b30084f316070 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx @@ -30,6 +30,7 @@ interface Props { selectedSpaceIds: string[]; onChange: (selectedSpaceIds: string[]) => void; enableCreateNewSpaceLink: boolean; + enableSpaceAgnosticBehavior: boolean; } function createLabel({ @@ -71,6 +72,7 @@ export const ShareModeControl = (props: Props) => { selectedSpaceIds, onChange, enableCreateNewSpaceLink, + enableSpaceAgnosticBehavior, } = props; if (spaces.length === 0) { @@ -173,6 +175,7 @@ export const ShareModeControl = (props: Props) => { selectedSpaceIds={selectedSpaceIds} onChange={onChange} enableCreateNewSpaceLink={enableCreateNewSpaceLink} + enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior} /> diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx index a1e248c84448a..b88885c6e0512 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx @@ -37,6 +37,7 @@ interface SetupOpts { canShareToAllSpaces?: boolean; // default: true enableCreateCopyCallout?: boolean; enableCreateNewSpaceLink?: boolean; + enableSpaceAgnosticBehavior?: boolean; } const setup = async (opts: SetupOpts = {}) => { @@ -111,6 +112,7 @@ const setup = async (opts: SetupOpts = {}) => { onClose={onClose} enableCreateCopyCallout={opts.enableCreateCopyCallout} enableCreateNewSpaceLink={opts.enableCreateNewSpaceLink} + enableSpaceAgnosticBehavior={opts.enableSpaceAgnosticBehavior} /> ); @@ -609,32 +611,66 @@ describe('ShareToSpaceFlyout', () => { expect(option.disabled).toEqual(true); }; - it('correctly defines space selection options when spaces are not selected', async () => { - const namespaces = ['my-active-space']; // the saved object's current namespaces; it will always exist in at least the active namespace - const { wrapper } = await setup({ mockSpaces, namespaces }); - - const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); - const selectOptions = selectable.prop('options'); - expect(selectOptions[0]['data-space-id']).toEqual('my-active-space'); - expectActiveSpace(selectOptions[0]); - expect(selectOptions[1]['data-space-id']).toEqual('space-1'); - expectInactiveSpace(selectOptions[1], false); - expect(selectOptions[2]['data-space-id']).toEqual('space-2'); - expectPartiallyAuthorizedSpace(selectOptions[2], false); + describe('without enableSpaceAgnosticBehavior', () => { + it('correctly defines space selection options when spaces are not selected', async () => { + const namespaces = ['my-active-space']; // the saved object's current namespaces; it will always exist in at least the active namespace + const { wrapper } = await setup({ mockSpaces, namespaces }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const selectOptions = selectable.prop('options'); + expect(selectOptions[0]['data-space-id']).toEqual('my-active-space'); + expectActiveSpace(selectOptions[0]); + expect(selectOptions[1]['data-space-id']).toEqual('space-1'); + expectInactiveSpace(selectOptions[1], false); + expect(selectOptions[2]['data-space-id']).toEqual('space-2'); + expectPartiallyAuthorizedSpace(selectOptions[2], false); + }); + + it('correctly defines space selection options when spaces are selected', async () => { + const namespaces = ['my-active-space', 'space-1', 'space-2']; // the saved object's current namespaces + const { wrapper } = await setup({ mockSpaces, namespaces }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const selectOptions = selectable.prop('options'); + expect(selectOptions[0]['data-space-id']).toEqual('my-active-space'); + expectActiveSpace(selectOptions[0]); + expect(selectOptions[1]['data-space-id']).toEqual('space-1'); + expectInactiveSpace(selectOptions[1], true); + expect(selectOptions[2]['data-space-id']).toEqual('space-2'); + expectPartiallyAuthorizedSpace(selectOptions[2], true); + }); }); - it('correctly defines space selection options when spaces are selected', async () => { - const namespaces = ['my-active-space', 'space-1', 'space-2']; // the saved object's current namespaces - const { wrapper } = await setup({ mockSpaces, namespaces }); - - const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); - const selectOptions = selectable.prop('options'); - expect(selectOptions[0]['data-space-id']).toEqual('my-active-space'); - expectActiveSpace(selectOptions[0]); - expect(selectOptions[1]['data-space-id']).toEqual('space-1'); - expectInactiveSpace(selectOptions[1], true); - expect(selectOptions[2]['data-space-id']).toEqual('space-2'); - expectPartiallyAuthorizedSpace(selectOptions[2], true); + describe('with enableSpaceAgnosticBehavior', () => { + const enableSpaceAgnosticBehavior = true; + + it('correctly defines space selection options when spaces are not selected', async () => { + const namespaces = ['my-active-space']; // the saved object's current namespaces; it will always exist in at least the active namespace + const { wrapper } = await setup({ enableSpaceAgnosticBehavior, mockSpaces, namespaces }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const selectOptions = selectable.prop('options'); + expect(selectOptions[0]['data-space-id']).toEqual('space-1'); + expectInactiveSpace(selectOptions[0], false); + expect(selectOptions[1]['data-space-id']).toEqual('space-2'); + expectPartiallyAuthorizedSpace(selectOptions[1], false); + expect(selectOptions[2]['data-space-id']).toEqual('my-active-space'); + expectInactiveSpace(selectOptions[2], true); + }); + + it('correctly defines space selection options when spaces are selected', async () => { + const namespaces = ['my-active-space', 'space-1', 'space-2']; // the saved object's current namespaces + const { wrapper } = await setup({ enableSpaceAgnosticBehavior, mockSpaces, namespaces }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const selectOptions = selectable.prop('options'); + expect(selectOptions[0]['data-space-id']).toEqual('space-1'); + expectInactiveSpace(selectOptions[0], true); + expect(selectOptions[1]['data-space-id']).toEqual('space-2'); + expectPartiallyAuthorizedSpace(selectOptions[1], true); + expect(selectOptions[2]['data-space-id']).toEqual('my-active-space'); + expectInactiveSpace(selectOptions[2], true); + }); }); }); }); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx index a9a2694b02242..1dd3b06b30812 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -28,6 +28,7 @@ import type { ShareToSpaceFlyoutProps, ShareToSpaceSavedObjectTarget, } from 'src/plugins/spaces_oss/public'; +import type { Space } from 'src/plugins/spaces_oss/common'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { GetSpaceResult } from '../../../common'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; @@ -122,6 +123,7 @@ export const ShareToSpaceFlyoutInternal = (props: InternalProps) => { }), enableCreateCopyCallout = false, enableCreateNewSpaceLink = false, + enableSpaceAgnosticBehavior = false, changeSpacesHandler = createDefaultChangeSpacesHandler( savedObjectTarget, spacesManager, @@ -141,7 +143,9 @@ export const ShareToSpaceFlyoutInternal = (props: InternalProps) => { }>({ isLoading: true, spaces: [] }); useEffect(() => { const getSpaces = spacesManager.getSpaces({ includeAuthorizedPurposes: true }); - const getActiveSpace = spacesManager.getActiveSpace(); + const getActiveSpace = enableSpaceAgnosticBehavior + ? Promise.resolve({} as Space) + : spacesManager.getActiveSpace(); const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObjectTarget.type); Promise.all([getSpaces, getActiveSpace, getPermissions]) .then(([allSpaces, activeSpace, permissions]) => { @@ -168,15 +172,15 @@ export const ShareToSpaceFlyoutInternal = (props: InternalProps) => { }), }); }); - }, [savedObjectTarget, spacesManager, toastNotifications]); + }, [savedObjectTarget, spacesManager, toastNotifications, enableSpaceAgnosticBehavior]); const getSelectionChanges = () => { const activeSpace = spaces.find((space) => space.isActiveSpace); - if (!activeSpace) { + if (!activeSpace && !enableSpaceAgnosticBehavior) { return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] }; } const initialSelection = savedObjectTarget.namespaces.filter( - (spaceId) => spaceId !== activeSpace.id && spaceId !== UNKNOWN_SPACE + (spaceId) => spaceId !== activeSpace?.id && spaceId !== UNKNOWN_SPACE ); const { selectedSpaceIds } = shareOptions; const filteredSelection = selectedSpaceIds.filter((x) => x !== UNKNOWN_SPACE); @@ -199,15 +203,16 @@ export const ShareToSpaceFlyoutInternal = (props: InternalProps) => { (spaceId) => !filteredSelection.includes(spaceId) ); + const spacesArray = activeSpace ? [activeSpace.id] : []; // if we have an active space, it is automatically selected const spacesToAdd = isSharedToAllSpaces ? [ALL_SPACES_ID] : isUnsharedFromAllSpaces - ? [activeSpace.id, ...selectedSpacesToAdd] + ? [...spacesArray, ...selectedSpacesToAdd] : selectedSpacesToAdd; const spacesToRemove = isUnsharedFromAllSpaces ? [ALL_SPACES_ID] : isSharedToAllSpaces - ? [activeSpace.id, ...initialSelection] + ? [...spacesArray, ...initialSelection] : selectedSpacesToRemove; return { isSelectionChanged, spacesToAdd, spacesToRemove }; }; @@ -238,11 +243,11 @@ export const ShareToSpaceFlyoutInternal = (props: InternalProps) => { return ; } - const activeSpace = spaces.find((x) => x.isActiveSpace)!; const showShareWarning = enableCreateCopyCallout && spaces.length > 1 && - arraysAreEqual(savedObjectTarget.namespaces, [activeSpace.id]); + savedObjectTarget.namespaces.length === 1 && + !arraysAreEqual(savedObjectTarget.namespaces, [ALL_SPACES_ID]); // Step 2: Share has not been initiated yet; User must fill out form to continue. return ( { canShareToAllSpaces={canShareToAllSpaces} makeCopy={() => setShowMakeCopy(true)} enableCreateNewSpaceLink={enableCreateNewSpaceLink} + enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior} /> ); }; @@ -269,6 +275,11 @@ export const ShareToSpaceFlyoutInternal = (props: InternalProps) => { ); } + const isStartShareButtonDisabled = + !isSelectionChanged || + shareInProgress || + (enableSpaceAgnosticBehavior && !shareOptions.selectedSpaceIds.length); // the object must exist in at least one space, or all spaces + return ( @@ -319,7 +330,7 @@ export const ShareToSpaceFlyoutInternal = (props: InternalProps) => { fill onClick={() => startShare()} data-test-subj="sts-initiate-button" - disabled={!isSelectionChanged || shareInProgress} + disabled={isStartShareButtonDisabled} > void; enableCreateNewSpaceLink: boolean; + enableSpaceAgnosticBehavior: boolean; } export const ShareToSpaceForm = (props: Props) => { @@ -33,6 +34,7 @@ export const ShareToSpaceForm = (props: Props) => { canShareToAllSpaces, makeCopy, enableCreateNewSpaceLink, + enableSpaceAgnosticBehavior, } = props; const setSelectedSpaceIds = (selectedSpaceIds: string[]) => @@ -88,6 +90,7 @@ export const ShareToSpaceForm = (props: Props) => { selectedSpaceIds={shareOptions.selectedSpaceIds} onChange={(selection) => setSelectedSpaceIds(selection)} enableCreateNewSpaceLink={enableCreateNewSpaceLink} + enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior} /> ); From 3d6e1abb04bbd93efe5e4fd6d2f7f613b1d8d535 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Sat, 6 Feb 2021 17:54:14 -0500 Subject: [PATCH 09/29] Add SpacesContext component This React context fetches Spaces data one time, allowing any children to consume it without re-fetching. The first such children to use the SpacesContext are the ShareToSpaceFlyout and the ShareToSpaceAction. --- .../saved_objects_management/kibana.json | 2 +- .../management_section/mount_section.tsx | 9 +- .../objects_table/components/table.tsx | 11 -- .../saved_objects_table_page.tsx | 68 ++++--- .../saved_objects_management/public/plugin.ts | 2 + .../public/services/types/column.ts | 3 - .../saved_objects_management/tsconfig.json | 1 + src/plugins/spaces_oss/public/api.mock.ts | 1 + src/plugins/spaces_oss/public/api.ts | 4 + src/plugins/spaces_oss/public/index.ts | 7 +- src/plugins/spaces_oss/public/types.ts | 4 +- x-pack/plugins/spaces/public/plugin.tsx | 1 - .../components/context_wrapper.tsx | 41 ---- .../components/index.ts | 1 - .../components/selectable_spaces_control.tsx | 11 +- .../components/share_to_space_flyout.tsx | 29 +-- .../share_to_space_flyout_internal.test.tsx | 34 ++-- .../share_to_space_flyout_internal.tsx | 40 ++-- ...are_saved_objects_to_space_column.test.tsx | 178 +++++++++--------- .../share_saved_objects_to_space_column.tsx | 48 ++--- ...are_saved_objects_to_space_service.test.ts | 2 - .../share_saved_objects_to_space_service.ts | 4 +- .../share_saved_objects_to_space/types.ts | 2 +- .../spaces/public/spaces_context/context.tsx | 41 ++++ .../spaces/public/spaces_context/index.ts | 9 + .../spaces/public/spaces_context/types.ts | 30 +++ .../spaces/public/spaces_context/wrapper.tsx | 71 +++++++ .../spaces/public/ui_api/components.ts | 4 +- x-pack/plugins/spaces/public/ui_api/mocks.ts | 1 + 29 files changed, 371 insertions(+), 288 deletions(-) delete mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx create mode 100644 x-pack/plugins/spaces/public/spaces_context/context.tsx create mode 100644 x-pack/plugins/spaces/public/spaces_context/index.ts create mode 100644 x-pack/plugins/spaces/public/spaces_context/types.ts create mode 100644 x-pack/plugins/spaces/public/spaces_context/wrapper.tsx diff --git a/src/plugins/saved_objects_management/kibana.json b/src/plugins/saved_objects_management/kibana.json index f062433605c53..6c6d11d053c0f 100644 --- a/src/plugins/saved_objects_management/kibana.json +++ b/src/plugins/saved_objects_management/kibana.json @@ -4,7 +4,7 @@ "server": true, "ui": true, "requiredPlugins": ["management", "data"], - "optionalPlugins": ["dashboard", "visualizations", "discover", "home", "savedObjectsTaggingOss"], + "optionalPlugins": ["dashboard", "visualizations", "discover", "home", "savedObjectsTaggingOss", "spacesOss"], "extraPublicDirs": ["public/lib"], "requiredBundles": ["kibanaReact", "home"] } 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 d6cebd491b6e3..37ff79719c870 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 @@ -37,7 +37,11 @@ export const mountManagementSection = async ({ mountParams, serviceRegistry, }: MountParams) => { - const [coreStart, { data, savedObjectsTaggingOss }, pluginStart] = await core.getStartServices(); + const [ + coreStart, + { data, savedObjectsTaggingOss, spacesOss }, + pluginStart, + ] = await core.getStartServices(); const { element, history, setBreadcrumbs } = mountParams; if (allowedObjectTypes === undefined) { allowedObjectTypes = await getAllowedTypes(coreStart.http); @@ -57,6 +61,8 @@ export const mountManagementSection = async ({ return children! as React.ReactElement; }; + const spacesApi = (spacesOss?.isSpacesAvailable && spacesOss) || undefined; + ReactDOM.render( @@ -79,6 +85,7 @@ export const mountManagementSection = async ({ { @@ -80,22 +79,12 @@ 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({ diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx index 8049b8adfdf1c..ed7233bceea38 100644 --- a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx +++ b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useEffect, useMemo } from 'react'; +import React, { createElement, useEffect, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { get } from 'lodash'; import { Query } from '@elastic/eui'; @@ -15,6 +15,7 @@ import { i18n } from '@kbn/i18n'; import { CoreStart, ChromeBreadcrumb } from 'src/core/public'; import { DataPublicPluginStart } from '../../../data/public'; import { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; +import type { SpacesAvailableStartContract } from '../../../spaces_oss/public'; import { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementActionServiceStart, @@ -22,10 +23,14 @@ import { } from '../services'; import { SavedObjectsTable } from './objects_table'; +const EmptyFunctionComponent: React.FC = ({ children }) => + createElement('EmptyFunctionComponent', { children }); + const SavedObjectsTablePage = ({ coreStart, dataStart, taggingApi, + spacesApi, allowedTypes, serviceRegistry, actionRegistry, @@ -35,6 +40,7 @@ const SavedObjectsTablePage = ({ coreStart: CoreStart; dataStart: DataPublicPluginStart; taggingApi?: SavedObjectsTaggingApi; + spacesApi?: SpacesAvailableStartContract; allowedTypes: string[]; serviceRegistry: ISavedObjectsManagementServiceRegistry; actionRegistry: SavedObjectsManagementActionServiceStart; @@ -65,35 +71,39 @@ const SavedObjectsTablePage = ({ ]); }, [setBreadcrumbs]); + const ContextWrapper = spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent; + return ( - { - const { editUrl } = savedObject.meta; - if (editUrl) { - return coreStart.application.navigateToUrl( - coreStart.http.basePath.prepend(`/app${editUrl}`) - ); - } - }} - canGoInApp={(savedObject) => { - const { inAppUrl } = savedObject.meta; - return inAppUrl ? Boolean(get(capabilities, inAppUrl.uiCapabilitiesPath)) : false; - }} - /> + + { + const { editUrl } = savedObject.meta; + if (editUrl) { + return coreStart.application.navigateToUrl( + coreStart.http.basePath.prepend(`/app${editUrl}`) + ); + } + }} + canGoInApp={(savedObject) => { + const { inAppUrl } = savedObject.meta; + return inAppUrl ? Boolean(get(capabilities, inAppUrl.uiCapabilitiesPath)) : false; + }} + /> + ); }; // eslint-disable-next-line import/no-default-export diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index a4c7a84b419ba..f4578c4c4b8e1 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -15,6 +15,7 @@ import { DiscoverStart } from '../../discover/public'; import { HomePublicPluginSetup, FeatureCatalogueCategory } from '../../home/public'; import { VisualizationsStart } from '../../visualizations/public'; import { SavedObjectTaggingOssPluginStart } from '../../saved_objects_tagging_oss/public'; +import type { SpacesOssPluginStart } from '../../spaces_oss/public'; import { SavedObjectsManagementActionService, SavedObjectsManagementActionServiceSetup, @@ -49,6 +50,7 @@ export interface StartDependencies { visualizations?: VisualizationsStart; discover?: DiscoverStart; savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; + spacesOss?: SpacesOssPluginStart; } export class SavedObjectsManagementPlugin diff --git a/src/plugins/saved_objects_management/public/services/types/column.ts b/src/plugins/saved_objects_management/public/services/types/column.ts index 6103f1bd3d5c0..1be279db91205 100644 --- a/src/plugins/saved_objects_management/public/services/types/column.ts +++ b/src/plugins/saved_objects_management/public/services/types/column.ts @@ -12,7 +12,4 @@ import { SavedObjectsManagementRecord } from '.'; export interface SavedObjectsManagementColumn { id: string; euiColumn: Omit, 'sortable'>; - - data?: T; - loadData: () => Promise; } diff --git a/src/plugins/saved_objects_management/tsconfig.json b/src/plugins/saved_objects_management/tsconfig.json index eb047c346714c..99849dea38618 100644 --- a/src/plugins/saved_objects_management/tsconfig.json +++ b/src/plugins/saved_objects_management/tsconfig.json @@ -21,5 +21,6 @@ { "path": "../kibana_react/tsconfig.json" }, { "path": "../management/tsconfig.json" }, { "path": "../visualizations/tsconfig.json" }, + { "path": "../spaces_oss/tsconfig.json" }, ] } diff --git a/src/plugins/spaces_oss/public/api.mock.ts b/src/plugins/spaces_oss/public/api.mock.ts index cc8e075b2bd01..e68a4c91213c6 100644 --- a/src/plugins/spaces_oss/public/api.mock.ts +++ b/src/plugins/spaces_oss/public/api.mock.ts @@ -31,6 +31,7 @@ type SpacesApiUiComponentMock = jest.Mocked; const createApiUiComponentsMock = () => { const mock: SpacesApiUiComponentMock = { + SpacesContext: jest.fn(), ShareToSpaceFlyout: jest.fn(), }; diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index 572c74dc19b81..a3009774db5ab 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -38,6 +38,10 @@ export interface SpacesApiUi { * @public */ export interface SpacesApiUiComponent { + /** + * Provides a context that is required to render all Spaces components. + */ + SpacesContext: FunctionComponent; /** * Displays the tags for given saved object. */ diff --git a/src/plugins/spaces_oss/public/index.ts b/src/plugins/spaces_oss/public/index.ts index 999f20c93463c..26af79f33f923 100644 --- a/src/plugins/spaces_oss/public/index.ts +++ b/src/plugins/spaces_oss/public/index.ts @@ -8,7 +8,12 @@ import { SpacesOssPlugin } from './plugin'; -export { SpacesOssPluginSetup, SpacesOssPluginStart } from './types'; +export { + SpacesOssPluginSetup, + SpacesOssPluginStart, + SpacesAvailableStartContract, + SpacesUnavailableStartContract, +} from './types'; export { SpacesApi, diff --git a/src/plugins/spaces_oss/public/types.ts b/src/plugins/spaces_oss/public/types.ts index 80b1f7aa840bb..831aaa2c45943 100644 --- a/src/plugins/spaces_oss/public/types.ts +++ b/src/plugins/spaces_oss/public/types.ts @@ -8,11 +8,11 @@ import { SpacesApi } from './api'; -interface SpacesAvailableStartContract extends SpacesApi { +export interface SpacesAvailableStartContract extends SpacesApi { isSpacesAvailable: true; } -interface SpacesUnavailableStartContract { +export interface SpacesUnavailableStartContract { isSpacesAvailable: false; } diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 5c345f97fbca1..aa5e50d8a6c0c 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -79,7 +79,6 @@ export class SpacesPlugin implements Plugin; -} - -export const ContextWrapper = (props: PropsWithChildren) => { - const { getStartServices, children } = props; - - const [coreStart, setCoreStart] = useState(); - - useEffect(() => { - getStartServices().then((startServices) => { - const [coreStartValue] = startServices; - setCoreStart(coreStartValue); - }); - }, [getStartServices]); - - if (!coreStart) { - return null; - } - - const { application, docLinks, notifications } = coreStart; - const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ - application, - docLinks, - notifications, - }); - - return {children}; -}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts index 6aaf5d45e4aa2..9e5459ff538da 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts @@ -5,6 +5,5 @@ * 2.0. */ -export { ContextWrapper } from './context_wrapper'; export { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal'; export { getShareToSpaceFlyoutComponent } from './share_to_space_flyout'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index 0a27ddea4dcfa..0155bf790e540 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -22,11 +22,11 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { NoSpacesAvailable } from './no_spaces_available'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; import { DocumentationLinksService } from '../../lib'; import { SpaceAvatar } from '../../space_avatar'; import { SpaceTarget } from '../types'; +import { useSpaces } from '../../spaces_context'; interface Props { spaces: SpaceTarget[]; @@ -73,13 +73,14 @@ export const SelectableSpacesControl = (props: Props) => { enableCreateNewSpaceLink, enableSpaceAgnosticBehavior, } = props; - const { services } = useKibana(); + const { services } = useSpaces(); const { application, docLinks } = services; - const activeSpaceId = spaces.find((space) => space.isActiveSpace)?.id; + const activeSpaceId = + !enableSpaceAgnosticBehavior && spaces.find((space) => space.isActiveSpace)!.id; const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); const options = spaces - .sort((a, b) => (a.isActiveSpace ? -1 : b.isActiveSpace ? 1 : 0)) + .sort((a, b) => (a.id === activeSpaceId ? -1 : b.id === activeSpaceId ? 1 : 0)) .map((space) => { const checked = selectedSpaceIds.includes(space.id); return { @@ -90,7 +91,7 @@ export const SelectableSpacesControl = (props: Props) => { ['data-test-subj']: `sts-space-selector-row-${space.id}`, ...(isGlobalControlChecked && { disabled: true }), ...(space.isPartiallyAuthorized && partiallyAuthorizedSpaceProps(checked)), - ...(space.isActiveSpace && activeSpaceProps), + ...(space.id === activeSpaceId && activeSpaceProps), }; }); 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 index 7ad4063f1407b..0f9783e3ac8c0 100644 --- 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 @@ -5,35 +5,12 @@ * 2.0. */ -import React, { FC } from 'react'; -import { StartServicesAccessor } from 'src/core/public'; +import React from 'react'; import type { ShareToSpaceFlyoutProps } from '../../../../../../src/plugins/spaces_oss/public'; -import { PluginsStart } from '../../plugin'; -import { SpacesManager } from '../../spaces_manager'; -import { ContextWrapper } from './context_wrapper'; import { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal'; -const ShareToSpaceFlyout: FC = ({ - spacesManager, - getStartServices, - ...props -}) => { - return ( - - - - ); -}; - -interface GetShareToSpaceFlyoutOptions { - spacesManager: SpacesManager; - getStartServices: StartServicesAccessor; -} - -export const getShareToSpaceFlyoutComponent = ( - options: GetShareToSpaceFlyoutOptions -): FC => { +export const getShareToSpaceFlyoutComponent = (): React.FC => { return (props: ShareToSpaceFlyoutProps) => { - return ; + return ; }; }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx index b88885c6e0512..0b81c6187cbd8 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx @@ -29,6 +29,7 @@ import { getShareToSpaceFlyoutComponent } from './share_to_space_flyout'; import { ShareModeControl } from './share_mode_control'; import { ReactWrapper } from 'enzyme'; import { ALL_SPACES_ID } from '../../../common/constants'; +import { getSpacesContextWrapper } from '../../spaces_context'; interface SetupOpts { mockSpaces?: Space[]; @@ -46,12 +47,14 @@ const setup = async (opts: SetupOpts = {}) => { const mockSpacesManager = spacesManagerMock.create(); + // note: this call is made in the SpacesContext mockSpacesManager.getActiveSpace.mockResolvedValue({ id: 'my-active-space', name: 'my active space', disabledFeatures: [], }); + // note: this call is made in the SpacesContext mockSpacesManager.getSpaces.mockResolvedValue( opts.mockSpaces || [ { @@ -98,22 +101,25 @@ const setup = async (opts: SetupOpts = {}) => { const mockToastNotifications = startServices.notifications.toasts; getStartServices.mockResolvedValue([startServices, , ,]); - const ShareToSpaceFlyout = getShareToSpaceFlyoutComponent({ + const SpacesContext = getSpacesContextWrapper({ getStartServices, spacesManager: mockSpacesManager, }); + const ShareToSpaceFlyout = getShareToSpaceFlyoutComponent(); // the internal flyout depends upon the Kibana React Context, and it cannot be used without the context wrapper // the context wrapper is only split into a separate component to avoid recreating the context upon every flyout state change // the ShareToSpaceFlyout component renders the internal flyout inside of the context wrapper const wrapper = mountWithIntl( - + + + ); // wait for context wrapper to rerender @@ -124,10 +130,7 @@ const setup = async (opts: SetupOpts = {}) => { if (!opts.returnBeforeSpacesLoad) { // Wait for spaces manager to complete and flyout to rerender - await act(async () => { - await nextTick(); - wrapper.update(); - }); + wrapper.update(); } return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare }; @@ -145,10 +148,7 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + wrapper.update(); expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx index 1dd3b06b30812..4aac254bcc0a5 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -28,18 +28,12 @@ import type { ShareToSpaceFlyoutProps, ShareToSpaceSavedObjectTarget, } from 'src/plugins/spaces_oss/public'; -import type { Space } from 'src/plugins/spaces_oss/common'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { GetSpaceResult } from '../../../common'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; 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 InternalProps extends ShareToSpaceFlyoutProps { - spacesManager: SpacesManager; -} +import { useSpaces } from '../../spaces_context'; const DEFAULT_FLYOUT_ICON = 'share'; const DEFAULT_OBJECT_ICON = 'empty'; @@ -98,12 +92,12 @@ function createDefaultChangeSpacesHandler( }; } -export const ShareToSpaceFlyoutInternal = (props: InternalProps) => { - const { services } = useKibana(); +export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { + const { spacesManager, spacesDataPromise, services } = useSpaces(); const { notifications } = services; const toastNotifications = notifications!.toasts; - const { savedObjectTarget: object, spacesManager } = props; + const { savedObjectTarget: object } = props; const savedObjectTarget = useMemo( () => ({ type: object.type, @@ -142,27 +136,19 @@ export const ShareToSpaceFlyoutInternal = (props: InternalProps) => { spaces: SpaceTarget[]; }>({ isLoading: true, spaces: [] }); useEffect(() => { - const getSpaces = spacesManager.getSpaces({ includeAuthorizedPurposes: true }); - const getActiveSpace = enableSpaceAgnosticBehavior - ? Promise.resolve({} as Space) - : spacesManager.getActiveSpace(); const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObjectTarget.type); - Promise.all([getSpaces, getActiveSpace, getPermissions]) - .then(([allSpaces, activeSpace, permissions]) => { + Promise.all([spacesDataPromise, getPermissions]) + .then(([spacesData, permissions]) => { + const activeSpaceId = !enableSpaceAgnosticBehavior && spacesData.activeSpaceId; setShareOptions({ selectedSpaceIds: savedObjectTarget.namespaces.filter( - (spaceId) => spaceId !== activeSpace.id + (spaceId) => spaceId !== activeSpaceId ), }); setCanShareToAllSpaces(permissions.shareToAllSpaces); - const createSpaceTarget = (space: GetSpaceResult): SpaceTarget => ({ - ...space, - isActiveSpace: space.id === activeSpace.id, - isPartiallyAuthorized: space.authorizedPurposes?.shareSavedObjectsIntoSpace === false, - }); setSpacesState({ isLoading: false, - spaces: allSpaces.map((space) => createSpaceTarget(space)), + spaces: [...spacesData.spacesMap].map(([, spaceTarget]) => spaceTarget), }); }) .catch((e) => { @@ -172,7 +158,13 @@ export const ShareToSpaceFlyoutInternal = (props: InternalProps) => { }), }); }); - }, [savedObjectTarget, spacesManager, toastNotifications, enableSpaceAgnosticBehavior]); + }, [ + savedObjectTarget, + spacesManager, + spacesDataPromise, + toastNotifications, + enableSpaceAgnosticBehavior, + ]); const getSelectionChanges = () => { const activeSpace = spaces.find((space) => space.isActiveSpace); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx index d0949da27c579..ad93e2587a676 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx @@ -5,46 +5,54 @@ * 2.0. */ -import { shallowWithIntl } from '@kbn/test/jest'; -import { SpacesManager } from '../spaces_manager'; +import React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import { act } from '@testing-library/react'; +import { coreMock } from 'src/core/public/mocks'; +import type { Space } from 'src/plugins/spaces_oss/common'; +import { getSpacesContextWrapper } from '../spaces_context'; import { spacesManagerMock } from '../spaces_manager/mocks'; import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; -import { SpaceTarget } from './types'; +import { ReactWrapper } from 'enzyme'; -const ACTIVE_SPACE: SpaceTarget = { +const ACTIVE_SPACE: Space = { id: 'default', name: 'Default', color: '#ffffff', - isActiveSpace: true, + disabledFeatures: [], }; const getSpaceData = (inactiveSpaceCount: number = 0) => { const inactive = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'] - .map((name) => ({ + .map((name) => ({ id: name.toLowerCase(), name, color: `#123456`, // must be a valid color as `render()` is used below - isActiveSpace: false, + disabledFeatures: [], })) .slice(0, inactiveSpaceCount); - const spaceTargets = [ACTIVE_SPACE, ...inactive]; - const namespaces = spaceTargets.map(({ id }) => id); - return { spaceTargets, namespaces }; + const spaces = [ACTIVE_SPACE, ...inactive]; + const namespaces = spaces.map(({ id }) => id); + return { spaces, namespaces }; }; describe('ShareToSpaceSavedObjectsManagementColumn', () => { - let spacesManager: SpacesManager; - beforeEach(() => { - spacesManager = spacesManagerMock.create(); - }); - - const createColumn = (spaceTargets: SpaceTarget[], namespaces: string[]) => { - const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); - column.data = spaceTargets.reduce( - (acc, cur) => acc.set(cur.id, cur), - new Map() - ); + const createColumn = async (spaces: Space[], namespaces: string[]) => { + const column = new ShareToSpaceSavedObjectsManagementColumn(); + const { getStartServices } = coreMock.createSetup(); + const spacesManager = spacesManagerMock.create(); + spacesManager.getActiveSpace.mockResolvedValue(ACTIVE_SPACE); + spacesManager.getSpaces.mockResolvedValue(spaces); + + const SpacesContext = getSpacesContextWrapper({ getStartServices, spacesManager }); const element = column.euiColumn.render(namespaces); - return shallowWithIntl(element); + + const wrapper = mountWithIntl({element}); + + // wait for context wrapper to rerender + await act(async () => {}); + wrapper.update(); + + return wrapper; }; /** @@ -54,80 +62,77 @@ describe('ShareToSpaceSavedObjectsManagementColumn', () => { * If '*' (aka "All spaces") is present, it supersedes all of the above and just displays a single badge without a button. */ describe('#euiColumn.render', () => { + function getBadgeText(wrapper: ReactWrapper) { + return wrapper.find('EuiBadge').map((x) => x.render().text()); + } + function getButton(wrapper: ReactWrapper) { + return wrapper.find('EuiButtonEmpty'); + } + describe('with only the active space', () => { - const { spaceTargets, namespaces } = getSpaceData(); - const wrapper = createColumn(spaceTargets, namespaces); + const { spaces, namespaces } = getSpaceData(); it('does not show badges or button', async () => { - const badges = wrapper.find('EuiBadge'); - expect(badges).toHaveLength(0); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); + const wrapper = await createColumn(spaces, namespaces); + + expect(getBadgeText(wrapper)).toHaveLength(0); + expect(getButton(wrapper)).toHaveLength(0); }); }); describe('with the active space and one inactive space', () => { - const { spaceTargets, namespaces } = getSpaceData(1); - const wrapper = createColumn(spaceTargets, namespaces); + const { spaces, namespaces } = getSpaceData(1); it('shows one badge without button', async () => { - const badges = wrapper.find('EuiBadge'); - expect(badges).toMatchInlineSnapshot(` - - Alpha - - `); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); + const wrapper = await createColumn(spaces, namespaces); + + expect(getBadgeText(wrapper)).toEqual(['Alpha']); + expect(getButton(wrapper)).toHaveLength(0); }); }); describe('with the active space and five inactive spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(5); - const wrapper = createColumn(spaceTargets, namespaces); + const { spaces, namespaces } = getSpaceData(5); it('shows badges without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); + const wrapper = await createColumn(spaces, namespaces); + + expect(getBadgeText(wrapper)).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); + expect(getButton(wrapper)).toHaveLength(0); }); }); describe('with the active space, five inactive spaces, and one unauthorized space', () => { - const { spaceTargets, namespaces } = getSpaceData(5); - const wrapper = createColumn(spaceTargets, [...namespaces, '?']); + const { spaces, namespaces } = getSpaceData(5); it('shows badges without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+1']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); + const wrapper = await createColumn(spaces, [...namespaces, '?']); + + expect(getBadgeText(wrapper)).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+1']); + expect(getButton(wrapper)).toHaveLength(0); }); }); describe('with the active space, five inactive spaces, and two unauthorized spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(5); - const wrapper = createColumn(spaceTargets, [...namespaces, '?', '?']); + const { spaces, namespaces } = getSpaceData(5); it('shows badges without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+2']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); + const wrapper = await createColumn(spaces, [...namespaces, '?', '?']); + + expect(getBadgeText(wrapper)).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+2']); + expect(getButton(wrapper)).toHaveLength(0); }); }); describe('with the active space and six inactive spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, namespaces); + const { spaces, namespaces } = getSpaceData(6); it('shows badges with button', async () => { - let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); + const wrapper = await createColumn(spaces, namespaces); + + expect(getBadgeText(wrapper)).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); + + const button = getButton(wrapper); expect(button.find('FormattedMessage').props()).toEqual({ defaultMessage: '+{count} more', id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', @@ -135,19 +140,19 @@ describe('ShareToSpaceSavedObjectsManagementColumn', () => { }); button.simulate('click'); - badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + const badgeText = getBadgeText(wrapper); expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot']); }); }); describe('with the active space, six inactive spaces, and one unauthorized space', () => { - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, [...namespaces, '?']); + const { spaces, namespaces } = getSpaceData(6); it('shows badges with button', async () => { - let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); + const wrapper = await createColumn(spaces, [...namespaces, '?']); + + expect(getBadgeText(wrapper)).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); + const button = getButton(wrapper); expect(button.find('FormattedMessage').props()).toEqual({ defaultMessage: '+{count} more', id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', @@ -155,19 +160,19 @@ describe('ShareToSpaceSavedObjectsManagementColumn', () => { }); button.simulate('click'); - badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + const badgeText = getBadgeText(wrapper); expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+1']); }); }); describe('with the active space, six inactive spaces, and two unauthorized spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, [...namespaces, '?', '?']); + const { spaces, namespaces } = getSpaceData(6); it('shows badges with button', async () => { - let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); + const wrapper = await createColumn(spaces, [...namespaces, '?', '?']); + + expect(getBadgeText(wrapper)).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); + const button = getButton(wrapper); expect(button.find('FormattedMessage').props()).toEqual({ defaultMessage: '+{count} more', id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', @@ -175,32 +180,29 @@ describe('ShareToSpaceSavedObjectsManagementColumn', () => { }); button.simulate('click'); - badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + const badgeText = getBadgeText(wrapper); expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+2']); }); }); describe('with only "all spaces"', () => { - const wrapper = createColumn([], ['*']); - it('shows one badge without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['* All spaces']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); + const wrapper = await createColumn([], ['*']); + + expect(getBadgeText(wrapper)).toEqual(['* All spaces']); + expect(getButton(wrapper)).toHaveLength(0); }); }); describe('with "all spaces", the active space, six inactive spaces, and one unauthorized space', () => { // same as assertions 'with only "all spaces"' test case; if "all spaces" is present, it supersedes everything else - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, ['*', ...namespaces, '?']); + const { spaces, namespaces } = getSpaceData(6); it('shows one badge without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['* All spaces']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); + const wrapper = await createColumn(spaces, ['*', ...namespaces, '?']); + + expect(getBadgeText(wrapper)).toEqual(['* All spaces']); + expect(getButton(wrapper)).toHaveLength(0); }); }); }); 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 index 6195095156258..d361a8af663a2 100644 --- 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, ReactNode } from 'react'; +import React, { useState, ReactNode, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiBadge } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; @@ -14,22 +14,29 @@ import { EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { SavedObjectsManagementColumn } from '../../../../../src/plugins/saved_objects_management/public'; import { SpaceTarget } from './types'; -import { SpacesManager } from '../spaces_manager'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; -import { getSpaceColor } from '..'; +import { useSpaces } from '../spaces_context'; +import { SpacesData } from '../spaces_context/types'; const SPACES_DISPLAY_COUNT = 5; -type SpaceMap = Map; interface ColumnDataProps { namespaces?: string[]; - data?: SpaceMap; } -const ColumnDisplay = ({ namespaces, data }: ColumnDataProps) => { +const ColumnDisplay = ({ namespaces }: ColumnDataProps) => { + const { spacesDataPromise } = useSpaces(); + const [isExpanded, setIsExpanded] = useState(false); + const [spacesData, setSpacesData] = useState(); + + useEffect(() => { + spacesDataPromise.then((x) => { + setSpacesData(x); + }); + }, [spacesDataPromise]); - if (!data) { + if (!spacesData) { return null; } @@ -54,7 +61,7 @@ const ColumnDisplay = ({ namespaces, data }: ColumnDataProps) => { const authorized = namespaces?.filter((namespace) => namespace !== UNKNOWN_SPACE) ?? []; const authorizedSpaceTargets: SpaceTarget[] = []; authorized.forEach((namespace) => { - const spaceTarget = data.get(namespace); + const spaceTarget = spacesData.spacesMap.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, isActiveSpace: false }); @@ -118,9 +125,8 @@ const ColumnDisplay = ({ namespaces, data }: ColumnDataProps) => { }; export class ShareToSpaceSavedObjectsManagementColumn - implements SavedObjectsManagementColumn { + implements SavedObjectsManagementColumn { public id: string = 'share_saved_objects_to_space'; - public data: Map | undefined; public euiColumn = { field: 'namespaces', @@ -130,26 +136,8 @@ export class ShareToSpaceSavedObjectsManagementColumn description: i18n.translate('xpack.spaces.management.shareToSpace.columnDescription', { defaultMessage: 'The other spaces that this object is currently shared to', }), - render: (namespaces: string[] | undefined) => ( - - ), + render: (namespaces: string[] | undefined) => , }; - 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; - } - ); - }; + constructor() {} } 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 index a6daf1b36fa95..6e74fa31ec4b8 100644 --- 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 @@ -7,7 +7,6 @@ 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 { savedObjectsManagementPluginMock } from '../../../../../src/plugins/saved_objects_management/public/mocks'; import { uiApiMock } from '../ui_api/mocks'; @@ -16,7 +15,6 @@ describe('ShareSavedObjectsToSpaceService', () => { describe('#setup', () => { it('registers the ShareToSpaceSavedObjectsManagement Action and Column', () => { const deps = { - spacesManager: spacesManagerMock.create(), savedObjectsManagementSetup: savedObjectsManagementPluginMock.createSetupContract(), spacesApiUi: uiApiMock.create(), }; 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 index 8fc723ce34338..cb12831bfce49 100644 --- 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 @@ -9,10 +9,8 @@ import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_man import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/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; spacesApiUi: SpacesApiUi; } @@ -22,7 +20,7 @@ export class ShareSavedObjectsToSpaceService { const action = new ShareToSpaceSavedObjectsManagementAction(spacesApiUi); savedObjectsManagementSetup.actions.register(action); // Note: this column is hidden for now because no saved objects are shareable. It should be uncommented when at least one saved object type is multi-namespace. - // const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); + // const column = new ShareToSpaceSavedObjectsManagementColumn(); // 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 index f5e0d09a99e4b..dfa67304d5ec6 100644 --- 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 @@ -19,6 +19,6 @@ export interface ShareSavedObjectsToSpaceResponse { } export interface SpaceTarget extends Omit { - isActiveSpace: boolean; + isActiveSpace?: boolean; isPartiallyAuthorized?: boolean; } diff --git a/x-pack/plugins/spaces/public/spaces_context/context.tsx b/x-pack/plugins/spaces/public/spaces_context/context.tsx new file mode 100644 index 0000000000000..084778d1882d8 --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/context.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import { SpacesManager } from '../spaces_manager'; +import { SpacesReactContext, SpacesReactContextValue, KibanaServices, SpacesData } from './types'; + +const { useContext, createElement, createContext } = React; + +const context = createContext>>({}); + +export const useSpaces = (): SpacesReactContextValue< + KibanaServices & Extra +> => + useContext( + (context as unknown) as React.Context> + ); + +export const createSpacesReactContext = ( + services: Services, + spacesManager: SpacesManager, + spacesDataPromise: Promise +): SpacesReactContext => { + const value: SpacesReactContextValue = { + spacesManager, + spacesDataPromise, + services, + }; + const Provider: React.FC = ({ children }) => + createElement(context.Provider as React.ComponentType, { value, children }); + + return { + value, + Provider, + Consumer: (context.Consumer as unknown) as React.Consumer>, + }; +}; diff --git a/x-pack/plugins/spaces/public/spaces_context/index.ts b/x-pack/plugins/spaces/public/spaces_context/index.ts new file mode 100644 index 0000000000000..fdf28ad5957cf --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { useSpaces } from './context'; +export { getSpacesContextWrapper } from './wrapper'; diff --git a/x-pack/plugins/spaces/public/spaces_context/types.ts b/x-pack/plugins/spaces/public/spaces_context/types.ts new file mode 100644 index 0000000000000..a87b89edd5907 --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/types.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import { CoreStart } from 'src/core/public'; +import { SpaceTarget } from '../share_saved_objects_to_space/types'; +import { SpacesManager } from '../spaces_manager'; + +export type KibanaServices = Partial; + +export interface SpacesData { + readonly spacesMap: Map; + readonly activeSpaceId: string; +} + +export interface SpacesReactContextValue { + readonly spacesManager: SpacesManager; + readonly spacesDataPromise: Promise; + readonly services: Services; +} + +export interface SpacesReactContext { + value: SpacesReactContextValue; + Provider: React.FC; + Consumer: React.Consumer>; +} diff --git a/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx new file mode 100644 index 0000000000000..4630ef0433a53 --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useEffect, PropsWithChildren, useMemo } from 'react'; +import { StartServicesAccessor, CoreStart } from 'src/core/public'; +import { createSpacesReactContext } from './context'; +import { PluginsStart } from '../plugin'; +import { SpacesManager } from '../spaces_manager'; +import { SpaceTarget } from '../share_saved_objects_to_space/types'; +import { getSpaceColor } from '../space_avatar'; +import { SpacesData } from './types'; + +interface Props { + spacesManager: SpacesManager; + getStartServices: StartServicesAccessor; +} + +async function getSpacesData(spacesManager: SpacesManager): Promise { + const spaces = await spacesManager.getSpaces({ includeAuthorizedPurposes: true }); + const activeSpace = await spacesManager.getActiveSpace(); + const spacesMap = spaces + .map(({ authorizedPurposes, ...space }) => { + const isActiveSpace = space.id === activeSpace.id; + const isPartiallyAuthorized = authorizedPurposes?.shareSavedObjectsIntoSpace === false; + return { + ...space, + color: getSpaceColor(space), + ...(isActiveSpace && { isActiveSpace }), + ...(isPartiallyAuthorized && { isPartiallyAuthorized }), + }; + }) + .reduce((acc, cur) => acc.set(cur.id, cur), new Map()); + + return { + spacesMap, + activeSpaceId: activeSpace.id, + }; +} + +const SpacesContextWrapper = (props: PropsWithChildren) => { + const { spacesManager, getStartServices, children } = props; + + const [coreStart, setCoreStart] = useState(); + const spacesDataPromise = useMemo(() => getSpacesData(spacesManager), [spacesManager]); + + useEffect(() => { + getStartServices().then(([coreStartValue]) => { + setCoreStart(coreStartValue); + }); + }, [getStartServices]); + + if (!coreStart) { + return null; + } + + const { application, docLinks, notifications } = coreStart; + const services = { application, docLinks, notifications }; + const context = createSpacesReactContext(services, spacesManager, spacesDataPromise); + + return {children}; +}; + +export const getSpacesContextWrapper = (props: Props): React.FC => { + return ({ children }) => { + return ; + }; +}; diff --git a/x-pack/plugins/spaces/public/ui_api/components.ts b/x-pack/plugins/spaces/public/ui_api/components.ts index 90bc2d0e49684..9dafe413fcce4 100644 --- a/x-pack/plugins/spaces/public/ui_api/components.ts +++ b/x-pack/plugins/spaces/public/ui_api/components.ts @@ -9,6 +9,7 @@ import { StartServicesAccessor } from 'src/core/public'; import type { SpacesApiUiComponent } from '../../../../../src/plugins/spaces_oss/public'; import { PluginsStart } from '../plugin'; import { getShareToSpaceFlyoutComponent } from '../share_saved_objects_to_space'; +import { getSpacesContextWrapper } from '../spaces_context'; import { SpacesManager } from '../spaces_manager'; export interface GetComponentsOptions { @@ -21,6 +22,7 @@ export const getComponents = ({ getStartServices, }: GetComponentsOptions): SpacesApiUiComponent => { return { - ShareToSpaceFlyout: getShareToSpaceFlyoutComponent({ spacesManager, getStartServices }), + SpacesContext: getSpacesContextWrapper({ spacesManager, getStartServices }), + ShareToSpaceFlyout: getShareToSpaceFlyoutComponent(), }; }; diff --git a/x-pack/plugins/spaces/public/ui_api/mocks.ts b/x-pack/plugins/spaces/public/ui_api/mocks.ts index 6a4707fde9c14..4cbe33d2f09d8 100644 --- a/x-pack/plugins/spaces/public/ui_api/mocks.ts +++ b/x-pack/plugins/spaces/public/ui_api/mocks.ts @@ -12,6 +12,7 @@ import type { function createComponentsMock(): jest.Mocked { return { + SpacesContext: jest.fn(), ShareToSpaceFlyout: jest.fn(), }; } From 6b5984e8652c2eeee8f724e7680b333eccc405ab Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Mon, 8 Feb 2021 10:46:09 -0500 Subject: [PATCH 10/29] Create reusable space list, change display to use SpaceAvatar Previously it rendered spaces as badges with their full names. Now it renders them as SpaceAvatar components. It also allows consumers to change the limit on the number of spaces that are displayed, and to enable space-agnostic behavior (e.g., render the active space). --- src/plugins/spaces_oss/public/api.mock.ts | 1 + src/plugins/spaces_oss/public/api.ts | 27 ++ src/plugins/spaces_oss/public/index.ts | 1 + ...are_saved_objects_to_space_column.test.tsx | 209 -------------- .../share_saved_objects_to_space_column.tsx | 127 +------- .../share_saved_objects_to_space_service.ts | 2 +- .../plugins/spaces/public/space_list/index.ts | 8 + .../spaces/public/space_list/space_list.tsx | 16 ++ .../space_list/space_list_internal.test.tsx | 270 ++++++++++++++++++ .../public/space_list/space_list_internal.tsx | 127 ++++++++ .../spaces/public/spaces_context/wrapper.tsx | 2 - .../spaces/public/ui_api/components.ts | 2 + x-pack/plugins/spaces/public/ui_api/mocks.ts | 1 + .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - 15 files changed, 463 insertions(+), 338 deletions(-) delete mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx create mode 100644 x-pack/plugins/spaces/public/space_list/index.ts create mode 100644 x-pack/plugins/spaces/public/space_list/space_list.tsx create mode 100644 x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx create mode 100644 x-pack/plugins/spaces/public/space_list/space_list_internal.tsx diff --git a/src/plugins/spaces_oss/public/api.mock.ts b/src/plugins/spaces_oss/public/api.mock.ts index e68a4c91213c6..ab4e97b761759 100644 --- a/src/plugins/spaces_oss/public/api.mock.ts +++ b/src/plugins/spaces_oss/public/api.mock.ts @@ -33,6 +33,7 @@ const createApiUiComponentsMock = () => { const mock: SpacesApiUiComponentMock = { SpacesContext: jest.fn(), ShareToSpaceFlyout: jest.fn(), + SpaceList: jest.fn(), }; return mock; diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index a3009774db5ab..5a5ae0a55f7b1 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -46,6 +46,10 @@ export interface SpacesApiUiComponent { * Displays the tags for given saved object. */ ShareToSpaceFlyout: FunctionComponent; + /** + * Displays a corresponding list of spaces for a given list of saved object namespaces. + */ + SpaceList: FunctionComponent; } /** @@ -140,3 +144,26 @@ export interface ShareToSpaceSavedObjectTarget { */ noun?: string; } + +/** + * @public + */ +export interface SpaceListProps { + /** + * The namespaces of a saved object to render into a corresponding list of spaces. + */ + namespaces: string[]; + /** + * Optional limit to the number of spaces that can be displayed in the list. If the number of spaces exceeds this limit, they will be + * hidden behind a "show more" button. Set to 0 to disable. + * + * Default value is 5. + */ + displayLimit?: number; + /** + * When enabled, the space list will omit the active space. Otherwise, the active space is displayed. + * + * Default value is false. + */ + enableSpaceAgnosticBehavior?: boolean; +} diff --git a/src/plugins/spaces_oss/public/index.ts b/src/plugins/spaces_oss/public/index.ts index 26af79f33f923..fc0849f25d5a4 100644 --- a/src/plugins/spaces_oss/public/index.ts +++ b/src/plugins/spaces_oss/public/index.ts @@ -21,6 +21,7 @@ export { SpacesApiUiComponent, ShareToSpaceFlyoutProps, ShareToSpaceSavedObjectTarget, + SpaceListProps, } from './api'; export const plugin = () => new SpacesOssPlugin(); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx deleted file mode 100644 index ad93e2587a676..0000000000000 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { mountWithIntl } from '@kbn/test/jest'; -import { act } from '@testing-library/react'; -import { coreMock } from 'src/core/public/mocks'; -import type { Space } from 'src/plugins/spaces_oss/common'; -import { getSpacesContextWrapper } from '../spaces_context'; -import { spacesManagerMock } from '../spaces_manager/mocks'; -import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; -import { ReactWrapper } from 'enzyme'; - -const ACTIVE_SPACE: Space = { - id: 'default', - name: 'Default', - color: '#ffffff', - disabledFeatures: [], -}; -const getSpaceData = (inactiveSpaceCount: number = 0) => { - const inactive = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'] - .map((name) => ({ - id: name.toLowerCase(), - name, - color: `#123456`, // must be a valid color as `render()` is used below - disabledFeatures: [], - })) - .slice(0, inactiveSpaceCount); - const spaces = [ACTIVE_SPACE, ...inactive]; - const namespaces = spaces.map(({ id }) => id); - return { spaces, namespaces }; -}; - -describe('ShareToSpaceSavedObjectsManagementColumn', () => { - const createColumn = async (spaces: Space[], namespaces: string[]) => { - const column = new ShareToSpaceSavedObjectsManagementColumn(); - const { getStartServices } = coreMock.createSetup(); - const spacesManager = spacesManagerMock.create(); - spacesManager.getActiveSpace.mockResolvedValue(ACTIVE_SPACE); - spacesManager.getSpaces.mockResolvedValue(spaces); - - const SpacesContext = getSpacesContextWrapper({ getStartServices, spacesManager }); - const element = column.euiColumn.render(namespaces); - - const wrapper = mountWithIntl({element}); - - // wait for context wrapper to rerender - await act(async () => {}); - wrapper.update(); - - return wrapper; - }; - - /** - * This node displays up to five named spaces (and an indicator for any number of unauthorized spaces) by default. The active space is - * omitted from this list. If more than five named spaces would be displayed, the extras (along with the unauthorized spaces indicator, if - * present) are hidden behind a button. - * If '*' (aka "All spaces") is present, it supersedes all of the above and just displays a single badge without a button. - */ - describe('#euiColumn.render', () => { - function getBadgeText(wrapper: ReactWrapper) { - return wrapper.find('EuiBadge').map((x) => x.render().text()); - } - function getButton(wrapper: ReactWrapper) { - return wrapper.find('EuiButtonEmpty'); - } - - describe('with only the active space', () => { - const { spaces, namespaces } = getSpaceData(); - - it('does not show badges or button', async () => { - const wrapper = await createColumn(spaces, namespaces); - - expect(getBadgeText(wrapper)).toHaveLength(0); - expect(getButton(wrapper)).toHaveLength(0); - }); - }); - - describe('with the active space and one inactive space', () => { - const { spaces, namespaces } = getSpaceData(1); - - it('shows one badge without button', async () => { - const wrapper = await createColumn(spaces, namespaces); - - expect(getBadgeText(wrapper)).toEqual(['Alpha']); - expect(getButton(wrapper)).toHaveLength(0); - }); - }); - - describe('with the active space and five inactive spaces', () => { - const { spaces, namespaces } = getSpaceData(5); - - it('shows badges without button', async () => { - const wrapper = await createColumn(spaces, namespaces); - - expect(getBadgeText(wrapper)).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - expect(getButton(wrapper)).toHaveLength(0); - }); - }); - - describe('with the active space, five inactive spaces, and one unauthorized space', () => { - const { spaces, namespaces } = getSpaceData(5); - - it('shows badges without button', async () => { - const wrapper = await createColumn(spaces, [...namespaces, '?']); - - expect(getBadgeText(wrapper)).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+1']); - expect(getButton(wrapper)).toHaveLength(0); - }); - }); - - describe('with the active space, five inactive spaces, and two unauthorized spaces', () => { - const { spaces, namespaces } = getSpaceData(5); - - it('shows badges without button', async () => { - const wrapper = await createColumn(spaces, [...namespaces, '?', '?']); - - expect(getBadgeText(wrapper)).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+2']); - expect(getButton(wrapper)).toHaveLength(0); - }); - }); - - describe('with the active space and six inactive spaces', () => { - const { spaces, namespaces } = getSpaceData(6); - - it('shows badges with button', async () => { - const wrapper = await createColumn(spaces, namespaces); - - expect(getBadgeText(wrapper)).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - - const button = getButton(wrapper); - expect(button.find('FormattedMessage').props()).toEqual({ - defaultMessage: '+{count} more', - id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', - values: { count: 1 }, - }); - - button.simulate('click'); - const badgeText = getBadgeText(wrapper); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot']); - }); - }); - - describe('with the active space, six inactive spaces, and one unauthorized space', () => { - const { spaces, namespaces } = getSpaceData(6); - - it('shows badges with button', async () => { - const wrapper = await createColumn(spaces, [...namespaces, '?']); - - expect(getBadgeText(wrapper)).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = getButton(wrapper); - expect(button.find('FormattedMessage').props()).toEqual({ - defaultMessage: '+{count} more', - id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', - values: { count: 2 }, - }); - - button.simulate('click'); - const badgeText = getBadgeText(wrapper); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+1']); - }); - }); - - describe('with the active space, six inactive spaces, and two unauthorized spaces', () => { - const { spaces, namespaces } = getSpaceData(6); - - it('shows badges with button', async () => { - const wrapper = await createColumn(spaces, [...namespaces, '?', '?']); - - expect(getBadgeText(wrapper)).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = getButton(wrapper); - expect(button.find('FormattedMessage').props()).toEqual({ - defaultMessage: '+{count} more', - id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', - values: { count: 3 }, - }); - - button.simulate('click'); - const badgeText = getBadgeText(wrapper); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+2']); - }); - }); - - describe('with only "all spaces"', () => { - it('shows one badge without button', async () => { - const wrapper = await createColumn([], ['*']); - - expect(getBadgeText(wrapper)).toEqual(['* All spaces']); - expect(getButton(wrapper)).toHaveLength(0); - }); - }); - - describe('with "all spaces", the active space, six inactive spaces, and one unauthorized space', () => { - // same as assertions 'with only "all spaces"' test case; if "all spaces" is present, it supersedes everything else - const { spaces, namespaces } = getSpaceData(6); - - it('shows one badge without button', async () => { - const wrapper = await createColumn(spaces, ['*', ...namespaces, '?']); - - expect(getBadgeText(wrapper)).toEqual(['* All spaces']); - expect(getButton(wrapper)).toHaveLength(0); - }); - }); - }); -}); 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 index d361a8af663a2..e5bac10eb2571 100644 --- 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 @@ -5,124 +5,10 @@ * 2.0. */ -import React, { useState, ReactNode, useEffect } from 'react'; +import React 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 } from '../../../../../src/plugins/saved_objects_management/public'; -import { SpaceTarget } from './types'; -import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; -import { useSpaces } from '../spaces_context'; -import { SpacesData } from '../spaces_context/types'; - -const SPACES_DISPLAY_COUNT = 5; - -interface ColumnDataProps { - namespaces?: string[]; -} - -const ColumnDisplay = ({ namespaces }: ColumnDataProps) => { - const { spacesDataPromise } = useSpaces(); - - const [isExpanded, setIsExpanded] = useState(false); - const [spacesData, setSpacesData] = useState(); - - useEffect(() => { - spacesDataPromise.then((x) => { - setSpacesData(x); - }); - }, [spacesDataPromise]); - - if (!spacesData) { - return null; - } - - const isSharedToAllSpaces = namespaces?.includes(ALL_SPACES_ID); - const unauthorizedCount = (namespaces?.filter((namespace) => namespace === UNKNOWN_SPACE) ?? []) - .length; - let displayedSpaces: SpaceTarget[]; - let button: ReactNode = null; - - if (isSharedToAllSpaces) { - displayedSpaces = [ - { - id: ALL_SPACES_ID, - name: i18n.translate('xpack.spaces.management.shareToSpace.allSpacesLabel', { - defaultMessage: `* All spaces`, - }), - isActiveSpace: false, - color: '#D3DAE6', - }, - ]; - } else { - const authorized = namespaces?.filter((namespace) => namespace !== UNKNOWN_SPACE) ?? []; - const authorizedSpaceTargets: SpaceTarget[] = []; - authorized.forEach((namespace) => { - const spaceTarget = spacesData.spacesMap.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, isActiveSpace: false }); - } else if (!spaceTarget.isActiveSpace) { - authorizedSpaceTargets.push(spaceTarget); - } - }); - displayedSpaces = isExpanded - ? authorizedSpaceTargets - : authorizedSpaceTargets.slice(0, SPACES_DISPLAY_COUNT); - - if (authorizedSpaceTargets.length > SPACES_DISPLAY_COUNT) { - button = isExpanded ? ( - setIsExpanded(false)}> - - - ) : ( - setIsExpanded(true)}> - - - ); - } - } - - const unauthorizedCountBadge = - !isSharedToAllSpaces && (isExpanded || button === null) && unauthorizedCount > 0 ? ( - - - } - > - +{unauthorizedCount} - - - ) : null; - - return ( - - {displayedSpaces.map(({ id, name, color }) => ( - - {name} - - ))} - {unauthorizedCountBadge} - {button} - - ); -}; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; export class ShareToSpaceSavedObjectsManagementColumn implements SavedObjectsManagementColumn { @@ -136,8 +22,13 @@ export class ShareToSpaceSavedObjectsManagementColumn description: i18n.translate('xpack.spaces.management.shareToSpace.columnDescription', { defaultMessage: 'The other spaces that this object is currently shared to', }), - render: (namespaces: string[] | undefined) => , + render: (namespaces: string[] | undefined) => { + if (!namespaces) { + return null; + } + return ; + }, }; - constructor() {} + constructor(private readonly spacesApiUi: SpacesApiUi) {} } 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 index cb12831bfce49..86b9c07bebe92 100644 --- 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 @@ -20,7 +20,7 @@ export class ShareSavedObjectsToSpaceService { const action = new ShareToSpaceSavedObjectsManagementAction(spacesApiUi); savedObjectsManagementSetup.actions.register(action); // Note: this column is hidden for now because no saved objects are shareable. It should be uncommented when at least one saved object type is multi-namespace. - // const column = new ShareToSpaceSavedObjectsManagementColumn(); + // const column = new ShareToSpaceSavedObjectsManagementColumn(spacesApiUi); // savedObjectsManagementSetup.columns.register(column); } } diff --git a/x-pack/plugins/spaces/public/space_list/index.ts b/x-pack/plugins/spaces/public/space_list/index.ts new file mode 100644 index 0000000000000..1570ad123b9ab --- /dev/null +++ b/x-pack/plugins/spaces/public/space_list/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getSpaceListComponent } from './space_list'; diff --git a/x-pack/plugins/spaces/public/space_list/space_list.tsx b/x-pack/plugins/spaces/public/space_list/space_list.tsx new file mode 100644 index 0000000000000..d8bd47b66b5c6 --- /dev/null +++ b/x-pack/plugins/spaces/public/space_list/space_list.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public'; +import { SpaceListInternal } from './space_list_internal'; + +export const getSpaceListComponent = (): React.FC => { + return (props: SpaceListProps) => { + return ; + }; +}; diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx new file mode 100644 index 0000000000000..8d6d3da87aa4e --- /dev/null +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx @@ -0,0 +1,270 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import { act } from '@testing-library/react'; +import { coreMock } from 'src/core/public/mocks'; +import type { Space } from 'src/plugins/spaces_oss/common'; +import type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public'; +import { getSpacesContextWrapper } from '../spaces_context'; +import { spacesManagerMock } from '../spaces_manager/mocks'; +import { ReactWrapper } from 'enzyme'; +import { SpaceListInternal } from './space_list_internal'; + +const ACTIVE_SPACE: Space = { + id: 'default', + name: 'Default', + initials: 'D!', // so it can be differentiated from 'Delta' + disabledFeatures: [], +}; +const getSpaceData = (inactiveSpaceCount: number = 0) => { + const inactive = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'] + .map((name) => ({ + id: name.toLowerCase(), + name, + disabledFeatures: [], + })) + .slice(0, inactiveSpaceCount); + const spaces = [ACTIVE_SPACE, ...inactive]; + const namespaces = spaces.map(({ id }) => id); + return { spaces, namespaces }; +}; + +/** + * This node displays up to five named spaces (and an indicator for any number of unauthorized spaces) by default. The active space is + * omitted from this list unless enableSpaceAgnosticBehavior is enabled. If more than five named spaces would be displayed, the extras + * (along with the unauthorized spaces indicator, if present) are hidden behind a button. + * If '*' (aka "All spaces") is present, it supersedes all of the above and just displays a single badge without a button. + */ +describe('SpaceListInternal', () => { + const createSpaceList = async (spaces: Space[], props: SpaceListProps) => { + const { getStartServices } = coreMock.createSetup(); + const spacesManager = spacesManagerMock.create(); + spacesManager.getActiveSpace.mockResolvedValue(ACTIVE_SPACE); + spacesManager.getSpaces.mockResolvedValue(spaces); + + const SpacesContext = getSpacesContextWrapper({ getStartServices, spacesManager }); + const wrapper = mountWithIntl( + + + + ); + + // wait for context wrapper to rerender + await act(async () => {}); + wrapper.update(); + + return wrapper; + }; + + function getListText(wrapper: ReactWrapper) { + return wrapper.find('EuiFlexItem').map((x) => x.text()); + } + function getButton(wrapper: ReactWrapper) { + return wrapper.find('EuiButtonEmpty'); + } + + describe('using default properties', () => { + describe('with only the active space', () => { + const { spaces, namespaces } = getSpaceData(); + + it('does not show badges or button', async () => { + const wrapper = await createSpaceList(spaces, { namespaces }); + + expect(getListText(wrapper)).toHaveLength(0); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space and one inactive space', () => { + const { spaces, namespaces } = getSpaceData(1); + + it('shows one badge without button', async () => { + const wrapper = await createSpaceList(spaces, { namespaces }); + + expect(getListText(wrapper)).toEqual(['A']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space and five inactive spaces', () => { + const { spaces, namespaces } = getSpaceData(5); + + it('shows badges without button', async () => { + const wrapper = await createSpaceList(spaces, { namespaces }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space, five inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(5); + + it('shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?'] }; + const wrapper = await createSpaceList(spaces, props); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', '+1']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space, five inactive spaces, and two unauthorized spaces', () => { + const { spaces, namespaces } = getSpaceData(5); + + it('shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?', '?'] }; + const wrapper = await createSpaceList(spaces, props); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', '+2']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space and six inactive spaces', () => { + const { spaces, namespaces } = getSpaceData(6); + + it('shows badges with button', async () => { + const wrapper = await createSpaceList(spaces, { namespaces }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + + const button = getButton(wrapper); + expect(button.text()).toEqual('+1 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F']); + expect(button.text()).toEqual('show less'); + }); + }); + + describe('with the active space, six inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(6); + + it('shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?'] }; + const wrapper = await createSpaceList(spaces, props); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+2 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', '+1']); + expect(button.text()).toEqual('show less'); + }); + }); + + describe('with the active space, six inactive spaces, and two unauthorized spaces', () => { + const { spaces, namespaces } = getSpaceData(6); + + it('shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?', '?'] }; + const wrapper = await createSpaceList(spaces, props); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+3 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', '+2']); + expect(button.text()).toEqual('show less'); + }); + }); + + describe('with only "all spaces"', () => { + it('shows one badge without button', async () => { + const wrapper = await createSpaceList([], { namespaces: ['*'] }); + + expect(getListText(wrapper)).toEqual(['*']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with "all spaces", the active space, six inactive spaces, and one unauthorized space', () => { + // same as assertions 'with only "all spaces"' test case; if "all spaces" is present, it supersedes everything else + const { spaces, namespaces } = getSpaceData(6); + + it('shows one badge without button', async () => { + const props = { namespaces: ['*', ...namespaces, '?'] }; + const wrapper = await createSpaceList(spaces, props); + + expect(getListText(wrapper)).toEqual(['*']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + }); + + describe('using custom properties', () => { + describe('with the active space, eight inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(8); + + it('with displayLimit=0, shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 0 }; + const wrapper = await createSpaceList(spaces, props); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(getButton(wrapper)).toHaveLength(0); + }); + + it('with displayLimit=1, shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 1 }; + const wrapper = await createSpaceList(spaces, props); + + expect(getListText(wrapper)).toEqual(['A']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+8 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(button.text()).toEqual('show less'); + }); + + it('with displayLimit=7, shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 7 }; + const wrapper = await createSpaceList(spaces, props); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+2 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(button.text()).toEqual('show less'); + }); + + it('with displayLimit=8, shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 8 }; + const wrapper = await createSpaceList(spaces, props); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(getButton(wrapper)).toHaveLength(0); + }); + + it('with enableSpaceAgnosticBehavior=true, shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?'], enableSpaceAgnosticBehavior: true }; + const wrapper = await createSpaceList(spaces, props); + + expect(getListText(wrapper)).toEqual(['D!', 'A', 'B', 'C', 'D']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+5 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['D!', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(button.text()).toEqual('show less'); + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx new file mode 100644 index 0000000000000..91180f7de17f3 --- /dev/null +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, ReactNode, useEffect } 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 type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public'; +import { SpaceTarget } from '../share_saved_objects_to_space/types'; +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; +import { useSpaces } from '../spaces_context'; +import { SpacesData } from '../spaces_context/types'; +import { SpaceAvatar } from '../space_avatar'; + +const DEFAULT_DISPLAY_LIMIT = 5; + +export const SpaceListInternal = ({ + namespaces, + displayLimit = DEFAULT_DISPLAY_LIMIT, + enableSpaceAgnosticBehavior, +}: SpaceListProps) => { + const { spacesDataPromise } = useSpaces(); + + const [isExpanded, setIsExpanded] = useState(false); + const [spacesData, setSpacesData] = useState(); + + useEffect(() => { + spacesDataPromise.then((x) => { + setSpacesData(x); + }); + }, [spacesDataPromise]); + + if (!spacesData) { + return null; + } + + const isSharedToAllSpaces = namespaces?.includes(ALL_SPACES_ID); + const unauthorizedCount = (namespaces?.filter((namespace) => namespace === UNKNOWN_SPACE) ?? []) + .length; + let displayedSpaces: SpaceTarget[]; + let button: ReactNode = null; + + if (isSharedToAllSpaces) { + displayedSpaces = [ + { + id: ALL_SPACES_ID, + name: i18n.translate('xpack.spaces.spaceList.allSpacesLabel', { + defaultMessage: `* All spaces`, + }), + initials: '*', + color: '#D3DAE6', + }, + ]; + } else { + const authorized = namespaces?.filter((namespace) => namespace !== UNKNOWN_SPACE) ?? []; + const authorizedSpaceTargets: SpaceTarget[] = []; + authorized.forEach((namespace) => { + const spaceTarget = spacesData.spacesMap.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 }); + } else if (enableSpaceAgnosticBehavior || !spaceTarget.isActiveSpace) { + authorizedSpaceTargets.push(spaceTarget); + } + }); + displayedSpaces = + isExpanded || !displayLimit + ? authorizedSpaceTargets + : authorizedSpaceTargets.slice(0, displayLimit); + + if (displayLimit && authorizedSpaceTargets.length > displayLimit) { + button = isExpanded ? ( + setIsExpanded(false)}> + + + ) : ( + setIsExpanded(true)}> + + + ); + } + } + + const unauthorizedCountBadge = + !isSharedToAllSpaces && (isExpanded || button === null) && unauthorizedCount > 0 ? ( + + + } + > + +{unauthorizedCount} + + + ) : null; + + return ( + + {displayedSpaces.map((space) => ( + + + + ))} + {unauthorizedCountBadge} + {button} + + ); +}; diff --git a/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx index 4630ef0433a53..badee5ebbc3b5 100644 --- a/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx +++ b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx @@ -11,7 +11,6 @@ import { createSpacesReactContext } from './context'; import { PluginsStart } from '../plugin'; import { SpacesManager } from '../spaces_manager'; import { SpaceTarget } from '../share_saved_objects_to_space/types'; -import { getSpaceColor } from '../space_avatar'; import { SpacesData } from './types'; interface Props { @@ -28,7 +27,6 @@ async function getSpacesData(spacesManager: SpacesManager): Promise const isPartiallyAuthorized = authorizedPurposes?.shareSavedObjectsIntoSpace === false; return { ...space, - color: getSpaceColor(space), ...(isActiveSpace && { isActiveSpace }), ...(isPartiallyAuthorized && { isPartiallyAuthorized }), }; diff --git a/x-pack/plugins/spaces/public/ui_api/components.ts b/x-pack/plugins/spaces/public/ui_api/components.ts index 9dafe413fcce4..8b538e79842d4 100644 --- a/x-pack/plugins/spaces/public/ui_api/components.ts +++ b/x-pack/plugins/spaces/public/ui_api/components.ts @@ -11,6 +11,7 @@ import { PluginsStart } from '../plugin'; import { getShareToSpaceFlyoutComponent } from '../share_saved_objects_to_space'; import { getSpacesContextWrapper } from '../spaces_context'; import { SpacesManager } from '../spaces_manager'; +import { getSpaceListComponent } from '../space_list'; export interface GetComponentsOptions { spacesManager: SpacesManager; @@ -24,5 +25,6 @@ export const getComponents = ({ return { SpacesContext: getSpacesContextWrapper({ spacesManager, getStartServices }), ShareToSpaceFlyout: getShareToSpaceFlyoutComponent(), + SpaceList: getSpaceListComponent(), }; }; diff --git a/x-pack/plugins/spaces/public/ui_api/mocks.ts b/x-pack/plugins/spaces/public/ui_api/mocks.ts index 4cbe33d2f09d8..13ef05f3e5846 100644 --- a/x-pack/plugins/spaces/public/ui_api/mocks.ts +++ b/x-pack/plugins/spaces/public/ui_api/mocks.ts @@ -14,6 +14,7 @@ function createComponentsMock(): jest.Mocked { return { SpacesContext: jest.fn(), ShareToSpaceFlyout: jest.fn(), + SpaceList: jest.fn(), }; } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c616b612585c0..63e9a66b898bc 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20673,11 +20673,9 @@ "xpack.spaces.management.selectAllFeaturesLink": "すべて選択", "xpack.spaces.management.shareToSpace.actionDescription": "この保存されたオブジェクトを1つ以上のスペースと共有します。", "xpack.spaces.management.shareToSpace.actionTitle": "スペースと共有", - "xpack.spaces.management.shareToSpace.allSpacesLabel": "*すべてのスペース", "xpack.spaces.management.shareToSpace.cancelButton": "キャンセル", "xpack.spaces.management.shareToSpace.columnDescription": "このオブジェクトが現在共有されている他のスペース", "xpack.spaces.management.shareToSpace.columnTitle": "共有されているスペース", - "xpack.spaces.management.shareToSpace.columnUnauthorizedLabel": "これらのスペースを表示するアクセス権がありません。", "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.linkText": "新しいスペースを作成", "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.text": "オブジェクトを共有するには、{createANewSpaceLink}できます。", "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked": "このスペースの選択を解除するには、追加の権限が必要です。", @@ -20696,8 +20694,6 @@ "xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存して閉じる", "xpack.spaces.management.shareToSpace.shareWarningLink": "コピーを作成", "xpack.spaces.management.shareToSpace.shareWarningTitle": "共有オブジェクトの編集は、すべてのスペースで変更を適用します。", - "xpack.spaces.management.shareToSpace.showLessSpacesLink": "縮小表示", - "xpack.spaces.management.shareToSpace.showMoreSpacesLink": "他 {count} 件", "xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "利用可能なスペースを読み込み中にエラーが発生", "xpack.spaces.management.shareToSpace.unknownSpacesLabel.additionalPrivilegesLink": "追加権限", "xpack.spaces.management.shareToSpace.unknownSpacesLabel.text": "非表示のスペースを表示するには、{additionalPrivilegesLink}が必要です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 70d93b9928841..8bc9236c82402 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -20721,11 +20721,9 @@ "xpack.spaces.management.selectAllFeaturesLink": "全选", "xpack.spaces.management.shareToSpace.actionDescription": "将此已保存对象共享到一个或多个工作区", "xpack.spaces.management.shareToSpace.actionTitle": "共享到工作区", - "xpack.spaces.management.shareToSpace.allSpacesLabel": "* 所有工作区", "xpack.spaces.management.shareToSpace.cancelButton": "取消", "xpack.spaces.management.shareToSpace.columnDescription": "目前将此对象共享到的其他工作区", "xpack.spaces.management.shareToSpace.columnTitle": "共享工作区", - "xpack.spaces.management.shareToSpace.columnUnauthorizedLabel": "您无权查看这些工作区。", "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.linkText": "创建新工作区", "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.text": "您可以{createANewSpaceLink},用于共享您的对象。", "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked": "您需要额外权限才能取消选择此工作区。", @@ -20744,8 +20742,6 @@ "xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存并关闭", "xpack.spaces.management.shareToSpace.shareWarningLink": "创建副本", "xpack.spaces.management.shareToSpace.shareWarningTitle": "编辑共享对象会在所有工作区中应用更改", - "xpack.spaces.management.shareToSpace.showLessSpacesLink": "显示更少", - "xpack.spaces.management.shareToSpace.showMoreSpacesLink": "另外 {count} 个", "xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "加载可用工作区时出错", "xpack.spaces.management.shareToSpace.unknownSpacesLabel.additionalPrivilegesLink": "其他权限", "xpack.spaces.management.shareToSpace.unknownSpacesLabel.text": "要查看隐藏的工作区,您需要{additionalPrivilegesLink}。", From 68018a78279659d88da077f6ff1a528abbdb5a98 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Mon, 8 Feb 2021 18:38:50 -0500 Subject: [PATCH 11/29] Allow consumers to specify a feature ID for SpaceContext When a feature ID is specified on a SpaceContext, other Space UI components will behave accordingly when the feature is disabled in a given space. In SpacesList, the affected spaces will be moved to the end of the list. In ShareToSpaceFlyout, the affected spaces will only be shown if the object already exists in those spaces, and will be differentiated with a tooltip explaining why. --- src/plugins/spaces_oss/public/api.ts | 12 +- src/plugins/spaces_oss/public/index.ts | 1 + x-pack/plugins/spaces/public/plugin.tsx | 2 +- .../components/selectable_spaces_control.tsx | 112 +++++++--- .../components/share_mode_control.tsx | 4 +- .../share_to_space_flyout_internal.test.tsx | 198 ++++++++++++------ .../share_to_space_flyout_internal.tsx | 5 +- .../components/share_to_space_form.tsx | 5 +- .../share_saved_objects_to_space/types.ts | 6 - .../space_list/space_list_internal.test.tsx | 81 +++++-- .../public/space_list/space_list_internal.tsx | 31 ++- .../spaces/public/spaces_context/context.tsx | 3 +- .../spaces/public/spaces_context/types.ts | 7 +- .../spaces/public/spaces_context/wrapper.tsx | 31 +-- x-pack/plugins/spaces/public/types.ts | 22 ++ 15 files changed, 360 insertions(+), 160 deletions(-) create mode 100644 x-pack/plugins/spaces/public/types.ts diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index 5a5ae0a55f7b1..81ab7e39a0701 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -41,7 +41,7 @@ export interface SpacesApiUiComponent { /** * Provides a context that is required to render all Spaces components. */ - SpacesContext: FunctionComponent; + SpacesContext: FunctionComponent; /** * Displays the tags for given saved object. */ @@ -52,6 +52,16 @@ export interface SpacesApiUiComponent { SpaceList: FunctionComponent; } +/** + * @public + */ +export interface SpacesContextProps { + /** + * If a feature is specified, all Spaces components will treat it appropriately if the feature is disabled in a given Space. + */ + feature?: string; +} + /** * @public */ diff --git a/src/plugins/spaces_oss/public/index.ts b/src/plugins/spaces_oss/public/index.ts index fc0849f25d5a4..a6ed7b6f2a9e1 100644 --- a/src/plugins/spaces_oss/public/index.ts +++ b/src/plugins/spaces_oss/public/index.ts @@ -19,6 +19,7 @@ export { SpacesApi, SpacesApiUi, SpacesApiUiComponent, + SpacesContextProps, ShareToSpaceFlyoutProps, ShareToSpaceSavedObjectTarget, SpaceListProps, diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index aa5e50d8a6c0c..d99f447314a6b 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -98,7 +98,7 @@ export class SpacesPlugin implements Plugin void; enableCreateNewSpaceLink: boolean; @@ -39,31 +39,38 @@ interface Props { type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; const ROW_HEIGHT = 40; -const partiallyAuthorizedTooltip = { - checked: i18n.translate( - 'xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked', - { defaultMessage: 'You need additional privileges to deselect this space.' } - ), - unchecked: i18n.translate( - 'xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked', - { defaultMessage: 'You need additional privileges to select this space.' } - ), -}; -const partiallyAuthorizedSpaceProps = (checked: boolean) => ({ - append: ( - - ), - disabled: true, -}); -const activeSpaceProps = { - append: Current, - disabled: true, - checked: 'on' as 'on', -}; +const APPEND_ACTIVE_SPACE = Current; +const APPEND_CANNOT_SELECT = ( + +); +const APPEND_CANNOT_DESELECT = ( + +); +const APPEND_FEATURE_IS_DISABLED = ( + +); export const SelectableSpacesControl = (props: Props) => { const { @@ -80,9 +87,11 @@ export const SelectableSpacesControl = (props: Props) => { !enableSpaceAgnosticBehavior && spaces.find((space) => space.isActiveSpace)!.id; const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); const options = spaces - .sort((a, b) => (a.id === activeSpaceId ? -1 : b.id === activeSpaceId ? 1 : 0)) + .filter(({ id, isFeatureDisabled }) => !isFeatureDisabled || selectedSpaceIds.includes(id)) // filter out spaces that are not already selected and have the feature disabled in that space + .sort(createSpacesComparator(activeSpaceId)) .map((space) => { const checked = selectedSpaceIds.includes(space.id); + const additionalProps = getAdditionalProps(space, activeSpaceId, checked); return { label: space.name, prepend: , @@ -90,8 +99,7 @@ export const SelectableSpacesControl = (props: Props) => { ['data-space-id']: space.id, ['data-test-subj']: `sts-space-selector-row-${space.id}`, ...(isGlobalControlChecked && { disabled: true }), - ...(space.isPartiallyAuthorized && partiallyAuthorizedSpaceProps(checked)), - ...(space.id === activeSpaceId && activeSpaceProps), + ...additionalProps, }; }); @@ -205,3 +213,47 @@ export const SelectableSpacesControl = (props: Props) => { ); }; + +/** + * Gets additional props for the selection option. + */ +function getAdditionalProps(space: SpaceData, activeSpaceId: string | false, checked: boolean) { + if (space.id === activeSpaceId) { + return { + append: APPEND_ACTIVE_SPACE, + disabled: true, + checked: 'on' as 'on', + }; + } else if (space.isPartiallyAuthorized) { + return { + append: ( + <> + {checked ? APPEND_CANNOT_DESELECT : APPEND_CANNOT_SELECT} + {space.isFeatureDisabled ? APPEND_FEATURE_IS_DISABLED : null} + + ), + disabled: true, + }; + } else if (space.isFeatureDisabled) { + return { + append: APPEND_FEATURE_IS_DISABLED, + }; + } +} + +/** + * Given the active space, create a comparator to sort a SpaceData array so that the active space is at the beginning, and space(s) for + * which the current feature is disabled are all at the end. + */ +function createSpacesComparator(activeSpaceId: string | false) { + return (a: SpaceData, b: SpaceData) => { + if (a.id === activeSpaceId) { + return -1; + } else if (b.id === activeSpaceId) { + return 1; + } else if (a.isFeatureDisabled !== b.isFeatureDisabled) { + return a.isFeatureDisabled ? 1 : -1; + } + return 0; + }; +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx index b30084f316070..c523da0df693f 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx @@ -21,10 +21,10 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { ALL_SPACES_ID } from '../../../common/constants'; -import { SpaceTarget } from '../types'; +import { SpaceData } from '../../types'; interface Props { - spaces: SpaceTarget[]; + spaces: SpaceData[]; objectNoun: string; canShareToAllSpaces: boolean; selectedSpaceIds: string[]; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx index 0b81c6187cbd8..f31829f4a4b2b 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx @@ -39,6 +39,7 @@ interface SetupOpts { enableCreateCopyCallout?: boolean; enableCreateNewSpaceLink?: boolean; enableSpaceAgnosticBehavior?: boolean; + mockFeatureId?: string; // optional feature ID to use for the SpacesContext } const setup = async (opts: SetupOpts = {}) => { @@ -110,7 +111,7 @@ const setup = async (opts: SetupOpts = {}) => { // the context wrapper is only split into a separate component to avoid recreating the context upon every flyout state change // the ShareToSpaceFlyout component renders the internal flyout inside of the context wrapper const wrapper = mountWithIntl( - + { }); describe('space selection', () => { + const mockFeatureId = 'some-feature'; + const mockSpaces = [ { // normal "fully authorized" space selection option -- not the active space @@ -558,12 +561,25 @@ describe('ShareToSpaceFlyout', () => { disabledFeatures: [], }, { - // "partially authorized" space selection option -- not the active space + // normal "fully authorized" space selection option, with a disabled feature -- not the active space id: 'space-2', name: 'Space 2', + disabledFeatures: [mockFeatureId], + }, + { + // "partially authorized" space selection option -- not the active space + id: 'space-3', + name: 'Space 3', disabledFeatures: [], authorizedPurposes: { shareSavedObjectsIntoSpace: false }, }, + { + // "partially authorized" space selection option, with a disabled feature -- not the active space + id: 'space-4', + name: 'Space 4', + disabledFeatures: [mockFeatureId], + authorizedPurposes: { shareSavedObjectsIntoSpace: false }, + }, { // "active space" selection option (determined by an ID that matches the result of `getActiveSpace`, mocked at top) id: 'my-active-space', @@ -572,7 +588,8 @@ describe('ShareToSpaceFlyout', () => { }, ]; - const expectActiveSpace = (option: any) => { + const expectActiveSpace = (option: any, { spaceId }: { spaceId: string }) => { + expect(option['data-space-id']).toEqual(spaceId); expect(option.append).toMatchInlineSnapshot(` { expect(option.checked).toEqual('on'); expect(option.disabled).toEqual(true); }; - const expectInactiveSpace = (option: any, checked: boolean) => { - expect(option.append).toBeUndefined(); - expect(option.checked).toEqual(checked ? 'on' : undefined); - expect(option.disabled).toBeUndefined(); - }; - const expectPartiallyAuthorizedSpace = (option: any, checked: boolean) => { - if (checked) { + const expectNeedAdditionalPrivileges = ( + option: any, + { + spaceId, + checked, + featureIsDisabled, + }: { spaceId: string; checked: boolean; featureIsDisabled?: boolean } + ) => { + expect(option['data-space-id']).toEqual(spaceId); + if (checked && featureIsDisabled) { expect(option.append).toMatchInlineSnapshot(` - + + + + `); - } else { + } else if (checked && !featureIsDisabled) { + expect(option.append).toMatchInlineSnapshot(` + + + + `); + } else if (!checked && !featureIsDisabled) { expect(option.append).toMatchInlineSnapshot(` - + + + `); + } else { + throw new Error('Unexpected test case!'); } expect(option.checked).toEqual(checked ? 'on' : undefined); expect(option.disabled).toEqual(true); }; + const expectFeatureIsDisabled = (option: any, { spaceId }: { spaceId: string }) => { + expect(option['data-space-id']).toEqual(spaceId); + expect(option.append).toMatchInlineSnapshot(` + + `); + expect(option.checked).toEqual('on'); + expect(option.disabled).toBeUndefined(); + }; + const expectInactiveSpace = ( + option: any, + { spaceId, checked }: { spaceId: string; checked: boolean } + ) => { + expect(option['data-space-id']).toEqual(spaceId); + expect(option.append).toBeUndefined(); + expect(option.checked).toEqual(checked ? 'on' : undefined); + expect(option.disabled).toBeUndefined(); + }; describe('without enableSpaceAgnosticBehavior', () => { - it('correctly defines space selection options when spaces are not selected', async () => { - const namespaces = ['my-active-space']; // the saved object's current namespaces; it will always exist in at least the active namespace + it('correctly defines space selection options', async () => { + const namespaces = ['my-active-space', 'space-1', 'space-3']; // the saved object's current namespaces const { wrapper } = await setup({ mockSpaces, namespaces }); const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); - const selectOptions = selectable.prop('options'); - expect(selectOptions[0]['data-space-id']).toEqual('my-active-space'); - expectActiveSpace(selectOptions[0]); - expect(selectOptions[1]['data-space-id']).toEqual('space-1'); - expectInactiveSpace(selectOptions[1], false); - expect(selectOptions[2]['data-space-id']).toEqual('space-2'); - expectPartiallyAuthorizedSpace(selectOptions[2], false); + const options = selectable.prop('options'); + expect(options).toHaveLength(5); + expectActiveSpace(options[0], { spaceId: 'my-active-space' }); + expectInactiveSpace(options[1], { spaceId: 'space-1', checked: true }); + expectInactiveSpace(options[2], { spaceId: 'space-2', checked: false }); + expectNeedAdditionalPrivileges(options[3], { spaceId: 'space-3', checked: true }); + expectNeedAdditionalPrivileges(options[4], { spaceId: 'space-4', checked: false }); }); - it('correctly defines space selection options when spaces are selected', async () => { - const namespaces = ['my-active-space', 'space-1', 'space-2']; // the saved object's current namespaces - const { wrapper } = await setup({ mockSpaces, namespaces }); + describe('with a SpacesContext for a specific feature', () => { + it('correctly defines space selection options when affected spaces are not selected', async () => { + const namespaces = ['my-active-space']; // the saved object's current namespaces + const { wrapper } = await setup({ mockSpaces, namespaces, mockFeatureId }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const options = selectable.prop('options'); + expect(options).toHaveLength(3); + expectActiveSpace(options[0], { spaceId: 'my-active-space' }); + expectInactiveSpace(options[1], { spaceId: 'space-1', checked: false }); + expectNeedAdditionalPrivileges(options[2], { spaceId: 'space-3', checked: false }); + // space-2 and space-4 are omitted, because they are not selected and the current feature is disabled in those spaces + }); - const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); - const selectOptions = selectable.prop('options'); - expect(selectOptions[0]['data-space-id']).toEqual('my-active-space'); - expectActiveSpace(selectOptions[0]); - expect(selectOptions[1]['data-space-id']).toEqual('space-1'); - expectInactiveSpace(selectOptions[1], true); - expect(selectOptions[2]['data-space-id']).toEqual('space-2'); - expectPartiallyAuthorizedSpace(selectOptions[2], true); + it('correctly defines space selection options when affected spaces are already selected', async () => { + const namespaces = ['my-active-space', 'space-1', 'space-2', 'space-3', 'space-4']; // the saved object's current namespaces + const { wrapper } = await setup({ mockSpaces, namespaces, mockFeatureId }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const options = selectable.prop('options'); + expect(options).toHaveLength(5); + expectActiveSpace(options[0], { spaceId: 'my-active-space' }); + expectInactiveSpace(options[1], { spaceId: 'space-1', checked: true }); + expectNeedAdditionalPrivileges(options[2], { spaceId: 'space-3', checked: true }); + // space-2 and space-4 are at the end, because they are selected and the current feature is disabled in those spaces + expectFeatureIsDisabled(options[3], { spaceId: 'space-2' }); + expectNeedAdditionalPrivileges(options[4], { + spaceId: 'space-4', + checked: true, + featureIsDisabled: true, + }); + }); }); }); describe('with enableSpaceAgnosticBehavior', () => { const enableSpaceAgnosticBehavior = true; - it('correctly defines space selection options when spaces are not selected', async () => { - const namespaces = ['my-active-space']; // the saved object's current namespaces; it will always exist in at least the active namespace - const { wrapper } = await setup({ enableSpaceAgnosticBehavior, mockSpaces, namespaces }); - - const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); - const selectOptions = selectable.prop('options'); - expect(selectOptions[0]['data-space-id']).toEqual('space-1'); - expectInactiveSpace(selectOptions[0], false); - expect(selectOptions[1]['data-space-id']).toEqual('space-2'); - expectPartiallyAuthorizedSpace(selectOptions[1], false); - expect(selectOptions[2]['data-space-id']).toEqual('my-active-space'); - expectInactiveSpace(selectOptions[2], true); - }); - - it('correctly defines space selection options when spaces are selected', async () => { - const namespaces = ['my-active-space', 'space-1', 'space-2']; // the saved object's current namespaces + it('correctly defines space selection options', async () => { + const namespaces = ['my-active-space', 'space-1', 'space-3']; // the saved object's current namespaces const { wrapper } = await setup({ enableSpaceAgnosticBehavior, mockSpaces, namespaces }); const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); - const selectOptions = selectable.prop('options'); - expect(selectOptions[0]['data-space-id']).toEqual('space-1'); - expectInactiveSpace(selectOptions[0], true); - expect(selectOptions[1]['data-space-id']).toEqual('space-2'); - expectPartiallyAuthorizedSpace(selectOptions[1], true); - expect(selectOptions[2]['data-space-id']).toEqual('my-active-space'); - expectInactiveSpace(selectOptions[2], true); + const options = selectable.prop('options'); + expect(options).toHaveLength(5); + expectInactiveSpace(options[0], { spaceId: 'space-1', checked: true }); + expectInactiveSpace(options[1], { spaceId: 'space-2', checked: false }); + expectNeedAdditionalPrivileges(options[2], { spaceId: 'space-3', checked: true }); + expectNeedAdditionalPrivileges(options[3], { spaceId: 'space-4', checked: false }); + expectInactiveSpace(options[4], { spaceId: 'my-active-space', checked: true }); }); }); }); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx index 4aac254bcc0a5..69573937b116a 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -30,8 +30,9 @@ import type { } from 'src/plugins/spaces_oss/public'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; import { SpacesManager } from '../../spaces_manager'; +import { SpaceData } from '../../types'; import { ShareToSpaceForm } from './share_to_space_form'; -import { ShareOptions, SpaceTarget } from '../types'; +import { ShareOptions } from '../types'; import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; import { useSpaces } from '../../spaces_context'; @@ -133,7 +134,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { const [{ isLoading, spaces }, setSpacesState] = useState<{ isLoading: boolean; - spaces: SpaceTarget[]; + spaces: SpaceData[]; }>({ isLoading: true, spaces: [] }); useEffect(() => { const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObjectTarget.type); 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 index 7d6c4fea378cb..d8303670756e0 100644 --- 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 @@ -9,11 +9,12 @@ import './share_to_space_form.scss'; import React, { Fragment } from 'react'; import { EuiSpacer, EuiCallOut, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ShareOptions, SpaceTarget } from '../types'; +import { SpaceData } from '../../types'; +import { ShareOptions } from '../types'; import { ShareModeControl } from './share_mode_control'; interface Props { - spaces: SpaceTarget[]; + spaces: SpaceData[]; objectNoun: string; onUpdate: (shareOptions: ShareOptions) => void; shareOptions: ShareOptions; 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 index dfa67304d5ec6..b1e55ff16bafa 100644 --- 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 @@ -6,7 +6,6 @@ */ import { SavedObjectsImportRetry, SavedObjectsImportResponse } from 'src/core/public'; -import { GetSpaceResult } from '..'; export interface ShareOptions { selectedSpaceIds: string[]; @@ -17,8 +16,3 @@ export type ImportRetry = Omit; export interface ShareSavedObjectsToSpaceResponse { [spaceId: string]: SavedObjectsImportResponse; } - -export interface SpaceTarget extends Omit { - isActiveSpace?: boolean; - isPartiallyAuthorized?: boolean; -} diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx index 8d6d3da87aa4e..3da59cbe8cdc7 100644 --- a/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx @@ -24,11 +24,10 @@ const ACTIVE_SPACE: Space = { }; const getSpaceData = (inactiveSpaceCount: number = 0) => { const inactive = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'] - .map((name) => ({ - id: name.toLowerCase(), - name, - disabledFeatures: [], - })) + .map((name) => { + const id = name.toLowerCase(); + return { id, name, disabledFeatures: [`${id}-feature`] }; + }) .slice(0, inactiveSpaceCount); const spaces = [ACTIVE_SPACE, ...inactive]; const namespaces = spaces.map(({ id }) => id); @@ -42,7 +41,15 @@ const getSpaceData = (inactiveSpaceCount: number = 0) => { * If '*' (aka "All spaces") is present, it supersedes all of the above and just displays a single badge without a button. */ describe('SpaceListInternal', () => { - const createSpaceList = async (spaces: Space[], props: SpaceListProps) => { + const createSpaceList = async ({ + spaces, + props, + feature, + }: { + spaces: Space[]; + props: SpaceListProps; + feature?: string; + }) => { const { getStartServices } = coreMock.createSetup(); const spacesManager = spacesManagerMock.create(); spacesManager.getActiveSpace.mockResolvedValue(ACTIVE_SPACE); @@ -50,7 +57,7 @@ describe('SpaceListInternal', () => { const SpacesContext = getSpacesContextWrapper({ getStartServices, spacesManager }); const wrapper = mountWithIntl( - + ); @@ -74,7 +81,8 @@ describe('SpaceListInternal', () => { const { spaces, namespaces } = getSpaceData(); it('does not show badges or button', async () => { - const wrapper = await createSpaceList(spaces, { namespaces }); + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toHaveLength(0); expect(getButton(wrapper)).toHaveLength(0); @@ -85,7 +93,8 @@ describe('SpaceListInternal', () => { const { spaces, namespaces } = getSpaceData(1); it('shows one badge without button', async () => { - const wrapper = await createSpaceList(spaces, { namespaces }); + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A']); expect(getButton(wrapper)).toHaveLength(0); @@ -96,7 +105,8 @@ describe('SpaceListInternal', () => { const { spaces, namespaces } = getSpaceData(5); it('shows badges without button', async () => { - const wrapper = await createSpaceList(spaces, { namespaces }); + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); expect(getButton(wrapper)).toHaveLength(0); @@ -108,7 +118,7 @@ describe('SpaceListInternal', () => { it('shows badges without button', async () => { const props = { namespaces: [...namespaces, '?'] }; - const wrapper = await createSpaceList(spaces, props); + const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', '+1']); expect(getButton(wrapper)).toHaveLength(0); @@ -120,7 +130,7 @@ describe('SpaceListInternal', () => { it('shows badges without button', async () => { const props = { namespaces: [...namespaces, '?', '?'] }; - const wrapper = await createSpaceList(spaces, props); + const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', '+2']); expect(getButton(wrapper)).toHaveLength(0); @@ -131,7 +141,8 @@ describe('SpaceListInternal', () => { const { spaces, namespaces } = getSpaceData(6); it('shows badges with button', async () => { - const wrapper = await createSpaceList(spaces, { namespaces }); + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); @@ -150,7 +161,7 @@ describe('SpaceListInternal', () => { it('shows badges with button', async () => { const props = { namespaces: [...namespaces, '?'] }; - const wrapper = await createSpaceList(spaces, props); + const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); const button = getButton(wrapper); @@ -168,7 +179,7 @@ describe('SpaceListInternal', () => { it('shows badges with button', async () => { const props = { namespaces: [...namespaces, '?', '?'] }; - const wrapper = await createSpaceList(spaces, props); + const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); const button = getButton(wrapper); @@ -183,7 +194,8 @@ describe('SpaceListInternal', () => { describe('with only "all spaces"', () => { it('shows one badge without button', async () => { - const wrapper = await createSpaceList([], { namespaces: ['*'] }); + const props = { namespaces: ['*'] }; + const wrapper = await createSpaceList({ spaces: [], props }); expect(getListText(wrapper)).toEqual(['*']); expect(getButton(wrapper)).toHaveLength(0); @@ -196,7 +208,7 @@ describe('SpaceListInternal', () => { it('shows one badge without button', async () => { const props = { namespaces: ['*', ...namespaces, '?'] }; - const wrapper = await createSpaceList(spaces, props); + const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['*']); expect(getButton(wrapper)).toHaveLength(0); @@ -210,7 +222,7 @@ describe('SpaceListInternal', () => { it('with displayLimit=0, shows badges without button', async () => { const props = { namespaces: [...namespaces, '?'], displayLimit: 0 }; - const wrapper = await createSpaceList(spaces, props); + const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); expect(getButton(wrapper)).toHaveLength(0); @@ -218,7 +230,7 @@ describe('SpaceListInternal', () => { it('with displayLimit=1, shows badges with button', async () => { const props = { namespaces: [...namespaces, '?'], displayLimit: 1 }; - const wrapper = await createSpaceList(spaces, props); + const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A']); const button = getButton(wrapper); @@ -232,7 +244,7 @@ describe('SpaceListInternal', () => { it('with displayLimit=7, shows badges with button', async () => { const props = { namespaces: [...namespaces, '?'], displayLimit: 7 }; - const wrapper = await createSpaceList(spaces, props); + const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G']); const button = getButton(wrapper); @@ -246,7 +258,7 @@ describe('SpaceListInternal', () => { it('with displayLimit=8, shows badges without button', async () => { const props = { namespaces: [...namespaces, '?'], displayLimit: 8 }; - const wrapper = await createSpaceList(spaces, props); + const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); expect(getButton(wrapper)).toHaveLength(0); @@ -254,7 +266,7 @@ describe('SpaceListInternal', () => { it('with enableSpaceAgnosticBehavior=true, shows badges with button', async () => { const props = { namespaces: [...namespaces, '?'], enableSpaceAgnosticBehavior: true }; - const wrapper = await createSpaceList(spaces, props); + const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['D!', 'A', 'B', 'C', 'D']); const button = getButton(wrapper); @@ -267,4 +279,29 @@ describe('SpaceListInternal', () => { }); }); }); + + describe('with a SpacesContext for a specific feature', () => { + describe('with the active space, eight inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(8); + + it('shows badges with button, showing disabled features at the end of the list', async () => { + // Each space that is generated by the getSpaceData function has a disabled feature derived from its own ID. + // E.g., the Alpha space has `disabledFeatures: ['alpha-feature']`, the Bravo space has `disabledFeatures: ['bravo-feature']`, and + // so on and so forth. For this test case we will render the Space context for the 'bravo-feature' feature, so the SpaceAvatar for + // the Bravo space will appear at the end of the list. + const props = { namespaces: [...namespaces, '?'] }; + const feature = 'bravo-feature'; + const wrapper = await createSpaceList({ spaces, props, feature }); + + expect(getListText(wrapper)).toEqual(['A', 'C', 'D', 'E', 'F']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+4 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'C', 'D', 'E', 'F', 'G', 'H', 'B', '+1']); + expect(button.text()).toEqual('show less'); + }); + }); + }); }); diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx index 91180f7de17f3..907d6f7fe85d7 100644 --- a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx @@ -13,10 +13,9 @@ import { EuiToolTip } from '@elastic/eui'; import { EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public'; -import { SpaceTarget } from '../share_saved_objects_to_space/types'; +import { SpacesData, SpaceData } from '../types'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; import { useSpaces } from '../spaces_context'; -import { SpacesData } from '../spaces_context/types'; import { SpaceAvatar } from '../space_avatar'; const DEFAULT_DISPLAY_LIMIT = 5; @@ -44,7 +43,7 @@ export const SpaceListInternal = ({ const isSharedToAllSpaces = namespaces?.includes(ALL_SPACES_ID); const unauthorizedCount = (namespaces?.filter((namespace) => namespace === UNKNOWN_SPACE) ?? []) .length; - let displayedSpaces: SpaceTarget[]; + let displayedSpaces: SpaceData[]; let button: ReactNode = null; if (isSharedToAllSpaces) { @@ -60,16 +59,23 @@ export const SpaceListInternal = ({ ]; } else { const authorized = namespaces?.filter((namespace) => namespace !== UNKNOWN_SPACE) ?? []; - const authorizedSpaceTargets: SpaceTarget[] = []; + const enabledSpaceTargets: SpaceData[] = []; + const disabledSpaceTargets: SpaceData[] = []; authorized.forEach((namespace) => { const spaceTarget = spacesData.spacesMap.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 }); + enabledSpaceTargets.push({ id: namespace, name: namespace }); } else if (enableSpaceAgnosticBehavior || !spaceTarget.isActiveSpace) { - authorizedSpaceTargets.push(spaceTarget); + if (spaceTarget.isFeatureDisabled) { + disabledSpaceTargets.push(spaceTarget); + } else { + enabledSpaceTargets.push(spaceTarget); + } } }); + const authorizedSpaceTargets = [...enabledSpaceTargets, ...disabledSpaceTargets]; + displayedSpaces = isExpanded || !displayLimit ? authorizedSpaceTargets @@ -115,11 +121,14 @@ export const SpaceListInternal = ({ return ( - {displayedSpaces.map((space) => ( - - - - ))} + {displayedSpaces.map((space) => { + const color = space.isFeatureDisabled ? 'hollow' : space.color; + return ( + + + + ); + })} {unauthorizedCountBadge} {button} diff --git a/x-pack/plugins/spaces/public/spaces_context/context.tsx b/x-pack/plugins/spaces/public/spaces_context/context.tsx index 084778d1882d8..b4ca71cd377b7 100644 --- a/x-pack/plugins/spaces/public/spaces_context/context.tsx +++ b/x-pack/plugins/spaces/public/spaces_context/context.tsx @@ -7,7 +7,8 @@ import * as React from 'react'; import { SpacesManager } from '../spaces_manager'; -import { SpacesReactContext, SpacesReactContextValue, KibanaServices, SpacesData } from './types'; +import { SpacesData } from '../types'; +import { SpacesReactContext, SpacesReactContextValue, KibanaServices } from './types'; const { useContext, createElement, createContext } = React; diff --git a/x-pack/plugins/spaces/public/spaces_context/types.ts b/x-pack/plugins/spaces/public/spaces_context/types.ts index a87b89edd5907..afc2358db0b66 100644 --- a/x-pack/plugins/spaces/public/spaces_context/types.ts +++ b/x-pack/plugins/spaces/public/spaces_context/types.ts @@ -7,16 +7,11 @@ import * as React from 'react'; import { CoreStart } from 'src/core/public'; -import { SpaceTarget } from '../share_saved_objects_to_space/types'; +import { SpacesData } from '../types'; import { SpacesManager } from '../spaces_manager'; export type KibanaServices = Partial; -export interface SpacesData { - readonly spacesMap: Map; - readonly activeSpaceId: string; -} - export interface SpacesReactContextValue { readonly spacesManager: SpacesManager; readonly spacesDataPromise: Promise; diff --git a/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx index badee5ebbc3b5..247b868f07517 100644 --- a/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx +++ b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx @@ -7,31 +7,33 @@ import React, { useState, useEffect, PropsWithChildren, useMemo } from 'react'; import { StartServicesAccessor, CoreStart } from 'src/core/public'; +import type { SpacesContextProps } from '../../../../../src/plugins/spaces_oss/public'; import { createSpacesReactContext } from './context'; import { PluginsStart } from '../plugin'; import { SpacesManager } from '../spaces_manager'; -import { SpaceTarget } from '../share_saved_objects_to_space/types'; -import { SpacesData } from './types'; +import { SpacesData, SpaceData } from '../types'; -interface Props { +interface InternalProps { spacesManager: SpacesManager; getStartServices: StartServicesAccessor; } -async function getSpacesData(spacesManager: SpacesManager): Promise { +async function getSpacesData(spacesManager: SpacesManager, feature?: string): Promise { const spaces = await spacesManager.getSpaces({ includeAuthorizedPurposes: true }); const activeSpace = await spacesManager.getActiveSpace(); const spacesMap = spaces - .map(({ authorizedPurposes, ...space }) => { + .map(({ authorizedPurposes, disabledFeatures, ...space }) => { const isActiveSpace = space.id === activeSpace.id; const isPartiallyAuthorized = authorizedPurposes?.shareSavedObjectsIntoSpace === false; + const isFeatureDisabled = feature && disabledFeatures.includes(feature); return { ...space, ...(isActiveSpace && { isActiveSpace }), ...(isPartiallyAuthorized && { isPartiallyAuthorized }), + ...(isFeatureDisabled && { isFeatureDisabled }), }; }) - .reduce((acc, cur) => acc.set(cur.id, cur), new Map()); + .reduce((acc, cur) => acc.set(cur.id, cur), new Map()); return { spacesMap, @@ -39,11 +41,14 @@ async function getSpacesData(spacesManager: SpacesManager): Promise }; } -const SpacesContextWrapper = (props: PropsWithChildren) => { - const { spacesManager, getStartServices, children } = props; +const SpacesContextWrapper = (props: PropsWithChildren) => { + const { spacesManager, getStartServices, feature, children } = props; const [coreStart, setCoreStart] = useState(); - const spacesDataPromise = useMemo(() => getSpacesData(spacesManager), [spacesManager]); + const spacesDataPromise = useMemo(() => getSpacesData(spacesManager, feature), [ + spacesManager, + feature, + ]); useEffect(() => { getStartServices().then(([coreStartValue]) => { @@ -62,8 +67,10 @@ const SpacesContextWrapper = (props: PropsWithChildren) => { return {children}; }; -export const getSpacesContextWrapper = (props: Props): React.FC => { - return ({ children }) => { - return ; +export const getSpacesContextWrapper = ( + internalProps: InternalProps +): React.FC => { + return ({ children, ...props }: PropsWithChildren) => { + return ; }; }; diff --git a/x-pack/plugins/spaces/public/types.ts b/x-pack/plugins/spaces/public/types.ts new file mode 100644 index 0000000000000..739ede09901b4 --- /dev/null +++ b/x-pack/plugins/spaces/public/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GetSpaceResult } from '../common'; + +export interface SpacesData { + readonly spacesMap: Map; + readonly activeSpaceId: string; +} + +export interface SpaceData extends Omit { + /** True if this space is the active space. */ + isActiveSpace?: boolean; + /** True if the user has read access to this space, but is not authorized to share objects into this space. */ + isPartiallyAuthorized?: boolean; + /** True if the current feature (specified in the `SpacesContext`) is disabled in this space. */ + isFeatureDisabled?: boolean; +} From 8a0008ae0e15c2b5adebdfabb34011e77810b304 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Mon, 8 Feb 2021 23:25:16 -0500 Subject: [PATCH 12/29] Change ML plugin to consume new reusable Spaces components --- x-pack/plugins/ml/kibana.json | 1 - .../components/job_spaces_list/index.ts | 2 +- .../job_spaces_list/job_spaces_list.tsx | 93 +++++--- .../cannot_edit_callout.tsx | 30 --- .../components/job_spaces_selector/index.ts | 8 - .../jobs_spaces_flyout.tsx | 132 ----------- .../job_spaces_selector/spaces_selector.scss | 3 - .../job_spaces_selector/spaces_selectors.tsx | 223 ------------------ .../application/contexts/spaces/index.ts | 13 - .../contexts/spaces/spaces_context.ts | 36 --- .../components/analytics_list/use_columns.tsx | 39 +-- .../components/jobs_list/jobs_list.js | 68 +++--- .../jobs_list_view/jobs_list_view.js | 17 +- .../jobs_list_page/jobs_list_page.tsx | 143 ++++++----- .../application/management/jobs_list/index.ts | 4 +- .../components/selectable_spaces_control.tsx | 11 +- .../components/share_mode_control.tsx | 8 +- .../share_to_space_flyout_internal.tsx | 55 +++-- .../components/share_to_space_form.tsx | 2 +- .../share_saved_objects_to_space_action.tsx | 1 + .../share_saved_objects_to_space/types.ts | 1 + .../translations/translations/ja-JP.json | 13 - .../translations/translations/zh-CN.json | 13 - 23 files changed, 240 insertions(+), 676 deletions(-) delete mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx delete mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts delete mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx delete mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss delete mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx delete mode 100644 x-pack/plugins/ml/public/application/contexts/spaces/index.ts delete mode 100644 x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index a73a68445a391..a6a2e8e639f97 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -37,7 +37,6 @@ "dashboard", "savedObjects", "home", - "spaces", "maps" ], "extraPublicDirs": [ diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts b/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts index cac8f63b6e049..8acec6a45a0c8 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts +++ b/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { JobSpacesList, ALL_SPACES_ID } from './job_spaces_list'; +export { JobSpacesList } from './job_spaces_list'; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx index 2aa7c6bb4a6e3..55984d74260e3 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx +++ b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx @@ -5,64 +5,87 @@ * 2.0. */ -import React, { FC, useState, useEffect } from 'react'; +import React, { FC, useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -import { JobSpacesFlyout } from '../job_spaces_selector'; -import { JobType } from '../../../../common/types/saved_objects'; -import { useSpacesContext } from '../../contexts/spaces'; -import { Space, SpaceAvatar } from '../../../../../spaces/public'; - -export const ALL_SPACES_ID = '*'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { ShareToSpaceFlyoutProps } from 'src/plugins/spaces_oss/public'; +import { + JobType, + ML_SAVED_OBJECT_TYPE, + SavedObjectResult, +} from '../../../../common/types/saved_objects'; +import type { SpacesPluginStart } from '../../../../../spaces/public'; +import { ml } from '../../services/ml_api_service'; +import { useToastNotificationService } from '../../services/toast_notification_service'; interface Props { + spacesApi: SpacesPluginStart; spaceIds: string[]; jobId: string; jobType: JobType; refresh(): void; } -function filterUnknownSpaces(ids: string[]) { - return ids.filter((id) => id !== '?'); -} +const ALL_SPACES_ID = '*'; +const objectNoun = i18n.translate('xpack.ml.management.jobsSpacesList.objectNoun', { + defaultMessage: 'job', +}); -export const JobSpacesList: FC = ({ spaceIds, jobId, jobType, refresh }) => { - const { allSpaces } = useSpacesContext(); +export const JobSpacesList: FC = ({ spacesApi, spaceIds, jobId, jobType, refresh }) => { + const { displayErrorToast } = useToastNotificationService(); const [showFlyout, setShowFlyout] = useState(false); - const [spaces, setSpaces] = useState([]); - useEffect(() => { - const tempSpaces = spaceIds.includes(ALL_SPACES_ID) - ? [{ id: ALL_SPACES_ID, name: ALL_SPACES_ID, disabledFeatures: [], color: '#DDD' }] - : allSpaces.filter((s) => spaceIds.includes(s.id)); - setSpaces(tempSpaces); - }, [spaceIds, allSpaces]); + async function changeSpacesHandler(spacesToAdd: string[], spacesToRemove: string[]) { + if (spacesToAdd.length) { + const resp = await ml.savedObjects.assignJobToSpace(jobType, [jobId], spacesToAdd); + handleApplySpaces(resp); + } + if (spacesToRemove.length && !spacesToAdd.includes(ALL_SPACES_ID)) { + const resp = await ml.savedObjects.removeJobFromSpace(jobType, [jobId], spacesToRemove); + handleApplySpaces(resp); + } + onClose(); + } function onClose() { setShowFlyout(false); refresh(); } + function handleApplySpaces(resp: SavedObjectResult) { + Object.entries(resp).forEach(([id, { success, error }]) => { + if (success === false) { + const title = i18n.translate('xpack.ml.management.jobsSpacesList.updateSpaces.error', { + defaultMessage: 'Error updating {id}', + values: { id }, + }); + displayErrorToast(error, title); + } + }); + } + + const { SpaceList, ShareToSpaceFlyout } = spacesApi.ui.components; + const shareToSpaceFlyoutProps: ShareToSpaceFlyoutProps = { + savedObjectTarget: { + type: ML_SAVED_OBJECT_TYPE, + id: jobId, + namespaces: spaceIds, + title: jobId, + noun: objectNoun, + }, + enableSpaceAgnosticBehavior: true, + changeSpacesHandler, + onClose, + }; + return ( <> setShowFlyout(true)} style={{ height: 'auto' }}> - - {spaces.map((space) => ( - - - - ))} - + - {showFlyout && ( - - )} + {showFlyout && } ); }; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx deleted file mode 100644 index 94ed9ad0d3074..0000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FC } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiSpacer, EuiCallOut } from '@elastic/eui'; - -export const CannotEditCallout: FC<{ jobId: string }> = ({ jobId }) => ( - <> - - - - - -); diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts b/x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts deleted file mode 100644 index da960a20c1538..0000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { JobSpacesFlyout } from './jobs_spaces_flyout'; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx deleted file mode 100644 index 12304cd133d8e..0000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FC, useState, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { difference, xor } from 'lodash'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFlyout, - EuiFlyoutHeader, - EuiFlyoutFooter, - EuiFlexGroup, - EuiFlexItem, - EuiButton, - EuiButtonEmpty, - EuiTitle, - EuiFlyoutBody, -} from '@elastic/eui'; - -import { JobType, SavedObjectResult } from '../../../../common/types/saved_objects'; -import { ml } from '../../services/ml_api_service'; -import { useToastNotificationService } from '../../services/toast_notification_service'; - -import { SpacesSelector } from './spaces_selectors'; - -interface Props { - jobId: string; - jobType: JobType; - spaceIds: string[]; - onClose: () => void; -} -export const JobSpacesFlyout: FC = ({ jobId, jobType, spaceIds, onClose }) => { - const { displayErrorToast } = useToastNotificationService(); - - const [selectedSpaceIds, setSelectedSpaceIds] = useState(spaceIds); - const [saving, setSaving] = useState(false); - const [savable, setSavable] = useState(false); - const [canEditSpaces, setCanEditSpaces] = useState(false); - - useEffect(() => { - const different = xor(selectedSpaceIds, spaceIds).length !== 0; - setSavable(different === true && selectedSpaceIds.length > 0); - }, [selectedSpaceIds.length]); - - async function applySpaces() { - if (savable) { - setSaving(true); - const addedSpaces = difference(selectedSpaceIds, spaceIds); - const removedSpaces = difference(spaceIds, selectedSpaceIds); - if (addedSpaces.length) { - const resp = await ml.savedObjects.assignJobToSpace(jobType, [jobId], addedSpaces); - handleApplySpaces(resp); - } - if (removedSpaces.length) { - const resp = await ml.savedObjects.removeJobFromSpace(jobType, [jobId], removedSpaces); - handleApplySpaces(resp); - } - onClose(); - } - } - - function handleApplySpaces(resp: SavedObjectResult) { - Object.entries(resp).forEach(([id, { success, error }]) => { - if (success === false) { - const title = i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.updateSpaces.error', - { - defaultMessage: 'Error updating {id}', - values: { id }, - } - ); - displayErrorToast(error, title); - } - }); - } - - return ( - <> - - - -

- -

-
-
- - - - - - - - - - - - - - - - - -
- - ); -}; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss b/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss deleted file mode 100644 index 75cdbd972455b..0000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss +++ /dev/null @@ -1,3 +0,0 @@ -.mlCopyToSpace__spacesList { - margin-top: $euiSizeXS; -} diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx deleted file mode 100644 index 281ac5028995b..0000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx +++ /dev/null @@ -1,223 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import './spaces_selector.scss'; -import React, { FC, useState, useEffect, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiFormRow, - EuiSelectable, - EuiSelectableOption, - EuiIconTip, - EuiText, - EuiCheckableCard, - EuiFormFieldset, -} from '@elastic/eui'; - -import { SpaceAvatar } from '../../../../../spaces/public'; -import { useSpacesContext } from '../../contexts/spaces'; -import { ML_SAVED_OBJECT_TYPE } from '../../../../common/types/saved_objects'; -import { ALL_SPACES_ID } from '../job_spaces_list'; -import { CannotEditCallout } from './cannot_edit_callout'; - -type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; - -interface Props { - jobId: string; - spaceIds: string[]; - setSelectedSpaceIds: (ids: string[]) => void; - selectedSpaceIds: string[]; - canEditSpaces: boolean; - setCanEditSpaces: (canEditSpaces: boolean) => void; -} - -export const SpacesSelector: FC = ({ - jobId, - spaceIds, - setSelectedSpaceIds, - selectedSpaceIds, - canEditSpaces, - setCanEditSpaces, -}) => { - const { spacesManager, allSpaces } = useSpacesContext(); - - const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); - - useEffect(() => { - if (spacesManager !== null) { - const getPermissions = spacesManager.getShareSavedObjectPermissions(ML_SAVED_OBJECT_TYPE); - Promise.all([getPermissions]).then(([{ shareToAllSpaces }]) => { - setCanShareToAllSpaces(shareToAllSpaces); - setCanEditSpaces(shareToAllSpaces || spaceIds.includes(ALL_SPACES_ID) === false); - }); - } - }, []); - - function toggleShareOption(isAllSpaces: boolean) { - const updatedSpaceIds = isAllSpaces - ? [ALL_SPACES_ID, ...selectedSpaceIds] - : selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID); - setSelectedSpaceIds(updatedSpaceIds); - } - - function updateSelectedSpaces(selectedOptions: SpaceOption[]) { - const ids = selectedOptions.filter((opt) => opt.checked).map((opt) => opt['data-space-id']); - setSelectedSpaceIds(ids); - } - - const isGlobalControlChecked = useMemo(() => selectedSpaceIds.includes(ALL_SPACES_ID), [ - selectedSpaceIds, - ]); - - const options = useMemo( - () => - allSpaces.map((space) => { - return { - label: space.name, - prepend: , - checked: selectedSpaceIds.includes(space.id) ? 'on' : undefined, - disabled: canEditSpaces === false, - ['data-space-id']: space.id, - ['data-test-subj']: `mlSpaceSelectorRow_${space.id}`, - }; - }), - [allSpaces, selectedSpaceIds, canEditSpaces] - ); - - const shareToAllSpaces = useMemo( - () => ({ - id: 'shareToAllSpaces', - title: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.title', { - defaultMessage: 'All spaces', - }), - text: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.text', { - defaultMessage: 'Make job available in all current and future spaces.', - }), - ...(!canShareToAllSpaces && { - tooltip: isGlobalControlChecked - ? i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotUncheckTooltip', - { defaultMessage: 'You need additional privileges to change this option.' } - ) - : i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotCheckTooltip', - { defaultMessage: 'You need additional privileges to use this option.' } - ), - }), - disabled: !canShareToAllSpaces, - }), - [isGlobalControlChecked, canShareToAllSpaces] - ); - - const shareToExplicitSpaces = useMemo( - () => ({ - id: 'shareToExplicitSpaces', - title: i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.title', - { - defaultMessage: 'Select spaces', - } - ), - text: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.text', { - defaultMessage: 'Make job available in selected spaces only.', - }), - disabled: !canShareToAllSpaces && isGlobalControlChecked, - }), - [canShareToAllSpaces, isGlobalControlChecked] - ); - - return ( - <> - {canEditSpaces === false && } - - toggleShareOption(false)} - disabled={shareToExplicitSpaces.disabled} - > - - } - fullWidth - > - updateSelectedSpaces(newOptions as SpaceOption[])} - listProps={{ - bordered: true, - rowHeight: 40, - className: 'mlCopyToSpace__spacesList', - 'data-test-subj': 'mlFormSpaceSelector', - }} - searchable - > - {(list, search) => { - return ( - <> - {search} - {list} - - ); - }} - - - - - - - toggleShareOption(true)} - disabled={shareToAllSpaces.disabled} - /> - - - ); -}; - -function createLabel({ - title, - text, - disabled, - tooltip, -}: { - title: string; - text: string; - disabled: boolean; - tooltip?: string; -}) { - return ( - <> - - - {title} - - {tooltip && ( - - - - )} - - - - {text} - - - ); -} diff --git a/x-pack/plugins/ml/public/application/contexts/spaces/index.ts b/x-pack/plugins/ml/public/application/contexts/spaces/index.ts deleted file mode 100644 index 7b87bab8057e9..0000000000000 --- a/x-pack/plugins/ml/public/application/contexts/spaces/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { - SpacesContext, - SpacesContextValue, - createSpacesContext, - useSpacesContext, -} from './spaces_context'; diff --git a/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts b/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts deleted file mode 100644 index dca7d0989d4de..0000000000000 --- a/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { createContext, useContext } from 'react'; -import { HttpSetup } from 'src/core/public'; -import { SpacesManager, Space } from '../../../../../spaces/public'; - -export interface SpacesContextValue { - spacesManager: SpacesManager | null; - allSpaces: Space[]; - spacesEnabled: boolean; -} - -export const SpacesContext = createContext>({}); - -export function createSpacesContext(http: HttpSetup, spacesEnabled: boolean) { - return { - spacesManager: spacesEnabled ? new SpacesManager(http) : null, - allSpaces: [], - spacesEnabled, - } as SpacesContextValue; -} - -export function useSpacesContext() { - const context = useContext(SpacesContext); - - if (context.spacesManager === undefined) { - throw new Error('required attribute is undefined'); - } - - return context as SpacesContextValue; -} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index 7a0f00fd377bf..e2e8c497ef05b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -33,7 +33,7 @@ import { import { useActions } from './use_actions'; import { useMlLink } from '../../../../../contexts/kibana'; import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator'; -import { JobSpacesList } from '../../../../../components/job_spaces_list'; +// import { JobSpacesList } from '../../../../../components/job_spaces_list'; enum TASK_STATE_COLOR { analyzing = 'primary', @@ -281,24 +281,25 @@ export const useColumns = ( ]; if (isManagementTable === true) { - if (spacesEnabled === true) { - // insert before last column - columns.splice(columns.length - 1, 0, { - name: i18n.translate('xpack.ml.jobsList.analyticsSpacesLabel', { - defaultMessage: 'Spaces', - }), - render: (item: DataFrameAnalyticsListRow) => - Array.isArray(item.spaceIds) ? ( - - ) : null, - width: '90px', - }); - } + // Note: this code path is commented because it is currently unreachable, it will need to be refactored to use the SpacesApi + // if (spacesEnabled === true) { + // // insert before last column + // columns.splice(columns.length - 1, 0, { + // name: i18n.translate('xpack.ml.jobsList.analyticsSpacesLabel', { + // defaultMessage: 'Spaces', + // }), + // render: (item: DataFrameAnalyticsListRow) => + // Array.isArray(item.spaceIds) ? ( + // + // ) : null, + // width: '90px', + // }); + // } // Remove actions if Ml not enabled in current space if (isMlEnabledInSpace === false) { columns.pop(); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index 59908293d8929..6ef07b6d8952f 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -6,7 +6,7 @@ */ import PropTypes from 'prop-types'; -import React, { Component } from 'react'; +import React, { createElement, Component } from 'react'; import { sortBy } from 'lodash'; import moment from 'moment'; @@ -16,6 +16,7 @@ import { ResultLinks, actionsMenuContent } from '../job_actions'; import { JobDescription } from './job_description'; import { JobIcon } from '../../../../components/job_message_icon'; import { JobSpacesList } from '../../../../components/job_spaces_list'; +import { PLUGIN_ID } from '../../../../../../common/constants/app'; import { TIME_FORMAT } from '../../../../../../common/constants/time_format'; import { EuiBasicTable, EuiButtonIcon, EuiScreenReaderOnly } from '@elastic/eui'; @@ -25,6 +26,9 @@ import { AnomalyDetectionJobIdLink } from './job_id_link'; const PAGE_SIZE_OPTIONS = [10, 25, 50]; +const EmptyFunctionComponent = ({ children }) => + createElement('EmptyFunctionComponent', { children }); + // 'isManagementTable' bool prop to determine when to configure table for use in Kibana management page export class JobsList extends Component { constructor(props) { @@ -96,7 +100,7 @@ export class JobsList extends Component { } render() { - const { loading, isManagementTable, spacesEnabled } = this.props; + const { loading, isManagementTable, spacesApi } = this.props; const selectionControls = { selectable: (job) => job.deleting !== true, selectableMessage: (selectable, rowItem) => @@ -243,7 +247,7 @@ export class JobsList extends Component { ]; if (isManagementTable === true) { - if (spacesEnabled === true) { + if (spacesApi) { // insert before last column columns.splice(columns.length - 1, 0, { name: i18n.translate('xpack.ml.jobsList.spacesLabel', { @@ -251,6 +255,7 @@ export class JobsList extends Component { }), render: (item) => ( ({ - 'data-test-subj': `mlJobListRow row-${item.id}`, - })} - /> + + ({ + 'data-test-subj': `mlJobListRow row-${item.id}`, + })} + /> + ); } } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index 4dec364ab042c..352bd839ba1f4 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -61,7 +61,6 @@ export class JobsListView extends Component { jobsAwaitingNodeCount: 0, }; - this.spacesEnabled = props.spacesEnabled ?? false; this.updateFunctions = {}; this.showEditJobFlyout = () => {}; @@ -269,10 +268,10 @@ export class JobsListView extends Component { const expandedJobsIds = Object.keys(this.state.itemIdToExpandedRowMap); try { - let spaces = {}; - if (this.props.spacesEnabled && this.props.isManagementTable) { + let jobsSpaces = {}; + if (this.props.spacesApi && this.props.isManagementTable) { const allSpaces = await ml.savedObjects.jobsSpaces(); - spaces = allSpaces['anomaly-detector']; + jobsSpaces = allSpaces['anomaly-detector']; } let jobsAwaitingNodeCount = 0; @@ -285,11 +284,11 @@ export class JobsListView extends Component { } job.latestTimestampSortValue = job.latestTimestampMs || 0; job.spaceIds = - this.props.spacesEnabled && + this.props.spacesApi && this.props.isManagementTable && - spaces && - spaces[job.id] !== undefined - ? spaces[job.id] + jobsSpaces && + jobsSpaces[job.id] !== undefined + ? jobsSpaces[job.id] : []; if (job.awaitingNodeAssignment === true) { @@ -410,7 +409,7 @@ export class JobsListView extends Component { loading={loading} isManagementTable={true} isMlEnabledInSpace={this.props.isMlEnabledInSpace} - spacesEnabled={this.props.spacesEnabled} + spacesApi={this.props.spacesApi} jobsViewState={this.props.jobsViewState} onJobsViewStateUpdate={this.props.onJobsViewStateUpdate} refreshJobs={() => this.refreshJobSummaryList(true)} diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index a322174e8a8c4..5bf488c2721f0 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -23,8 +23,6 @@ import { EuiTabbedContentTab, } from '@elastic/eui'; -import { PLUGIN_ID } from '../../../../../../common/constants/app'; -import { createSpacesContext, SpacesContext } from '../../../../contexts/spaces'; import { ManagementAppMountParams } from '../../../../../../../../../src/plugins/management/public/'; import { checkGetManagementMlJobsResolver } from '../../../../capabilities/check_capabilities'; @@ -39,7 +37,7 @@ import { JobsListView } from '../../../../jobs/jobs_list/components/jobs_list_vi import { DataFrameAnalyticsList } from '../../../../data_frame_analytics/pages/analytics_management/components/analytics_list'; import { AccessDeniedPage } from '../access_denied_page'; import { SharePluginStart } from '../../../../../../../../../src/plugins/share/public'; -import { SpacesPluginStart } from '../../../../../../../spaces/public'; +import type { SpacesPluginStart } from '../../../../../../../spaces/public'; import { JobSpacesSyncFlyout } from '../../../../components/job_spaces_sync'; import { getDefaultAnomalyDetectionJobsListState } from '../../../../jobs/jobs_list/jobs'; import { getMlGlobalServices } from '../../../../app'; @@ -68,7 +66,7 @@ function usePageState( return [pageState, updateState]; } -function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] { +function useTabs(isMlEnabledInSpace: boolean, spacesApi: SpacesPluginStart | undefined): Tab[] { const [adPageState, updateAdPageState] = usePageState(getDefaultAnomalyDetectionJobsListState()); const [dfaPageState, updateDfaPageState] = usePageState(getDefaultDFAListState()); @@ -88,7 +86,7 @@ function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] { onJobsViewStateUpdate={updateAdPageState} isManagementTable={true} isMlEnabledInSpace={isMlEnabledInSpace} - spacesEnabled={spacesEnabled} + spacesApi={spacesApi} /> ), @@ -105,7 +103,7 @@ function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] { @@ -121,28 +119,21 @@ export const JobsListPage: FC<{ coreStart: CoreStart; share: SharePluginStart; history: ManagementAppMountParams['history']; - spaces?: SpacesPluginStart; -}> = ({ coreStart, share, history, spaces }) => { - const spacesEnabled = spaces !== undefined; + spacesApi?: SpacesPluginStart; +}> = ({ coreStart, share, history, spacesApi }) => { + const spacesEnabled = spacesApi !== undefined; const [initialized, setInitialized] = useState(false); const [accessDenied, setAccessDenied] = useState(false); const [showSyncFlyout, setShowSyncFlyout] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); - const tabs = useTabs(isMlEnabledInSpace, spacesEnabled); + const tabs = useTabs(isMlEnabledInSpace, spacesApi); const [currentTabId, setCurrentTabId] = useState(tabs[0].id); const I18nContext = coreStart.i18n.Context; - const spacesContext = useMemo(() => createSpacesContext(coreStart.http, spacesEnabled), []); const check = async () => { try { const { mlFeatureEnabledInSpace } = await checkGetManagementMlJobsResolver(); setIsMlEnabledInSpace(mlFeatureEnabledInSpace); - spacesContext.spacesEnabled = spacesEnabled; - if (spacesEnabled && spacesContext.spacesManager !== null) { - spacesContext.allSpaces = (await spacesContext.spacesManager.getSpaces()).filter( - (space) => space.disabledFeatures.includes(PLUGIN_ID) === false - ); - } } catch (e) { setAccessDenied(true); } @@ -197,66 +188,64 @@ export const JobsListPage: FC<{ - - - - - - -

- {i18n.translate('xpack.ml.management.jobsList.jobsListTitle', { - defaultMessage: 'Machine Learning Jobs', - })} -

-
- - - {currentTabId === 'anomaly_detection_jobs' - ? anomalyDetectionDocsLabel - : analyticsDocsLabel} - - -
-
- - - - {i18n.translate('xpack.ml.management.jobsList.jobsListTagline', { - defaultMessage: 'View machine learning analytics and anomaly detection jobs.', - })} - - - - - {spacesEnabled && ( - <> - setShowSyncFlyout(true)}> - {i18n.translate('xpack.ml.management.jobsList.syncFlyoutButton', { - defaultMessage: 'Synchronize saved objects', - })} - - {showSyncFlyout && } - - - )} - {renderTabs()} - -
-
-
+ + + + + +

+ {i18n.translate('xpack.ml.management.jobsList.jobsListTitle', { + defaultMessage: 'Machine Learning Jobs', + })} +

+
+ + + {currentTabId === 'anomaly_detection_jobs' + ? anomalyDetectionDocsLabel + : analyticsDocsLabel} + + +
+
+ + + + {i18n.translate('xpack.ml.management.jobsList.jobsListTagline', { + defaultMessage: 'View machine learning analytics and anomaly detection jobs.', + })} + + + + + {spacesEnabled && ( + <> + setShowSyncFlyout(true)}> + {i18n.translate('xpack.ml.management.jobsList.syncFlyoutButton', { + defaultMessage: 'Synchronize saved objects', + })} + + {showSyncFlyout && } + + + )} + {renderTabs()} + +
+
diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts index 4059207aafcc3..dde543ac6ac9c 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts +++ b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts @@ -22,10 +22,10 @@ const renderApp = ( history: ManagementAppMountParams['history'], coreStart: CoreStart, share: SharePluginStart, - spaces?: SpacesPluginStart + spacesApi?: SpacesPluginStart ) => { ReactDOM.render( - React.createElement(JobsListPage, { coreStart, history, share, spaces }), + React.createElement(JobsListPage, { coreStart, history, share, spacesApi }), element ); return () => { diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index 94fa17896f3fa..69cd64b56b813 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -27,10 +27,11 @@ import { DocumentationLinksService } from '../../lib'; import { SpaceAvatar } from '../../space_avatar'; import { SpaceData } from '../../types'; import { useSpaces } from '../../spaces_context'; +import { ShareOptions } from '../types'; interface Props { spaces: SpaceData[]; - selectedSpaceIds: string[]; + shareOptions: ShareOptions; onChange: (selectedSpaceIds: string[]) => void; enableCreateNewSpaceLink: boolean; enableSpaceAgnosticBehavior: boolean; @@ -75,19 +76,23 @@ const APPEND_FEATURE_IS_DISABLED = ( export const SelectableSpacesControl = (props: Props) => { const { spaces, - selectedSpaceIds, + shareOptions, onChange, enableCreateNewSpaceLink, enableSpaceAgnosticBehavior, } = props; const { services } = useSpaces(); const { application, docLinks } = services; + const { selectedSpaceIds, initiallySelectedSpaceIds } = shareOptions; const activeSpaceId = !enableSpaceAgnosticBehavior && spaces.find((space) => space.isActiveSpace)!.id; const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); const options = spaces - .filter(({ id, isFeatureDisabled }) => !isFeatureDisabled || selectedSpaceIds.includes(id)) // filter out spaces that are not already selected and have the feature disabled in that space + .filter( + // filter out spaces that are not already selected and have the feature disabled in that space + ({ id, isFeatureDisabled }) => !isFeatureDisabled || initiallySelectedSpaceIds.includes(id) + ) .sort(createSpacesComparator(activeSpaceId)) .map((space) => { const checked = selectedSpaceIds.includes(space.id); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx index c523da0df693f..5aa90ecba6e95 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx @@ -22,12 +22,13 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { ALL_SPACES_ID } from '../../../common/constants'; import { SpaceData } from '../../types'; +import { ShareOptions } from '../types'; interface Props { spaces: SpaceData[]; objectNoun: string; canShareToAllSpaces: boolean; - selectedSpaceIds: string[]; + shareOptions: ShareOptions; onChange: (selectedSpaceIds: string[]) => void; enableCreateNewSpaceLink: boolean; enableSpaceAgnosticBehavior: boolean; @@ -69,7 +70,7 @@ export const ShareModeControl = (props: Props) => { spaces, objectNoun, canShareToAllSpaces, - selectedSpaceIds, + shareOptions, onChange, enableCreateNewSpaceLink, enableSpaceAgnosticBehavior, @@ -79,6 +80,7 @@ export const ShareModeControl = (props: Props) => { return ; } + const { selectedSpaceIds } = shareOptions; const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); const shareToAllSpaces = { id: 'shareToAllSpaces', @@ -172,7 +174,7 @@ export const ShareModeControl = (props: Props) => { > a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); function createDefaultChangeSpacesHandler( - object: Required, + object: Required>, spacesManager: SpacesManager, toastNotifications: ToastsStart ) { @@ -104,14 +102,14 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { type: object.type, id: object.id, namespaces: object.namespaces, - icon: object.icon || DEFAULT_OBJECT_ICON, + icon: object.icon, title: object.title || `${object.type} [id=${object.id}]`, noun: object.noun || DEFAULT_OBJECT_NOUN, }), [object] ); const { - flyoutIcon = DEFAULT_FLYOUT_ICON, + flyoutIcon, flyoutTitle = i18n.translate('xpack.spaces.management.shareToSpace.flyoutTitle', { defaultMessage: 'Edit spaces for {objectNoun}', values: { objectNoun: savedObjectTarget.noun }, @@ -128,7 +126,10 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { onClose = () => null, } = props; - const [shareOptions, setShareOptions] = useState({ selectedSpaceIds: [] }); + const [shareOptions, setShareOptions] = useState({ + selectedSpaceIds: [], + initiallySelectedSpaceIds: [], + }); const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); const [showMakeCopy, setShowMakeCopy] = useState(false); @@ -141,10 +142,12 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { Promise.all([spacesDataPromise, getPermissions]) .then(([spacesData, permissions]) => { const activeSpaceId = !enableSpaceAgnosticBehavior && spacesData.activeSpaceId; + const selectedSpaceIds = savedObjectTarget.namespaces.filter( + (spaceId) => spaceId !== activeSpaceId + ); setShareOptions({ - selectedSpaceIds: savedObjectTarget.namespaces.filter( - (spaceId) => spaceId !== activeSpaceId - ), + selectedSpaceIds, + initiallySelectedSpaceIds: selectedSpaceIds, }); setCanShareToAllSpaces(permissions.shareToAllSpaces); setSpacesState({ @@ -168,12 +171,13 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { ]); const getSelectionChanges = () => { - const activeSpace = spaces.find((space) => space.isActiveSpace); - if (!activeSpace && !enableSpaceAgnosticBehavior) { + if (!spaces.length) { return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] }; } + const activeSpaceId = + !enableSpaceAgnosticBehavior && spaces.find((space) => space.isActiveSpace)!.id; const initialSelection = savedObjectTarget.namespaces.filter( - (spaceId) => spaceId !== activeSpace?.id && spaceId !== UNKNOWN_SPACE + (spaceId) => spaceId !== activeSpaceId && spaceId !== UNKNOWN_SPACE ); const { selectedSpaceIds } = shareOptions; const filteredSelection = selectedSpaceIds.filter((x) => x !== UNKNOWN_SPACE); @@ -196,17 +200,16 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { (spaceId) => !filteredSelection.includes(spaceId) ); - const spacesArray = activeSpace ? [activeSpace.id] : []; // if we have an active space, it is automatically selected + const spacesArray = activeSpaceId ? [activeSpaceId] : []; // if we have an active space, it is automatically selected const spacesToAdd = isSharedToAllSpaces ? [ALL_SPACES_ID] : isUnsharedFromAllSpaces ? [...spacesArray, ...selectedSpacesToAdd] : selectedSpacesToAdd; - const spacesToRemove = isUnsharedFromAllSpaces - ? [ALL_SPACES_ID] - : isSharedToAllSpaces - ? [...spacesArray, ...initialSelection] - : selectedSpacesToRemove; + const spacesToRemove = + isUnsharedFromAllSpaces || !isSharedToAllSpaces + ? selectedSpacesToRemove + : [...spacesArray, ...initialSelection]; return { isSelectionChanged, spacesToAdd, spacesToRemove }; }; const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); @@ -277,9 +280,11 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { - - - + {flyoutIcon && ( + + + + )}

{flyoutTitle}

@@ -289,9 +294,11 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
- - - + {savedObjectTarget.icon && ( + + + + )}

{savedObjectTarget.title}

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 index d8303670756e0..ff5ef87e2f155 100644 --- 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 @@ -88,7 +88,7 @@ export const ShareToSpaceForm = (props: Props) => { spaces={spaces} objectNoun={objectNoun} canShareToAllSpaces={canShareToAllSpaces} - selectedSpaceIds={shareOptions.selectedSpaceIds} + shareOptions={shareOptions} onChange={(selection) => setSelectedSpaceIds(selection)} enableCreateNewSpaceLink={enableCreateNewSpaceLink} enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior} 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 index 67738ac8f7384..9727b9cf2a793 100644 --- 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 @@ -61,6 +61,7 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage return ( (this.isDataChanged = true)} onClose={this.onClose} enableCreateCopyCallout={true} 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 index b1e55ff16bafa..fda561d8c4af1 100644 --- 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 @@ -9,6 +9,7 @@ import { SavedObjectsImportRetry, SavedObjectsImportResponse } from 'src/core/pu export interface ShareOptions { selectedSpaceIds: string[]; + initiallySelectedSpaceIds: string[]; } export type ImportRetry = Omit; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 63e9a66b898bc..696ea696cf98f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13518,19 +13518,6 @@ "xpack.ml.management.jobsList.noPermissionToAccessLabel": "ML ジョブへのアクセスにはパーミッションが必要です", "xpack.ml.management.jobsList.syncFlyoutButton": "保存されたオブジェクトを同期", "xpack.ml.management.jobsListTitle": "機械学習ジョブ", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.text": "このジョブのスペースを変更するには、すべてのスペースでジョブを修正する権限が必要です。詳細については、システム管理者に連絡してください。", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.title": "{jobId} のスペースを編集する権限が不十分です", - "xpack.ml.management.spacesSelectorFlyout.closeButton": "閉じる", - "xpack.ml.management.spacesSelectorFlyout.headerLabel": "{jobId} のスペースを選択", - "xpack.ml.management.spacesSelectorFlyout.saveButton": "保存", - "xpack.ml.management.spacesSelectorFlyout.selectSpacesLabel": "スペースを選択", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotCheckTooltip": "このオプションを使用するには、追加権限が必要です。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotUncheckTooltip": "このオプションを変更するには、追加権限が必要です。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.text": "現在と将来のすべてのスペースでジョブを使用可能にします。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.title": "すべてのスペース", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.text": "選択したスペースでのみジョブを使用可能にします。", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.title": "スペースを選択", - "xpack.ml.management.spacesSelectorFlyout.updateSpaces.error": "{id} の更新エラー", "xpack.ml.management.syncSavedObjectsFlyout.closeButton": "閉じる", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.description": "異常検知のデータフィード ID がない保存されたオブジェクトがある場合は、ID が追加されます。", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.title": "データフィードがない選択されたオブジェクト({count})", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8bc9236c82402..8f4261f47426c 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13551,19 +13551,6 @@ "xpack.ml.management.jobsList.noPermissionToAccessLabel": "您需要访问 ML 作业的权限", "xpack.ml.management.jobsList.syncFlyoutButton": "同步已保存对象", "xpack.ml.management.jobsListTitle": "Machine Learning", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.text": "要更改此作业的工作区,您需要有在所有工作区中修改作业的权限。请与您的系统管理员联系,以获取更多信息。", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.title": "权限不足,无法编辑 {jobId} 的工作区", - "xpack.ml.management.spacesSelectorFlyout.closeButton": "关闭", - "xpack.ml.management.spacesSelectorFlyout.headerLabel": "为 {jobId} 选择工作区", - "xpack.ml.management.spacesSelectorFlyout.saveButton": "保存", - "xpack.ml.management.spacesSelectorFlyout.selectSpacesLabel": "选择工作区", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotCheckTooltip": "您还需要其他权限,才能使用此选项。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotUncheckTooltip": "您还需要其他权限,才能更改此选项。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.text": "使作业在所有当前和将来工作区中可用。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.title": "所有工作区", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.text": "使作业仅在选定工作区中可用。", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.title": "选择工作区", - "xpack.ml.management.spacesSelectorFlyout.updateSpaces.error": "更新 {id} 时出错", "xpack.ml.management.syncSavedObjectsFlyout.closeButton": "关闭", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.description": "如果有已保存对象缺失异常检测作业的数据馈送 ID,则将添加该 ID。", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.title": "缺失数据馈送的已保存对象 ({count})", From 6ce48ae1849d1ca6f0d08d59d4c67535653237b7 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 9 Feb 2021 00:30:47 -0500 Subject: [PATCH 13/29] Fix i18n --- x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 2 files changed, 2 deletions(-) diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 696ea696cf98f..7471b3b77a232 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13315,7 +13315,6 @@ "xpack.ml.jobsList.actionExecuteSuccessfullyNotificationMessage": "{successesJobsCount, plural, one{{successJob}} other{# 件のジョブ}} {actionTextPT}成功", "xpack.ml.jobsList.actionFailedNotificationMessage": "{failureId} が {actionText} に失敗しました", "xpack.ml.jobsList.actionsLabel": "アクション", - "xpack.ml.jobsList.analyticsSpacesLabel": "スペース", "xpack.ml.jobsList.auditMessageColumn.screenReaderDescription": "このカラムは、過去24時間にエラーまたは警告があった場合にアイコンを表示します", "xpack.ml.jobsList.breadcrumb": "ジョブ", "xpack.ml.jobsList.cannotSelectRowForJobMessage": "ジョブID {jobId}を選択できません", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8f4261f47426c..d2804fa2f5665 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13348,7 +13348,6 @@ "xpack.ml.jobsList.actionExecuteSuccessfullyNotificationMessage": "{successesJobsCount, plural, one{{successJob}} other{# 个作业}}{actionTextPT}已成功", "xpack.ml.jobsList.actionFailedNotificationMessage": "{failureId} 未能{actionText}", "xpack.ml.jobsList.actionsLabel": "操作", - "xpack.ml.jobsList.analyticsSpacesLabel": "工作区", "xpack.ml.jobsList.auditMessageColumn.screenReaderDescription": "过去 24 小时里该作业有错误或警告时,此列显示图标", "xpack.ml.jobsList.breadcrumb": "作业", "xpack.ml.jobsList.cannotSelectRowForJobMessage": "无法选择作业 ID {jobId}", From 9d1e02e0672f5d44914eab3f6ede46cc8ef9ddd4 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 9 Feb 2021 09:46:04 -0500 Subject: [PATCH 14/29] PR review feedback --- .../public/management_section/mount_section.tsx | 2 +- .../management_section/saved_objects_table_page.tsx | 10 ++++++---- .../jobs/jobs_list/components/jobs_list/jobs_list.js | 5 ++--- 3 files changed, 9 insertions(+), 8 deletions(-) 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 37ff79719c870..b855850ed185d 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 @@ -61,7 +61,7 @@ export const mountManagementSection = async ({ return children! as React.ReactElement; }; - const spacesApi = (spacesOss?.isSpacesAvailable && spacesOss) || undefined; + const spacesApi = spacesOss?.isSpacesAvailable ? spacesOss : undefined; ReactDOM.render( diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx index ed7233bceea38..c5ae2127ac030 100644 --- a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx +++ b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { createElement, useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { get } from 'lodash'; import { Query } from '@elastic/eui'; @@ -23,8 +23,7 @@ import { } from '../services'; import { SavedObjectsTable } from './objects_table'; -const EmptyFunctionComponent: React.FC = ({ children }) => - createElement('EmptyFunctionComponent', { children }); +const EmptyFunctionComponent: React.FC = ({ children }) => <>{children}; const SavedObjectsTablePage = ({ coreStart, @@ -71,7 +70,10 @@ const SavedObjectsTablePage = ({ ]); }, [setBreadcrumbs]); - const ContextWrapper = spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent; + const ContextWrapper = useMemo( + () => spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent, + [spacesApi] + ); return ( diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index 6ef07b6d8952f..76690131c6883 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -6,7 +6,7 @@ */ import PropTypes from 'prop-types'; -import React, { createElement, Component } from 'react'; +import React, { Component } from 'react'; import { sortBy } from 'lodash'; import moment from 'moment'; @@ -26,8 +26,7 @@ import { AnomalyDetectionJobIdLink } from './job_id_link'; const PAGE_SIZE_OPTIONS = [10, 25, 50]; -const EmptyFunctionComponent = ({ children }) => - createElement('EmptyFunctionComponent', { children }); +const EmptyFunctionComponent = ({ children }) => <>{children}; // 'isManagementTable' bool prop to determine when to configure table for use in Kibana management page export class JobsList extends Component { From 0bed8ab84c1e00c497bf56d6fca858de25cfea3e Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 9 Feb 2021 10:07:47 -0500 Subject: [PATCH 15/29] Remove unnecessary Spaces exports --- x-pack/plugins/spaces/public/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/x-pack/plugins/spaces/public/index.ts b/x-pack/plugins/spaces/public/index.ts index a87b953f08c62..3620ae757052d 100644 --- a/x-pack/plugins/spaces/public/index.ts +++ b/x-pack/plugins/spaces/public/index.ts @@ -11,9 +11,7 @@ export { SpaceAvatar, getSpaceColor, getSpaceImageUrl, getSpaceInitials } from ' export { SpacesPluginSetup, SpacesPluginStart } from './plugin'; -export { SpacesManager } from './spaces_manager'; - -export { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult } from '../common'; +export type { GetAllSpacesPurpose, GetSpaceResult } from '../common'; // re-export types from oss definition export type { Space } from '../../../../src/plugins/spaces_oss/common'; From e7ac5d48c47c0a14ee20e3ee828e4b102b64eb72 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 9 Feb 2021 19:14:35 -0500 Subject: [PATCH 16/29] More PR review feedback --- .../components/selectable_spaces_control.tsx | 16 +++--- .../components/share_mode_control.tsx | 4 +- .../share_to_space_flyout_internal.tsx | 51 ++++++++++--------- .../components/share_to_space_form.tsx | 4 +- .../public/space_list/space_list_internal.tsx | 23 +++++---- .../spaces/public/spaces_context/context.tsx | 6 +-- .../spaces/public/spaces_context/types.ts | 4 +- .../spaces/public/spaces_context/wrapper.tsx | 21 ++++---- x-pack/plugins/spaces/public/types.ts | 21 +++++--- 9 files changed, 86 insertions(+), 64 deletions(-) diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index 69cd64b56b813..d402dc06a08d2 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -25,12 +25,12 @@ import { NoSpacesAvailable } from './no_spaces_available'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; import { DocumentationLinksService } from '../../lib'; import { SpaceAvatar } from '../../space_avatar'; -import { SpaceData } from '../../types'; +import { ShareToSpaceTarget } from '../../types'; import { useSpaces } from '../../spaces_context'; import { ShareOptions } from '../types'; interface Props { - spaces: SpaceData[]; + spaces: ShareToSpaceTarget[]; shareOptions: ShareOptions; onChange: (selectedSpaceIds: string[]) => void; enableCreateNewSpaceLink: boolean; @@ -222,14 +222,18 @@ export const SelectableSpacesControl = (props: Props) => { /** * Gets additional props for the selection option. */ -function getAdditionalProps(space: SpaceData, activeSpaceId: string | false, checked: boolean) { +function getAdditionalProps( + space: ShareToSpaceTarget, + activeSpaceId: string | false, + checked: boolean +) { if (space.id === activeSpaceId) { return { append: APPEND_ACTIVE_SPACE, disabled: true, checked: 'on' as 'on', }; - } else if (space.isPartiallyAuthorized) { + } else if (space.cannotShareToSpace) { return { append: ( <> @@ -247,11 +251,11 @@ function getAdditionalProps(space: SpaceData, activeSpaceId: string | false, che } /** - * Given the active space, create a comparator to sort a SpaceData array so that the active space is at the beginning, and space(s) for + * Given the active space, create a comparator to sort a ShareToSpaceTarget array so that the active space is at the beginning, and space(s) for * which the current feature is disabled are all at the end. */ function createSpacesComparator(activeSpaceId: string | false) { - return (a: SpaceData, b: SpaceData) => { + return (a: ShareToSpaceTarget, b: ShareToSpaceTarget) => { if (a.id === activeSpaceId) { return -1; } else if (b.id === activeSpaceId) { diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx index 5aa90ecba6e95..855688ca0aa7a 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx @@ -21,11 +21,11 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { ALL_SPACES_ID } from '../../../common/constants'; -import { SpaceData } from '../../types'; +import { ShareToSpaceTarget } from '../../types'; import { ShareOptions } from '../types'; interface Props { - spaces: SpaceData[]; + spaces: ShareToSpaceTarget[]; objectNoun: string; canShareToAllSpaces: boolean; shareOptions: ShareOptions; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx index 8fa6d7edcc45a..378dd8ad12a03 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -30,15 +30,18 @@ import type { } from 'src/plugins/spaces_oss/public'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; import { SpacesManager } from '../../spaces_manager'; -import { SpaceData } from '../../types'; +import { ShareToSpaceTarget } from '../../types'; import { ShareToSpaceForm } from './share_to_space_form'; import { ShareOptions } from '../types'; import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; import { useSpaces } from '../../spaces_context'; -const DEFAULT_OBJECT_NOUN = i18n.translate('xpack.spaces.management.shareToSpace.objectNoun', { +const DEFAULT_OBJECT_NOUN = i18n.translate('xpack.spaces.shareToSpace.objectNoun', { defaultMessage: 'object', }); +const ALL_SPACES_TARGET = i18n.translate('xpack.spaces.shareToSpace.allSpacesTarget', { + defaultMessage: 'all', +}); const arraysAreEqual = (a: unknown[], b: unknown[]) => a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); @@ -50,14 +53,14 @@ function createDefaultChangeSpacesHandler( ) { return async (spacesToAdd: string[], spacesToRemove: string[]) => { const { type, id, title } = object; - const toastTitle = i18n.translate('xpack.spaces.management.shareToSpace.shareSuccessTitle', { + const toastTitle = i18n.translate('xpack.spaces.shareToSpace.shareSuccessTitle', { values: { objectNoun: object.noun }, defaultMessage: 'Updated {objectNoun}', }); const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID); if (spacesToAdd.length > 0) { await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); - const spaceTargets = isSharedToAllSpaces ? 'all' : `${spacesToAdd.length}`; + const spaceTargets = isSharedToAllSpaces ? ALL_SPACES_TARGET : `${spacesToAdd.length}`; const toastText = !isSharedToAllSpaces && spacesToAdd.length === 1 ? i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular', { @@ -73,7 +76,7 @@ function createDefaultChangeSpacesHandler( if (spacesToRemove.length > 0) { await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); const isUnsharedFromAllSpaces = spacesToRemove.includes(ALL_SPACES_ID); - const spaceTargets = isUnsharedFromAllSpaces ? 'all' : `${spacesToRemove.length}`; + const spaceTargets = isUnsharedFromAllSpaces ? ALL_SPACES_TARGET : `${spacesToRemove.length}`; const toastText = !isUnsharedFromAllSpaces && spacesToRemove.length === 1 ? i18n.translate('xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular', { @@ -92,7 +95,7 @@ function createDefaultChangeSpacesHandler( } export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { - const { spacesManager, spacesDataPromise, services } = useSpaces(); + const { spacesManager, shareToSpacesDataPromise, services } = useSpaces(); const { notifications } = services; const toastNotifications = notifications!.toasts; @@ -110,7 +113,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { ); const { flyoutIcon, - flyoutTitle = i18n.translate('xpack.spaces.management.shareToSpace.flyoutTitle', { + flyoutTitle = i18n.translate('xpack.spaces.shareToSpace.flyoutTitle', { defaultMessage: 'Edit spaces for {objectNoun}', values: { objectNoun: savedObjectTarget.noun }, }), @@ -135,13 +138,13 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { const [{ isLoading, spaces }, setSpacesState] = useState<{ isLoading: boolean; - spaces: SpaceData[]; + spaces: ShareToSpaceTarget[]; }>({ isLoading: true, spaces: [] }); useEffect(() => { const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObjectTarget.type); - Promise.all([spacesDataPromise, getPermissions]) - .then(([spacesData, permissions]) => { - const activeSpaceId = !enableSpaceAgnosticBehavior && spacesData.activeSpaceId; + Promise.all([shareToSpacesDataPromise, getPermissions]) + .then(([shareToSpacesData, permissions]) => { + const activeSpaceId = !enableSpaceAgnosticBehavior && shareToSpacesData.activeSpaceId; const selectedSpaceIds = savedObjectTarget.namespaces.filter( (spaceId) => spaceId !== activeSpaceId ); @@ -152,7 +155,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { setCanShareToAllSpaces(permissions.shareToAllSpaces); setSpacesState({ isLoading: false, - spaces: [...spacesData.spacesMap].map(([, spaceTarget]) => spaceTarget), + spaces: [...shareToSpacesData.spacesMap].map(([, spaceTarget]) => spaceTarget), }); }) .catch((e) => { @@ -165,7 +168,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { }, [ savedObjectTarget, spacesManager, - spacesDataPromise, + shareToSpacesDataPromise, toastNotifications, enableSpaceAgnosticBehavior, ]); @@ -181,13 +184,15 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { ); const { selectedSpaceIds } = shareOptions; const filteredSelection = selectedSpaceIds.filter((x) => x !== UNKNOWN_SPACE); - const isSharedToAllSpaces = - !initialSelection.includes(ALL_SPACES_ID) && filteredSelection.includes(ALL_SPACES_ID); - const isUnsharedFromAllSpaces = - initialSelection.includes(ALL_SPACES_ID) && !filteredSelection.includes(ALL_SPACES_ID); + + const initiallySharedToAllSpaces = initialSelection.includes(ALL_SPACES_ID); + const selectionIncludesAllSpaces = filteredSelection.includes(ALL_SPACES_ID); + + const isSharedToAllSpaces = !initiallySharedToAllSpaces && selectionIncludesAllSpaces; + const isUnsharedFromAllSpaces = initiallySharedToAllSpaces && !selectionIncludesAllSpaces; + const selectedSpacesChanged = - !filteredSelection.includes(ALL_SPACES_ID) && - !arraysAreEqual(initialSelection, filteredSelection); + !selectionIncludesAllSpaces && !arraysAreEqual(initialSelection, filteredSelection); const isSelectionChanged = isSharedToAllSpaces || isUnsharedFromAllSpaces || @@ -200,16 +205,16 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { (spaceId) => !filteredSelection.includes(spaceId) ); - const spacesArray = activeSpaceId ? [activeSpaceId] : []; // if we have an active space, it is automatically selected + const activeSpaceArray = activeSpaceId ? [activeSpaceId] : []; // if we have an active space, it is automatically selected const spacesToAdd = isSharedToAllSpaces ? [ALL_SPACES_ID] : isUnsharedFromAllSpaces - ? [...spacesArray, ...selectedSpacesToAdd] + ? [...activeSpaceArray, ...selectedSpacesToAdd] : selectedSpacesToAdd; const spacesToRemove = isUnsharedFromAllSpaces || !isSharedToAllSpaces ? selectedSpacesToRemove - : [...spacesArray, ...initialSelection]; + : [...activeSpaceArray, ...initialSelection]; return { isSelectionChanged, spacesToAdd, spacesToRemove }; }; const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); @@ -225,7 +230,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { } catch (e) { setShareInProgress(false); toastNotifications.addError(e, { - title: i18n.translate('xpack.spaces.management.shareToSpace.shareErrorTitle', { + title: i18n.translate('xpack.spaces.shareToSpace.shareErrorTitle', { values: { objectNoun: savedObjectTarget.noun }, defaultMessage: 'Error updating {objectNoun}', }), 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 index ff5ef87e2f155..f61610bd4f22a 100644 --- 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 @@ -9,12 +9,12 @@ import './share_to_space_form.scss'; import React, { Fragment } from 'react'; import { EuiSpacer, EuiCallOut, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { SpaceData } from '../../types'; +import { ShareToSpaceTarget } from '../../types'; import { ShareOptions } from '../types'; import { ShareModeControl } from './share_mode_control'; interface Props { - spaces: SpaceData[]; + spaces: ShareToSpaceTarget[]; objectNoun: string; onUpdate: (shareOptions: ShareOptions) => void; shareOptions: ShareOptions; diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx index 907d6f7fe85d7..16c4de1eaa5f5 100644 --- a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx @@ -13,7 +13,7 @@ import { EuiToolTip } from '@elastic/eui'; import { EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public'; -import { SpacesData, SpaceData } from '../types'; +import { ShareToSpacesData, ShareToSpaceTarget } from '../types'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; import { useSpaces } from '../spaces_context'; import { SpaceAvatar } from '../space_avatar'; @@ -25,25 +25,25 @@ export const SpaceListInternal = ({ displayLimit = DEFAULT_DISPLAY_LIMIT, enableSpaceAgnosticBehavior, }: SpaceListProps) => { - const { spacesDataPromise } = useSpaces(); + const { shareToSpacesDataPromise } = useSpaces(); const [isExpanded, setIsExpanded] = useState(false); - const [spacesData, setSpacesData] = useState(); + const [shareToSpacesData, setShareToSpacesData] = useState(); useEffect(() => { - spacesDataPromise.then((x) => { - setSpacesData(x); + shareToSpacesDataPromise.then((x) => { + setShareToSpacesData(x); }); - }, [spacesDataPromise]); + }, [shareToSpacesDataPromise]); - if (!spacesData) { + if (!shareToSpacesData) { return null; } const isSharedToAllSpaces = namespaces?.includes(ALL_SPACES_ID); const unauthorizedCount = (namespaces?.filter((namespace) => namespace === UNKNOWN_SPACE) ?? []) .length; - let displayedSpaces: SpaceData[]; + let displayedSpaces: ShareToSpaceTarget[]; let button: ReactNode = null; if (isSharedToAllSpaces) { @@ -59,10 +59,10 @@ export const SpaceListInternal = ({ ]; } else { const authorized = namespaces?.filter((namespace) => namespace !== UNKNOWN_SPACE) ?? []; - const enabledSpaceTargets: SpaceData[] = []; - const disabledSpaceTargets: SpaceData[] = []; + const enabledSpaceTargets: ShareToSpaceTarget[] = []; + const disabledSpaceTargets: ShareToSpaceTarget[] = []; authorized.forEach((namespace) => { - const spaceTarget = spacesData.spacesMap.get(namespace); + const spaceTarget = shareToSpacesData.spacesMap.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 enabledSpaceTargets.push({ id: namespace, name: namespace }); @@ -122,6 +122,7 @@ export const SpaceListInternal = ({ return ( {displayedSpaces.map((space) => { + // color may be undefined, which is intentional; SpacesAvatar calls the getSpaceColor function before rendering const color = space.isFeatureDisabled ? 'hollow' : space.color; return ( diff --git a/x-pack/plugins/spaces/public/spaces_context/context.tsx b/x-pack/plugins/spaces/public/spaces_context/context.tsx index b4ca71cd377b7..548b2158558c5 100644 --- a/x-pack/plugins/spaces/public/spaces_context/context.tsx +++ b/x-pack/plugins/spaces/public/spaces_context/context.tsx @@ -7,7 +7,7 @@ import * as React from 'react'; import { SpacesManager } from '../spaces_manager'; -import { SpacesData } from '../types'; +import { ShareToSpacesData } from '../types'; import { SpacesReactContext, SpacesReactContextValue, KibanaServices } from './types'; const { useContext, createElement, createContext } = React; @@ -24,11 +24,11 @@ export const useSpaces = (): SpacesReactContextValue< export const createSpacesReactContext = ( services: Services, spacesManager: SpacesManager, - spacesDataPromise: Promise + shareToSpacesDataPromise: Promise ): SpacesReactContext => { const value: SpacesReactContextValue = { spacesManager, - spacesDataPromise, + shareToSpacesDataPromise, services, }; const Provider: React.FC = ({ children }) => diff --git a/x-pack/plugins/spaces/public/spaces_context/types.ts b/x-pack/plugins/spaces/public/spaces_context/types.ts index afc2358db0b66..c2f7db69add09 100644 --- a/x-pack/plugins/spaces/public/spaces_context/types.ts +++ b/x-pack/plugins/spaces/public/spaces_context/types.ts @@ -7,14 +7,14 @@ import * as React from 'react'; import { CoreStart } from 'src/core/public'; -import { SpacesData } from '../types'; +import { ShareToSpacesData } from '../types'; import { SpacesManager } from '../spaces_manager'; export type KibanaServices = Partial; export interface SpacesReactContextValue { readonly spacesManager: SpacesManager; - readonly spacesDataPromise: Promise; + readonly shareToSpacesDataPromise: Promise; readonly services: Services; } diff --git a/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx index 247b868f07517..2e56a43f0f9fc 100644 --- a/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx +++ b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx @@ -11,29 +11,32 @@ import type { SpacesContextProps } from '../../../../../src/plugins/spaces_oss/p import { createSpacesReactContext } from './context'; import { PluginsStart } from '../plugin'; import { SpacesManager } from '../spaces_manager'; -import { SpacesData, SpaceData } from '../types'; +import { ShareToSpacesData, ShareToSpaceTarget } from '../types'; interface InternalProps { spacesManager: SpacesManager; getStartServices: StartServicesAccessor; } -async function getSpacesData(spacesManager: SpacesManager, feature?: string): Promise { +async function getShareToSpacesData( + spacesManager: SpacesManager, + feature?: string +): Promise { const spaces = await spacesManager.getSpaces({ includeAuthorizedPurposes: true }); const activeSpace = await spacesManager.getActiveSpace(); const spacesMap = spaces - .map(({ authorizedPurposes, disabledFeatures, ...space }) => { + .map(({ authorizedPurposes, disabledFeatures, ...space }) => { const isActiveSpace = space.id === activeSpace.id; - const isPartiallyAuthorized = authorizedPurposes?.shareSavedObjectsIntoSpace === false; - const isFeatureDisabled = feature && disabledFeatures.includes(feature); + const cannotShareToSpace = authorizedPurposes?.shareSavedObjectsIntoSpace === false; + const isFeatureDisabled = feature !== undefined && disabledFeatures.includes(feature); return { ...space, ...(isActiveSpace && { isActiveSpace }), - ...(isPartiallyAuthorized && { isPartiallyAuthorized }), + ...(cannotShareToSpace && { cannotShareToSpace }), ...(isFeatureDisabled && { isFeatureDisabled }), }; }) - .reduce((acc, cur) => acc.set(cur.id, cur), new Map()); + .reduce((acc, cur) => acc.set(cur.id, cur), new Map()); return { spacesMap, @@ -45,7 +48,7 @@ const SpacesContextWrapper = (props: PropsWithChildren(); - const spacesDataPromise = useMemo(() => getSpacesData(spacesManager, feature), [ + const shareToSpacesDataPromise = useMemo(() => getShareToSpacesData(spacesManager, feature), [ spacesManager, feature, ]); @@ -62,7 +65,7 @@ const SpacesContextWrapper = (props: PropsWithChildren{children}; }; diff --git a/x-pack/plugins/spaces/public/types.ts b/x-pack/plugins/spaces/public/types.ts index 739ede09901b4..a49df82154849 100644 --- a/x-pack/plugins/spaces/public/types.ts +++ b/x-pack/plugins/spaces/public/types.ts @@ -7,16 +7,25 @@ import { GetSpaceResult } from '../common'; -export interface SpacesData { - readonly spacesMap: Map; +/** + * The structure for all of the space data that must be loaded for share-to-space components to function. + */ +export interface ShareToSpacesData { + /** A map of each existing space's ID and its associated {@link ShareToSpaceTarget}. */ + readonly spacesMap: Map; + /** The ID of the active space. */ readonly activeSpaceId: string; } -export interface SpaceData extends Omit { +/** + * The data that was fetched for a specific space. Includes optional additional fields that are needed to handle edge cases in the + * share-to-space components that consume it. + */ +export interface ShareToSpaceTarget extends Omit { /** True if this space is the active space. */ - isActiveSpace?: boolean; + isActiveSpace?: true; /** True if the user has read access to this space, but is not authorized to share objects into this space. */ - isPartiallyAuthorized?: boolean; + cannotShareToSpace?: true; /** True if the current feature (specified in the `SpacesContext`) is disabled in this space. */ - isFeatureDisabled?: boolean; + isFeatureDisabled?: true; } From c551b2eba7e4b1e31d9020feec8e14282bb73e00 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 9 Feb 2021 21:15:30 -0500 Subject: [PATCH 17/29] Change SavedObjectsClient.resolve() response to add aliasTargetId When an alias was resolved successfully (outcome "conflict" or "aliasMatch") then the response will also include another field, aliasTargetId. This denotes the ID of the object that the legacy URL alias resolved to. This is needed particularly for the "conflict" outcome, where we want to render a callout with a link to the target object using its regenerated ID. --- .../saved_objects/service/lib/repository.test.js | 2 ++ .../server/saved_objects/service/lib/repository.ts | 2 ++ .../saved_objects/service/saved_objects_client.ts | 4 ++++ .../common/suites/resolve.ts | 12 ++++++++++-- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index d79ade22eb559..a92a8b31af592 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -3497,6 +3497,7 @@ describe('SavedObjectsRepository', () => { expect(result).toEqual({ saved_object: expect.objectContaining({ type, id: aliasTargetId }), outcome: 'aliasMatch', + aliasTargetId, }); }; @@ -3537,6 +3538,7 @@ describe('SavedObjectsRepository', () => { expect(result).toEqual({ saved_object: expect.objectContaining({ type, id }), outcome: 'conflict', + aliasTargetId, }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 70ec7e5ea8b0a..4b3479ef9c45d 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -1067,6 +1067,7 @@ export class SavedObjectsRepository { return { saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc), outcome: 'conflict', + aliasTargetId: legacyUrlAlias.targetId, }; } else if (foundExactMatch) { return { @@ -1077,6 +1078,7 @@ export class SavedObjectsRepository { return { saved_object: this.getSavedObjectFromSource(type, legacyUrlAlias.targetId, aliasMatchDoc), outcome: 'aliasMatch', + aliasTargetId: legacyUrlAlias.targetId, }; } throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index b90540fbfa971..6d1383b39db5a 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -309,6 +309,10 @@ export interface SavedObjectsResolveResponse { * `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. */ outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; + /** + * The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + */ + aliasTargetId?: string; } /** diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts index 94c417eeeadd5..80a4a805224bf 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts @@ -30,6 +30,7 @@ export type ResolveTestSuite = TestSuite; export interface ResolveTestCase extends TestCase { expectedOutcome?: 'exactMatch' | 'aliasMatch' | 'conflict'; expectedId?: string; + expectedAliasTargetId?: string; } const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; @@ -48,6 +49,7 @@ export const TEST_CASES = Object.freeze({ expectedNamespaces: EACH_SPACE, expectedOutcome: 'aliasMatch' as 'aliasMatch', expectedId: 'alias-match-newid', + expectedAliasTargetId: 'alias-match-newid', }), CONFLICT: Object.freeze({ type: 'resolvetype', @@ -55,6 +57,7 @@ export const TEST_CASES = Object.freeze({ expectedNamespaces: EACH_SPACE, expectedOutcome: 'conflict' as 'conflict', // only in space 1, where the alias exists expectedId: 'conflict', + expectedAliasTargetId: 'conflict-newid', }), DISABLED: Object.freeze({ type: 'resolvetype', @@ -77,10 +80,15 @@ export function resolveTestSuiteFactory(esArchiver: any, supertest: SuperTest Date: Wed, 10 Feb 2021 10:15:40 -0500 Subject: [PATCH 18/29] Add remaining reusable UI elements * LegacyUrlConflict component displays a callout * redirectLegacyUrl function redirects locally and displays a toast --- src/plugins/spaces_oss/public/api.mock.ts | 2 + src/plugins/spaces_oss/public/api.ts | 70 ++++++++++- src/plugins/spaces_oss/public/index.ts | 1 + .../components/constants.ts | 12 ++ .../components/index.ts | 1 + .../components/legacy_url_conflict.tsx | 18 +++ .../legacy_url_conflict_internal.test.tsx | 68 +++++++++++ .../legacy_url_conflict_internal.tsx | 114 ++++++++++++++++++ .../share_to_space_flyout_internal.test.tsx | 3 +- .../share_to_space_flyout_internal.tsx | 4 +- .../share_saved_objects_to_space/index.ts | 3 +- .../utils/index.ts | 8 ++ .../utils/redirect_legacy_url.test.ts | 40 ++++++ .../utils/redirect_legacy_url.ts | 32 +++++ .../spaces/public/ui_api/components.ts | 6 +- x-pack/plugins/spaces/public/ui_api/index.ts | 2 + x-pack/plugins/spaces/public/ui_api/mocks.ts | 2 + 17 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.ts create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/index.ts create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.ts diff --git a/src/plugins/spaces_oss/public/api.mock.ts b/src/plugins/spaces_oss/public/api.mock.ts index ab4e97b761759..c4a410c76e796 100644 --- a/src/plugins/spaces_oss/public/api.mock.ts +++ b/src/plugins/spaces_oss/public/api.mock.ts @@ -22,6 +22,7 @@ type SpacesApiUiMock = Omit, 'components'> & { const createApiUiMock = () => { const mock: SpacesApiUiMock = { components: createApiUiComponentsMock(), + redirectLegacyUrl: jest.fn(), }; return mock; @@ -34,6 +35,7 @@ const createApiUiComponentsMock = () => { SpacesContext: jest.fn(), ShareToSpaceFlyout: jest.fn(), SpaceList: jest.fn(), + LegacyUrlConflict: jest.fn(), }; return mock; diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index 81ab7e39a0701..7abf51b4772d6 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -30,6 +30,25 @@ export interface SpacesApiUi { * {@link SpacesApiUiComponent | React components} to support the spaces feature. */ components: SpacesApiUiComponent; + /** + * Redirect the user from a legacy URL to a new URL. This needs to be used if a call to `SavedObjectsClient.resolve()` results in an + * `"aliasMatch"` outcome, which indicates that the user has loaded the page using a legacy URL. Calling this function will trigger a + * client-side redirect to the new URL, and it will display a toast to the user. + * + * Consumers need to determine the local path for the new URL on their own, based on the object ID that was used to call + * `SavedObjectsClient.resolve()` (old ID) and the object ID in the result (new ID). For example... + * + * The old object ID is `workpad-123` and the new object ID is `workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e`. + * + * Full legacy URL: `https://localhost:5601/app/canvas#/workpad/workpad-123/page/1` + * + * New URL path: `#/workpad/workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e/page/1` + * + * The protocol, hostname, port, base path, and app path are automatically included. + * + * @param path The path to use for the new URL, optionally including `search` and/or `hash` URL components. + */ + redirectLegacyUrl: (path: string) => Promise; } /** @@ -39,17 +58,25 @@ export interface SpacesApiUi { */ export interface SpacesApiUiComponent { /** - * Provides a context that is required to render all Spaces components. + * Provides a context that is required to render some Spaces components. */ SpacesContext: FunctionComponent; /** * Displays the tags for given saved object. + * + * Note: must be rendered inside of a SpacesContext. */ ShareToSpaceFlyout: FunctionComponent; /** * Displays a corresponding list of spaces for a given list of saved object namespaces. + * + * Note: must be rendered inside of a SpacesContext. */ SpaceList: FunctionComponent; + /** + * Displays a warning callout when a user encounters a legacy URL alias conflict. + */ + LegacyUrlConflict: FunctionComponent; } /** @@ -177,3 +204,44 @@ export interface SpaceListProps { */ enableSpaceAgnosticBehavior?: boolean; } + +/** + * @public + * + * Displays a callout that. This needs to be used if a call to `SavedObjectsClient.resolve()` results in an `"conflict"` outcome, which + * indicates that the user has loaded the page which is associated directly with one object (A), *and* with a legacy URL that points to a + * different object (B). + * + * In this case, `SavedObjectsClient.resolve()` has returned object A. This component displays a callout to the user explaining that there + * is a conflict, and it includes a button that will redirect the user to object B when clicked. + * + * Consumers need to determine the local path for the new URL on their own, based on the object ID that was used to call + * `SavedObjectsClient.resolve()` (A) and the `aliasTargetId` value in the response (B). For example... + * + * A is `workpad-123` and B is `workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e`. + * + * Full legacy URL: `https://localhost:5601/app/canvas#/workpad/workpad-123/page/1` + * + * New URL path: `#/workpad/workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e/page/1` + */ +export interface LegacyUrlConflictProps { + /** + * The string that is used to describe the object in the callout, e.g., _There is a legacy URL for this page that points to a different + * **object**_. + * + * Default value is 'object'. + */ + objectNoun?: string; + /** + * The ID of the object that is currently shown on the page. + */ + currentObjectId: string; + /** + * The ID of the other object that the legacy URL alias points to. + */ + otherObjectId: string; + /** + * The path to use for the new URL, optionally including `search` and/or `hash` URL components. + */ + otherObjectPath: string; +} diff --git a/src/plugins/spaces_oss/public/index.ts b/src/plugins/spaces_oss/public/index.ts index a6ed7b6f2a9e1..be42bd9a899b1 100644 --- a/src/plugins/spaces_oss/public/index.ts +++ b/src/plugins/spaces_oss/public/index.ts @@ -23,6 +23,7 @@ export { ShareToSpaceFlyoutProps, ShareToSpaceSavedObjectTarget, SpaceListProps, + LegacyUrlConflictProps, } from './api'; export const plugin = () => new SpacesOssPlugin(); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.ts new file mode 100644 index 0000000000000..ef3248e1cd60a --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const DEFAULT_OBJECT_NOUN = i18n.translate('xpack.spaces.shareToSpace.objectNoun', { + defaultMessage: 'object', +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts index 9e5459ff538da..b133be833d505 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts @@ -7,3 +7,4 @@ export { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal'; export { getShareToSpaceFlyoutComponent } from './share_to_space_flyout'; +export { getLegacyUrlConflict } from './legacy_url_conflict'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx new file mode 100644 index 0000000000000..b9a01d4deabb5 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { LegacyUrlConflictProps } from 'src/plugins/spaces_oss/public'; +import { LegacyUrlConflictInternal, InternalProps } from './legacy_url_conflict_internal'; + +export const getLegacyUrlConflict = ( + internalProps: InternalProps +): React.FC => { + return (props: LegacyUrlConflictProps) => { + return ; + }; +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx new file mode 100644 index 0000000000000..1b897e8afa7d2 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { BehaviorSubject } from 'rxjs'; +import { EuiCallOut } from '@elastic/eui'; +import { mountWithIntl, findTestSubject } from '@kbn/test/jest'; +import { act } from '@testing-library/react'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { LegacyUrlConflictInternal } from './legacy_url_conflict_internal'; + +const APP_ID = 'testAppId'; +const PATH = 'path'; + +describe('LegacyUrlConflict', () => { + const setup = async () => { + const { getStartServices } = coreMock.createSetup(); + const startServices = coreMock.createStart(); + const subject = new BehaviorSubject(`not-${APP_ID}`); + subject.next(APP_ID); // test below asserts that the consumer received the most recent APP_ID + startServices.application.currentAppId$ = subject; + const application = startServices.application; + getStartServices.mockResolvedValue([startServices, , ,]); + + const wrapper = mountWithIntl( + + ); + + // wait for wrapper to rerender + await act(async () => {}); + wrapper.update(); + + return { wrapper, application }; + }; + + it('can click the "Go to other object" button', async () => { + const { wrapper, application } = await setup(); + + expect(application.navigateToApp).not.toHaveBeenCalled(); + + const goToOtherButton = findTestSubject(wrapper, 'legacy-url-conflict-go-to-other-button'); + goToOtherButton.simulate('click'); + + expect(application.navigateToApp).toHaveBeenCalledTimes(1); + expect(application.navigateToApp).toHaveBeenCalledWith(APP_ID, { path: PATH }); + }); + + it('can click the "Dismiss" button', async () => { + const { wrapper } = await setup(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); // callout is visible + + const dismissButton = findTestSubject(wrapper, 'legacy-url-conflict-dismiss-button'); + dismissButton.simulate('click'); + wrapper.update(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(0); // callout is not visible + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx new file mode 100644 index 0000000000000..ccbfa7b5bbed5 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { firstValueFrom } from '@kbn/std'; +import React, { useState, useEffect } from 'react'; +import type { ApplicationStart, StartServicesAccessor } from 'src/core/public'; +import type { LegacyUrlConflictProps } from 'src/plugins/spaces_oss/public'; +import type { PluginsStart } from '../../plugin'; +import { DEFAULT_OBJECT_NOUN } from './constants'; + +export interface InternalProps { + getStartServices: StartServicesAccessor; +} + +export const LegacyUrlConflictInternal = (props: InternalProps & LegacyUrlConflictProps) => { + const { + getStartServices, + objectNoun = DEFAULT_OBJECT_NOUN, + currentObjectId, + otherObjectId, + otherObjectPath, + } = props; + + const [applicationStart, setApplicationStart] = useState(); + const [isDismissed, setIsDismissed] = useState(false); + const [appId, setAppId] = useState(); + + useEffect(() => { + async function setup() { + const [{ application }] = await getStartServices(); + const appIdValue = await firstValueFrom(application.currentAppId$); // retrieve the most recent value from the BehaviorSubject + setApplicationStart(application); + setAppId(appIdValue); + } + setup(); + }, [getStartServices]); + + if (!applicationStart || !appId || isDismissed) { + return null; + } + + function clickLinkButton() { + applicationStart!.navigateToApp(appId!, { path: otherObjectPath }); + } + + function clickDismissButton() { + setIsDismissed(true); + } + + return ( + + } + > + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx index f31829f4a4b2b..21d987afb9149 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import Boom from '@hapi/boom'; -import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { mountWithIntl, nextTick, findTestSubject } from '@kbn/test/jest'; import { ShareToSpaceForm } from './share_to_space_form'; import { EuiCallOut, @@ -18,7 +18,6 @@ import { EuiSelectable, } from '@elastic/eui'; import { Space } from '../../../../../../src/plugins/spaces_oss/common'; -import { findTestSubject } from '@kbn/test/jest'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { act } from '@testing-library/react'; import { spacesManagerMock } from '../../spaces_manager/mocks'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx index 378dd8ad12a03..3bc5c21282a31 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -35,10 +35,8 @@ import { ShareToSpaceForm } from './share_to_space_form'; import { ShareOptions } from '../types'; import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; import { useSpaces } from '../../spaces_context'; +import { DEFAULT_OBJECT_NOUN } from './constants'; -const DEFAULT_OBJECT_NOUN = i18n.translate('xpack.spaces.shareToSpace.objectNoun', { - defaultMessage: 'object', -}); const ALL_SPACES_TARGET = i18n.translate('xpack.spaces.shareToSpace.allSpacesTarget', { defaultMessage: 'all', }); 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 index f9a593fb3c2aa..beed0fd9d592a 100644 --- 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 @@ -6,4 +6,5 @@ */ export { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space_service'; -export { getShareToSpaceFlyoutComponent } from './components'; +export { getShareToSpaceFlyoutComponent, getLegacyUrlConflict } from './components'; +export { createRedirectLegacyUrl } from './utils'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/index.ts new file mode 100644 index 0000000000000..a40bc87cd4dc3 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { createRedirectLegacyUrl } from './redirect_legacy_url'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts new file mode 100644 index 0000000000000..84d2958092a65 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BehaviorSubject } from 'rxjs'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { createRedirectLegacyUrl } from './redirect_legacy_url'; + +const APP_ID = 'testAppId'; + +describe('#redirectLegacyUrl', () => { + const setup = () => { + const { getStartServices } = coreMock.createSetup(); + const startServices = coreMock.createStart(); + const subject = new BehaviorSubject(`not-${APP_ID}`); + subject.next(APP_ID); // test below asserts that the consumer received the most recent APP_ID + startServices.application.currentAppId$ = subject; + const toasts = startServices.notifications.toasts; + const application = startServices.application; + getStartServices.mockResolvedValue([startServices, , ,]); + + const redirectLegacyUrl = createRedirectLegacyUrl(getStartServices); + + return { redirectLegacyUrl, toasts, application }; + }; + + it('creates a toast and redirects to the given path in the current app', async () => { + const { redirectLegacyUrl, toasts, application } = setup(); + + const path = '/foo?bar#baz'; + await redirectLegacyUrl(path); + + expect(toasts.addInfo).toHaveBeenCalledTimes(1); + expect(application.navigateToApp).toHaveBeenCalledTimes(1); + expect(application.navigateToApp).toHaveBeenCalledWith(APP_ID, { replace: true, path }); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.ts new file mode 100644 index 0000000000000..3b1e8286660d5 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { firstValueFrom } from '@kbn/std'; +import type { StartServicesAccessor } from 'src/core/public'; +import type { SpacesApiUi } from 'src/plugins/spaces_oss/public'; +import type { PluginsStart } from '../../plugin'; + +export function createRedirectLegacyUrl( + getStartServices: StartServicesAccessor +): SpacesApiUi['redirectLegacyUrl'] { + return async function (path: string) { + const [{ notifications, application }] = await getStartServices(); + const { currentAppId$, navigateToApp } = application; + const appId = await firstValueFrom(currentAppId$); // retrieve the most recent value from the BehaviorSubject + + const title = i18n.translate('xpack.spaces.shareToSpace.redirectLegacyUrlToast.title', { + defaultMessage: 'Redirected from legacy URL', + }); + const text = i18n.translate('xpack.spaces.shareToSpace.redirectLegacyUrlToast.text', { + defaultMessage: + 'You used a legacy URL to navigate to this page. This URL changed in Kibana 8.0, so we redirected you to the new one. You should use this new URL in the future.', + }); + notifications.toasts.addInfo({ title, text }); + navigateToApp(appId!, { replace: true, path }); + }; +} diff --git a/x-pack/plugins/spaces/public/ui_api/components.ts b/x-pack/plugins/spaces/public/ui_api/components.ts index 8b538e79842d4..6a8dedb5f5b68 100644 --- a/x-pack/plugins/spaces/public/ui_api/components.ts +++ b/x-pack/plugins/spaces/public/ui_api/components.ts @@ -8,7 +8,10 @@ import { StartServicesAccessor } from 'src/core/public'; import type { SpacesApiUiComponent } from '../../../../../src/plugins/spaces_oss/public'; import { PluginsStart } from '../plugin'; -import { getShareToSpaceFlyoutComponent } from '../share_saved_objects_to_space'; +import { + getShareToSpaceFlyoutComponent, + getLegacyUrlConflict, +} from '../share_saved_objects_to_space'; import { getSpacesContextWrapper } from '../spaces_context'; import { SpacesManager } from '../spaces_manager'; import { getSpaceListComponent } from '../space_list'; @@ -26,5 +29,6 @@ export const getComponents = ({ SpacesContext: getSpacesContextWrapper({ spacesManager, getStartServices }), ShareToSpaceFlyout: getShareToSpaceFlyoutComponent(), SpaceList: getSpaceListComponent(), + LegacyUrlConflict: getLegacyUrlConflict({ getStartServices }), }; }; diff --git a/x-pack/plugins/spaces/public/ui_api/index.ts b/x-pack/plugins/spaces/public/ui_api/index.ts index 2b0ace3d64dfa..9646449ec0c31 100644 --- a/x-pack/plugins/spaces/public/ui_api/index.ts +++ b/x-pack/plugins/spaces/public/ui_api/index.ts @@ -10,6 +10,7 @@ import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; import { PluginsStart } from '../plugin'; import { SpacesManager } from '../spaces_manager'; import { getComponents } from './components'; +import { createRedirectLegacyUrl } from '../share_saved_objects_to_space'; interface GetUiApiOptions { spacesManager: SpacesManager; @@ -21,5 +22,6 @@ export const getUiApi = ({ spacesManager, getStartServices }: GetUiApiOptions): return { components, + redirectLegacyUrl: createRedirectLegacyUrl(getStartServices), }; }; diff --git a/x-pack/plugins/spaces/public/ui_api/mocks.ts b/x-pack/plugins/spaces/public/ui_api/mocks.ts index 13ef05f3e5846..c9aa2a2b2b52f 100644 --- a/x-pack/plugins/spaces/public/ui_api/mocks.ts +++ b/x-pack/plugins/spaces/public/ui_api/mocks.ts @@ -15,12 +15,14 @@ function createComponentsMock(): jest.Mocked { SpacesContext: jest.fn(), ShareToSpaceFlyout: jest.fn(), SpaceList: jest.fn(), + LegacyUrlConflict: jest.fn(), }; } function createUiApiMock(): jest.Mocked { return { components: createComponentsMock(), + redirectLegacyUrl: jest.fn(), }; } From 9dcb7bc3b8e947dea7bce4d01832aafe9e8b27b1 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 10 Feb 2021 11:16:28 -0500 Subject: [PATCH 19/29] Fix dev docs and i18n --- ...vedobjectsresolveresponse.aliastargetid.md | 13 +++++++ ...core-server.savedobjectsresolveresponse.md | 1 + src/core/server/server.api.md | 1 + .../components/no_spaces_available.tsx | 4 +- .../components/selectable_spaces_control.tsx | 26 ++++++------- .../components/share_mode_control.tsx | 39 ++++++++----------- .../share_to_space_flyout_internal.tsx | 14 +++---- .../components/share_to_space_form.tsx | 6 +-- .../share_saved_objects_to_space_action.tsx | 4 +- .../share_saved_objects_to_space_column.tsx | 4 +- .../translations/translations/ja-JP.json | 26 ------------- .../translations/translations/zh-CN.json | 26 ------------- 12 files changed, 59 insertions(+), 105 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md new file mode 100644 index 0000000000000..2e73d6ba2e1a9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) > [aliasTargetId](./kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md) + +## SavedObjectsResolveResponse.aliasTargetId property + +The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + +Signature: + +```typescript +aliasTargetId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md index cfb309da0a716..ffcf15dbc80c7 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md @@ -15,6 +15,7 @@ export interface SavedObjectsResolveResponse | Property | Type | Description | | --- | --- | --- | +| [aliasTargetId](./kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md) | string | The ID of the object that the legacy URL alias points to. This is only defined when the outcome is 'aliasMatch' or 'conflict'. | | [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) | 'exactMatch' | 'aliasMatch' | 'conflict' | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | | [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | | diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index df6d1cca2fc21..083f2f8cc7ebb 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2813,6 +2813,7 @@ export interface SavedObjectsResolveImportErrorsOptions { // @public (undocumented) export interface SavedObjectsResolveResponse { + aliasTargetId?: string; outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; // (undocumented) saved_object: SavedObject; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx index 678464bcf4d64..46610a2cc9a7c 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx @@ -26,7 +26,7 @@ export const NoSpacesAvailable = (props: Props) => { { href={getUrlForApp('management', { path: 'kibana/spaces/create' })} > diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index d402dc06a08d2..f4fa1cd5593ca 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -43,27 +43,25 @@ const ROW_HEIGHT = 40; const APPEND_ACTIVE_SPACE = Current; const APPEND_CANNOT_SELECT = ( ); const APPEND_CANNOT_DESELECT = ( ); const APPEND_FEATURE_IS_DISABLED = ( { @@ -165,15 +163,15 @@ export const SelectableSpacesControl = (props: Props) => { selectedCountPad; const hiddenCount = selectedSpaceIds.filter((id) => id === UNKNOWN_SPACE).length; const selectSpacesLabel = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel', + 'xpack.spaces.shareToSpace.shareModeControl.selectSpacesLabel', { defaultMessage: 'Select spaces' } ); const selectedSpacesLabel = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel', + 'xpack.spaces.shareToSpace.shareModeControl.selectedCountLabel', { defaultMessage: '{selectedCount} selected', values: { selectedCount } } ); const hiddenSpacesLabel = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel', + 'xpack.spaces.shareToSpace.shareModeControl.hiddenCountLabel', { defaultMessage: '+{hiddenCount} hidden', values: { hiddenCount } } ); const hiddenSpaces = hiddenCount ? {hiddenSpacesLabel} : null; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx index 855688ca0aa7a..ed6e54bdaa770 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx @@ -84,25 +84,21 @@ export const ShareModeControl = (props: Props) => { const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); const shareToAllSpaces = { id: 'shareToAllSpaces', - title: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title', - { defaultMessage: 'All spaces' } - ), - text: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text', - { - defaultMessage: 'Make {objectNoun} available in all current and future spaces.', - values: { objectNoun }, - } - ), + title: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.title', { + defaultMessage: 'All spaces', + }), + text: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.text', { + defaultMessage: 'Make {objectNoun} available in all current and future spaces.', + values: { objectNoun }, + }), ...(!canShareToAllSpaces && { tooltip: isGlobalControlChecked ? i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip', + 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip', { defaultMessage: 'You need additional privileges to change this option.' } ) : i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip', + 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip', { defaultMessage: 'You need additional privileges to use this option.' } ), }), @@ -111,16 +107,13 @@ export const ShareModeControl = (props: Props) => { const shareToExplicitSpaces = { id: 'shareToExplicitSpaces', title: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title', + 'xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.title', { defaultMessage: 'Select spaces' } ), - text: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text', - { - defaultMessage: 'Make {objectNoun} available in selected spaces only.', - values: { objectNoun }, - } - ), + text: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text', { + defaultMessage: 'Make {objectNoun} available in selected spaces only.', + values: { objectNoun }, + }), disabled: !canShareToAllSpaces && isGlobalControlChecked, }; @@ -143,14 +136,14 @@ export const ShareModeControl = (props: Props) => { iconType="help" title={ } color="warning" > diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx index 3bc5c21282a31..8f665a704c8e8 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -61,11 +61,11 @@ function createDefaultChangeSpacesHandler( const spaceTargets = isSharedToAllSpaces ? ALL_SPACES_TARGET : `${spacesToAdd.length}`; const toastText = !isSharedToAllSpaces && spacesToAdd.length === 1 - ? i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular', { + ? i18n.translate('xpack.spaces.shareToSpace.shareAddSuccessTextSingular', { defaultMessage: `'{object}' was added to 1 space.`, values: { object: title }, }) - : i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural', { + : i18n.translate('xpack.spaces.shareToSpace.shareAddSuccessTextPlural', { defaultMessage: `'{object}' was added to {spaceTargets} spaces.`, values: { object: title, spaceTargets }, }); @@ -77,11 +77,11 @@ function createDefaultChangeSpacesHandler( const spaceTargets = isUnsharedFromAllSpaces ? ALL_SPACES_TARGET : `${spacesToRemove.length}`; const toastText = !isUnsharedFromAllSpaces && spacesToRemove.length === 1 - ? i18n.translate('xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular', { + ? i18n.translate('xpack.spaces.shareToSpace.shareRemoveSuccessTextSingular', { defaultMessage: `'{object}' was removed from 1 space.`, values: { object: title }, }) - : i18n.translate('xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural', { + : i18n.translate('xpack.spaces.shareToSpace.shareRemoveSuccessTextPlural', { defaultMessage: `'{object}' was removed from {spaceTargets} spaces.`, values: { object: title, spaceTargets }, }); @@ -158,7 +158,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { }) .catch((e) => { toastNotifications.addError(e, { - title: i18n.translate('xpack.spaces.management.shareToSpace.spacesLoadErrorTitle', { + title: i18n.translate('xpack.spaces.shareToSpace.spacesLoadErrorTitle', { defaultMessage: 'Error loading available spaces', }), }); @@ -323,7 +323,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { disabled={shareInProgress} > @@ -336,7 +336,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { disabled={isStartShareButtonDisabled} > 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 index f61610bd4f22a..8bebf73dbcdee 100644 --- 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 @@ -52,21 +52,21 @@ export const ShareToSpaceForm = (props: Props) => { size="s" title={ } color="warning" > makeCopy()}> 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 index 9727b9cf2a793..feb073745c616 100644 --- 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 @@ -17,10 +17,10 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage public id: string = 'share_saved_objects_to_space'; public euiAction = { - name: i18n.translate('xpack.spaces.management.shareToSpace.actionTitle', { + name: i18n.translate('xpack.spaces.shareToSpace.actionTitle', { defaultMessage: 'Share to space', }), - description: i18n.translate('xpack.spaces.management.shareToSpace.actionDescription', { + description: i18n.translate('xpack.spaces.shareToSpace.actionDescription', { defaultMessage: 'Share this saved object to one or more spaces', }), icon: 'share', 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 index e5bac10eb2571..05e0976da0710 100644 --- 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 @@ -16,10 +16,10 @@ export class ShareToSpaceSavedObjectsManagementColumn public euiColumn = { field: 'namespaces', - name: i18n.translate('xpack.spaces.management.shareToSpace.columnTitle', { + name: i18n.translate('xpack.spaces.shareToSpace.columnTitle', { defaultMessage: 'Shared spaces', }), - description: i18n.translate('xpack.spaces.management.shareToSpace.columnDescription', { + description: i18n.translate('xpack.spaces.shareToSpace.columnDescription', { defaultMessage: 'The other spaces that this object is currently shared to', }), render: (namespaces: string[] | undefined) => { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d545f34ee592a..ccd08341b270e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20601,32 +20601,6 @@ "xpack.spaces.management.manageSpacePage.updateSpaceButton": "スペースを更新", "xpack.spaces.management.reversedSpaceBadge.reversedSpacesCanBePartiallyModifiedTooltip": "リザーブされたスペースはビルトインのため、部分的な変更しかできません。", "xpack.spaces.management.selectAllFeaturesLink": "すべて選択", - "xpack.spaces.management.shareToSpace.actionDescription": "この保存されたオブジェクトを1つ以上のスペースと共有します。", - "xpack.spaces.management.shareToSpace.actionTitle": "スペースと共有", - "xpack.spaces.management.shareToSpace.cancelButton": "キャンセル", - "xpack.spaces.management.shareToSpace.columnDescription": "このオブジェクトが現在共有されている他のスペース", - "xpack.spaces.management.shareToSpace.columnTitle": "共有されているスペース", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.linkText": "新しいスペースを作成", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.text": "オブジェクトを共有するには、{createANewSpaceLink}できます。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked": "このスペースの選択を解除するには、追加の権限が必要です。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked": "このスペースを選択するには、追加の権限が必要です。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural": "「{object}」は{spaceTargets}個のスペースに追加されました。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular": "「{object}」は1つのスペースに追加されました。", - "xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount}個が非表示", - "xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount}個が選択済み", - "xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel": "スペースを選択", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip": "このオプションを使用するには、追加権限が必要です。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip": "このオプションを変更するには、追加権限が必要です。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title": "すべてのスペース", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "スペースを選択", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural": "「{object}」は{spaceTargets}個のスペースから削除されました。", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular": "「{object}」は1つのスペースから削除されました。", - "xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存して閉じる", - "xpack.spaces.management.shareToSpace.shareWarningLink": "コピーを作成", - "xpack.spaces.management.shareToSpace.shareWarningTitle": "共有オブジェクトの編集は、すべてのスペースで変更を適用します。", - "xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "利用可能なスペースを読み込み中にエラーが発生", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.additionalPrivilegesLink": "追加権限", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.text": "非表示のスペースを表示するには、{additionalPrivilegesLink}が必要です。", "xpack.spaces.management.showAllFeaturesText": "すべて表示", "xpack.spaces.management.spaceIdentifier.customizeSpaceLinkText": "[カスタマイズ]", "xpack.spaces.management.spaceIdentifier.customizeSpaceNameLinkLabel": "URL 識別子をカスタマイズ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b3a80bb55b6aa..b2f85d55f4db0 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -20648,32 +20648,6 @@ "xpack.spaces.management.manageSpacePage.updateSpaceButton": "更新工作区", "xpack.spaces.management.reversedSpaceBadge.reversedSpacesCanBePartiallyModifiedTooltip": "保留的空间是内置的,只能进行部分修改。", "xpack.spaces.management.selectAllFeaturesLink": "全选", - "xpack.spaces.management.shareToSpace.actionDescription": "将此已保存对象共享到一个或多个工作区", - "xpack.spaces.management.shareToSpace.actionTitle": "共享到工作区", - "xpack.spaces.management.shareToSpace.cancelButton": "取消", - "xpack.spaces.management.shareToSpace.columnDescription": "目前将此对象共享到的其他工作区", - "xpack.spaces.management.shareToSpace.columnTitle": "共享工作区", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.linkText": "创建新工作区", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.text": "您可以{createANewSpaceLink},用于共享您的对象。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked": "您需要额外权限才能取消选择此工作区。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked": "您需要额外权限才能选择此工作区。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural": "“{object}”已添加到 {spaceTargets} 个工作区。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular": "“{object}”已添加到 1 个工作区。", - "xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount} 个已隐藏", - "xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount} 个已选择", - "xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel": "选择工作区", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip": "您还需要其他权限,才能使用此选项。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip": "您还需要其他权限,才能更改此选项。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title": "所有工作区", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "选择工作区", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural": "“{object}”已从 {spaceTargets} 个工作区中移除。", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular": "“{object}”已从 1 个工作区中移除。", - "xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存并关闭", - "xpack.spaces.management.shareToSpace.shareWarningLink": "创建副本", - "xpack.spaces.management.shareToSpace.shareWarningTitle": "编辑共享对象会在所有工作区中应用更改", - "xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "加载可用工作区时出错", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.additionalPrivilegesLink": "其他权限", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.text": "要查看隐藏的工作区,您需要{additionalPrivilegesLink}。", "xpack.spaces.management.showAllFeaturesText": "全部显示", "xpack.spaces.management.spaceIdentifier.customizeSpaceLinkText": "[定制]", "xpack.spaces.management.spaceIdentifier.customizeSpaceNameLinkLabel": "定制 URL 标识符", From af4ebcc082adf38a0feb6991409d74176159cef8 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 10 Feb 2021 14:02:08 -0500 Subject: [PATCH 20/29] More PR review feedback --- src/plugins/spaces_oss/public/api.ts | 6 ++- .../server/create_migration.test.ts | 6 +-- .../server/create_migration.ts | 2 +- .../encrypted_saved_objects_service.test.ts | 20 +++++++- .../crypto/encrypted_saved_objects_service.ts | 46 ++++++++++--------- .../server/saved_objects/index.ts | 4 +- x-pack/plugins/spaces/public/plugin.tsx | 4 +- .../utils/redirect_legacy_url.ts | 9 ++-- x-pack/plugins/spaces/public/ui_api/index.ts | 6 +-- 9 files changed, 63 insertions(+), 40 deletions(-) diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index 7abf51b4772d6..e57d071fc4f7b 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -47,8 +47,10 @@ export interface SpacesApiUi { * The protocol, hostname, port, base path, and app path are automatically included. * * @param path The path to use for the new URL, optionally including `search` and/or `hash` URL components. + * @param objectNoun The string that is used to describe the object in the toast, e.g., _The **object** you're looking for has a new + * location_. Default value is 'object'. */ - redirectLegacyUrl: (path: string) => Promise; + redirectLegacyUrl: (path: string, objectNoun?: string) => Promise; } /** @@ -62,7 +64,7 @@ export interface SpacesApiUiComponent { */ SpacesContext: FunctionComponent; /** - * Displays the tags for given saved object. + * Displays a flyout to edit the spaces that an object is shared to. * * Note: must be rendered inside of a SpacesContext. */ diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts index 5fc355e2ef6de..16f9679da481f 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts @@ -227,15 +227,15 @@ describe('createMigration()', () => { ); }; - it('when namespaces is an empty array', async () => { + it('when namespaces is an empty array', () => { doTest({ objectNamespace: undefined, decryptDescriptorNamespace: undefined }); }); - it('when the first namespace element is "default"', async () => { + it('when the first namespace element is "default"', () => { doTest({ objectNamespace: 'default', decryptDescriptorNamespace: undefined }); }); - it('when the first namespace element is another string', async () => { + it('when the first namespace element is another string', () => { doTest({ objectNamespace: 'foo', decryptDescriptorNamespace: 'foo' }); }); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts index abfb9e1d839b4..cf5357c40fa20 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts @@ -11,7 +11,7 @@ import { SavedObjectMigrationContext, } from 'src/core/server'; import { EncryptedSavedObjectTypeRegistration, EncryptedSavedObjectsService } from './crypto'; -import { normalizeNamespace } from './saved_objects/get_descriptor_namespace'; +import { normalizeNamespace } from './saved_objects'; type SavedObjectOptionalMigrationFn = ( doc: SavedObjectUnsanitizedDoc | SavedObjectUnsanitizedDoc, diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts index dcbd7d8e75dec..7bc08d0e7b30f 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts @@ -1079,11 +1079,12 @@ describe('#decryptAttributes', () => { attrThree: expect.not.stringMatching(/^three$/), }); + const mockUser = mockAuthenticatedUser(); await expect(() => service.decryptAttributes( { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, encryptedAttributes, - { convertToMultiNamespaceType: true } + { user: mockUser, convertToMultiNamespaceType: true } ) ).rejects.toThrowError(EncryptionError); expect(mockNodeCrypto.decrypt).toHaveBeenCalledTimes(2); @@ -1097,6 +1098,13 @@ describe('#decryptAttributes', () => { expect.anything(), `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` ); + + expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); }); it('fails to decrypt if encrypted attribute is defined, but not a string', async () => { @@ -1996,11 +2004,12 @@ describe('#decryptAttributesSync', () => { attrThree: expect.not.stringMatching(/^three$/), }); + const mockUser = mockAuthenticatedUser(); expect(() => service.decryptAttributesSync( { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, encryptedAttributes, - { convertToMultiNamespaceType: true } + { user: mockUser, convertToMultiNamespaceType: true } ) ).toThrowError(EncryptionError); expect(mockNodeCrypto.decryptSync).toHaveBeenCalledTimes(2); @@ -2014,6 +2023,13 @@ describe('#decryptAttributesSync', () => { expect.anything(), `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` ); + + expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); }); it('fails to decrypt if encrypted attribute is defined, but not a string', () => { diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index 39904ac02fdd6..17757c9d8b2ba 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -381,17 +381,18 @@ export class EncryptedSavedObjectsService { decrypters.length === 0 ? new Error('Decryption is disabled because of missing decryption keys.') : undefined; - loop: for (const decrypter of decrypters) { - for (const encryptionAAD of encryptionAADs) { - try { - iteratorResult = iterator.next(await decrypter.decrypt(attributeValue, encryptionAAD)); - decryptionError = undefined; - break loop; - } catch (err) { - // Remember the error thrown when we tried to decrypt with the primary key. - if (!decryptionError) { - decryptionError = err; - } + const decryptersPerAAD = decrypters.flatMap((decr) => + encryptionAADs.map((aad) => [decr, aad] as [Crypto, string]) + ); + for (const [decrypter, encryptionAAD] of decryptersPerAAD) { + try { + iteratorResult = iterator.next(await decrypter.decrypt(attributeValue, encryptionAAD)); + decryptionError = undefined; + break; + } catch (err) { + // Remember the error thrown when we tried to decrypt with the primary key. + if (!decryptionError) { + decryptionError = err; } } } @@ -431,17 +432,18 @@ export class EncryptedSavedObjectsService { decrypters.length === 0 ? new Error('Decryption is disabled because of missing decryption keys.') : undefined; - loop: for (const decrypter of decrypters) { - for (const encryptionAAD of encryptionAADs) { - try { - iteratorResult = iterator.next(decrypter.decryptSync(attributeValue, encryptionAAD)); - decryptionError = undefined; - break loop; - } catch (err) { - // Remember the error thrown when we tried to decrypt with the primary key. - if (!decryptionError) { - decryptionError = err; - } + const decryptersPerAAD = decrypters.flatMap((decr) => + encryptionAADs.map((aad) => [decr, aad] as [Crypto, string]) + ); + for (const [decrypter, encryptionAAD] of decryptersPerAAD) { + try { + iteratorResult = iterator.next(decrypter.decryptSync(attributeValue, encryptionAAD)); + decryptionError = undefined; + break; + } catch (err) { + // Remember the error thrown when we tried to decrypt with the primary key. + if (!decryptionError) { + decryptionError = err; } } } diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index cac7b9ba9d5cc..9e7c1f6592290 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -17,7 +17,9 @@ import { import { SecurityPluginSetup } from '../../../security/server'; import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; -import { getDescriptorNamespace } from './get_descriptor_namespace'; +import { getDescriptorNamespace, normalizeNamespace } from './get_descriptor_namespace'; + +export { normalizeNamespace }; interface SetupSavedObjectsParams { service: PublicMethodsOf; diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index d99f447314a6b..971d450be7880 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -44,7 +44,7 @@ export class SpacesPlugin implements Plugin, plugins: PluginsSetup) { + public setup(core: CoreSetup, plugins: PluginsSetup) { this.spacesManager = new SpacesManager(core.http); this.spacesApi = { ui: getUiApi({ @@ -98,7 +98,7 @@ export class SpacesPlugin implements Plugin ): SpacesApiUi['redirectLegacyUrl'] { - return async function (path: string) { + return async function (path: string, objectNoun: string = DEFAULT_OBJECT_NOUN) { const [{ notifications, application }] = await getStartServices(); const { currentAppId$, navigateToApp } = application; const appId = await firstValueFrom(currentAppId$); // retrieve the most recent value from the BehaviorSubject const title = i18n.translate('xpack.spaces.shareToSpace.redirectLegacyUrlToast.title', { - defaultMessage: 'Redirected from legacy URL', + defaultMessage: `We redirected you to a new URL`, }); const text = i18n.translate('xpack.spaces.shareToSpace.redirectLegacyUrlToast.text', { - defaultMessage: - 'You used a legacy URL to navigate to this page. This URL changed in Kibana 8.0, so we redirected you to the new one. You should use this new URL in the future.', + defaultMessage: `The {objectNoun} you're looking for has a new location. Use this URL from now on.`, + values: { objectNoun }, }); notifications.toasts.addInfo({ title, text }); navigateToApp(appId!, { replace: true, path }); diff --git a/x-pack/plugins/spaces/public/ui_api/index.ts b/x-pack/plugins/spaces/public/ui_api/index.ts index 9646449ec0c31..e278eb691910f 100644 --- a/x-pack/plugins/spaces/public/ui_api/index.ts +++ b/x-pack/plugins/spaces/public/ui_api/index.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { StartServicesAccessor } from 'src/core/public'; +import type { StartServicesAccessor } from 'src/core/public'; import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; -import { PluginsStart } from '../plugin'; -import { SpacesManager } from '../spaces_manager'; +import type { PluginsStart } from '../plugin'; +import type { SpacesManager } from '../spaces_manager'; import { getComponents } from './components'; import { createRedirectLegacyUrl } from '../share_saved_objects_to_space'; From 94799fdaf76f316ad21846c920e958226cc730b3 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 10 Feb 2021 14:33:05 -0500 Subject: [PATCH 21/29] Change 'enableSpaceAgnosticBehavior' field to 'behaviorContext' --- src/plugins/spaces_oss/public/api.ts | 17 ++++++++++------- .../job_spaces_list/job_spaces_list.tsx | 4 ++-- .../share_to_space_flyout_internal.test.tsx | 12 ++++++------ .../share_to_space_flyout_internal.tsx | 3 ++- .../space_list/space_list_internal.test.tsx | 11 +++++++---- .../public/space_list/space_list_internal.tsx | 4 ++-- 6 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index e57d071fc4f7b..612438a4a8431 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -126,12 +126,13 @@ export interface ShareToSpaceFlyoutProps { */ enableCreateNewSpaceLink?: boolean; /** - * When enabled, the flyout will allow the user to remove the object from the current space. Otherwise, the current space is noted, and - * the user cannot interact with it. + * When set to 'within-space' (default), the flyout behaves like it is running on a page within the active space, and it will prevent the + * user from removing the object from the active space. * - * Default value is false. + * Conversely, when set to 'outside-space', the flyout behaves like it is running on a page outside of any space, so it will allow the + * user to remove the object from the active space. */ - enableSpaceAgnosticBehavior?: boolean; + behaviorContext?: 'within-space' | 'outside-space'; /** * Optional handler that is called when the user has saved changes and there are spaces to be added to and/or removed from the object. If * this is not defined, a default handler will be used that calls `/api/spaces/_share_saved_object_add` and/or @@ -200,11 +201,13 @@ export interface SpaceListProps { */ displayLimit?: number; /** - * When enabled, the space list will omit the active space. Otherwise, the active space is displayed. + * When set to 'within-space' (default), the space list behaves like it is running on a page within the active space, and it will omit the + * active space (e.g., it displays a list of all the _other_ spaces that an object is shared to). * - * Default value is false. + * Conversely, when set to 'outside-space', the space list behaves like it is running on a page outside of any space, so it will not omit + * the active space. */ - enableSpaceAgnosticBehavior?: boolean; + behaviorContext?: 'within-space' | 'outside-space'; } /** diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx index 55984d74260e3..6e0715de12fb9 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx +++ b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx @@ -75,7 +75,7 @@ export const JobSpacesList: FC = ({ spacesApi, spaceIds, jobId, jobType, title: jobId, noun: objectNoun, }, - enableSpaceAgnosticBehavior: true, + behaviorContext: 'outside-space', changeSpacesHandler, onClose, }; @@ -83,7 +83,7 @@ export const JobSpacesList: FC = ({ spacesApi, spaceIds, jobId, jobType, return ( <> setShowFlyout(true)} style={{ height: 'auto' }}> - + {showFlyout && } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx index 21d987afb9149..81dd011400745 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx @@ -37,7 +37,7 @@ interface SetupOpts { canShareToAllSpaces?: boolean; // default: true enableCreateCopyCallout?: boolean; enableCreateNewSpaceLink?: boolean; - enableSpaceAgnosticBehavior?: boolean; + behaviorContext?: 'within-space' | 'outside-space'; mockFeatureId?: string; // optional feature ID to use for the SpacesContext } @@ -117,7 +117,7 @@ const setup = async (opts: SetupOpts = {}) => { onClose={onClose} enableCreateCopyCallout={opts.enableCreateCopyCallout} enableCreateNewSpaceLink={opts.enableCreateNewSpaceLink} - enableSpaceAgnosticBehavior={opts.enableSpaceAgnosticBehavior} + behaviorContext={opts.behaviorContext} />
); @@ -674,7 +674,7 @@ describe('ShareToSpaceFlyout', () => { expect(option.disabled).toBeUndefined(); }; - describe('without enableSpaceAgnosticBehavior', () => { + describe('with behaviorContext="within-space" (default)', () => { it('correctly defines space selection options', async () => { const namespaces = ['my-active-space', 'space-1', 'space-3']; // the saved object's current namespaces const { wrapper } = await setup({ mockSpaces, namespaces }); @@ -724,12 +724,12 @@ describe('ShareToSpaceFlyout', () => { }); }); - describe('with enableSpaceAgnosticBehavior', () => { - const enableSpaceAgnosticBehavior = true; + describe('with behaviorContext="outside-space"', () => { + const behaviorContext = 'outside-space'; it('correctly defines space selection options', async () => { const namespaces = ['my-active-space', 'space-1', 'space-3']; // the saved object's current namespaces - const { wrapper } = await setup({ enableSpaceAgnosticBehavior, mockSpaces, namespaces }); + const { wrapper } = await setup({ behaviorContext, mockSpaces, namespaces }); const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); const options = selectable.prop('options'); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx index 8f665a704c8e8..1a1a251039029 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -117,7 +117,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { }), enableCreateCopyCallout = false, enableCreateNewSpaceLink = false, - enableSpaceAgnosticBehavior = false, + behaviorContext, changeSpacesHandler = createDefaultChangeSpacesHandler( savedObjectTarget, spacesManager, @@ -126,6 +126,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { onUpdate = () => null, onClose = () => null, } = props; + const enableSpaceAgnosticBehavior = behaviorContext === 'outside-space'; const [shareOptions, setShareOptions] = useState({ selectedSpaceIds: [], diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx index 3da59cbe8cdc7..552795a2a2a80 100644 --- a/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx @@ -36,8 +36,8 @@ const getSpaceData = (inactiveSpaceCount: number = 0) => { /** * This node displays up to five named spaces (and an indicator for any number of unauthorized spaces) by default. The active space is - * omitted from this list unless enableSpaceAgnosticBehavior is enabled. If more than five named spaces would be displayed, the extras - * (along with the unauthorized spaces indicator, if present) are hidden behind a button. + * omitted from this list unless behaviorContext='outside-space'. If more than five named spaces would be displayed, the extras (along with + * the unauthorized spaces indicator, if present) are hidden behind a button. * If '*' (aka "All spaces") is present, it supersedes all of the above and just displays a single badge without a button. */ describe('SpaceListInternal', () => { @@ -264,8 +264,11 @@ describe('SpaceListInternal', () => { expect(getButton(wrapper)).toHaveLength(0); }); - it('with enableSpaceAgnosticBehavior=true, shows badges with button', async () => { - const props = { namespaces: [...namespaces, '?'], enableSpaceAgnosticBehavior: true }; + it('with behaviorContext="outside-space", shows badges with button', async () => { + const props: SpaceListProps = { + namespaces: [...namespaces, '?'], + behaviorContext: 'outside-space', + }; const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['D!', 'A', 'B', 'C', 'D']); diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx index 16c4de1eaa5f5..326eb8ff4dc76 100644 --- a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx @@ -23,7 +23,7 @@ const DEFAULT_DISPLAY_LIMIT = 5; export const SpaceListInternal = ({ namespaces, displayLimit = DEFAULT_DISPLAY_LIMIT, - enableSpaceAgnosticBehavior, + behaviorContext, }: SpaceListProps) => { const { shareToSpacesDataPromise } = useSpaces(); @@ -66,7 +66,7 @@ export const SpaceListInternal = ({ if (spaceTarget === undefined) { // in the event that a new space was created after this page has loaded, fall back to displaying the space ID enabledSpaceTargets.push({ id: namespace, name: namespace }); - } else if (enableSpaceAgnosticBehavior || !spaceTarget.isActiveSpace) { + } else if (behaviorContext === 'outside-space' || !spaceTarget.isActiveSpace) { if (spaceTarget.isFeatureDisabled) { disabledSpaceTargets.push(spaceTarget); } else { From 1dca5ebd5cb4fa78365ebb90df7fb6b42a6214dd Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 11 Feb 2021 09:35:00 -0500 Subject: [PATCH 22/29] Even more PR review feedback --- src/plugins/spaces_oss/public/api.ts | 37 +++++----- .../components/copy_to_space_flyout.test.tsx | 4 -- x-pack/plugins/spaces/public/plugin.tsx | 6 +- .../components/selectable_spaces_control.tsx | 20 ++++-- .../share_to_space_flyout_internal.test.tsx | 22 +++--- .../share_to_space_flyout_internal.tsx | 7 +- .../components/share_to_space_form.tsx | 70 +++++++++---------- .../utils/redirect_legacy_url.ts | 2 +- .../space_list/space_list_internal.test.tsx | 8 +-- .../public/space_list/space_list_internal.tsx | 25 ++++--- 10 files changed, 105 insertions(+), 96 deletions(-) diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index 612438a4a8431..2d5e144158d78 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -70,13 +70,30 @@ export interface SpacesApiUiComponent { */ ShareToSpaceFlyout: FunctionComponent; /** - * Displays a corresponding list of spaces for a given list of saved object namespaces. + * Displays a corresponding list of spaces for a given list of saved object namespaces. It shows up to five spaces (and an indicator for + * any number of spaces that the user is not authorized to see) by default. If more than five named spaces would be displayed, the extras + * (along with the unauthorized spaces indicator, if present) are hidden behind a button. If '*' (aka "All spaces") is present, it + * supersedes all of the above and just displays a single badge without a button. * * Note: must be rendered inside of a SpacesContext. */ SpaceList: FunctionComponent; /** - * Displays a warning callout when a user encounters a legacy URL alias conflict. + * Displays a callout that needs to be used if a call to `SavedObjectsClient.resolve()` results in an `"conflict"` outcome, which + * indicates that the user has loaded the page which is associated directly with one object (A), *and* with a legacy URL that points to a + * different object (B). + * + * In this case, `SavedObjectsClient.resolve()` has returned object A. This component displays a warning callout to the user explaining + * that there is a conflict, and it includes a button that will redirect the user to object B when clicked. + * + * Consumers need to determine the local path for the new URL on their own, based on the object ID that was used to call + * `SavedObjectsClient.resolve()` (A) and the `aliasTargetId` value in the response (B). For example... + * + * A is `workpad-123` and B is `workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e`. + * + * Full legacy URL: `https://localhost:5601/app/canvas#/workpad/workpad-123/page/1` + * + * New URL path: `#/workpad/workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e/page/1` */ LegacyUrlConflict: FunctionComponent; } @@ -212,22 +229,6 @@ export interface SpaceListProps { /** * @public - * - * Displays a callout that. This needs to be used if a call to `SavedObjectsClient.resolve()` results in an `"conflict"` outcome, which - * indicates that the user has loaded the page which is associated directly with one object (A), *and* with a legacy URL that points to a - * different object (B). - * - * In this case, `SavedObjectsClient.resolve()` has returned object A. This component displays a callout to the user explaining that there - * is a conflict, and it includes a button that will redirect the user to object B when clicked. - * - * Consumers need to determine the local path for the new URL on their own, based on the object ID that was used to call - * `SavedObjectsClient.resolve()` (A) and the `aliasTargetId` value in the response (B). For example... - * - * A is `workpad-123` and B is `workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e`. - * - * Full legacy URL: `https://localhost:5601/app/canvas#/workpad/workpad-123/page/1` - * - * New URL path: `#/workpad/workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e/page/1` */ export interface LegacyUrlConflictProps { /** 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 d5d05795200a4..c880e3144fac0 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 @@ -96,10 +96,6 @@ const setup = async (opts: SetupOpts = {}) => { }; describe('CopyToSpaceFlyout', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - it('waits for spaces to load', async () => { const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 971d450be7880..2d02d4a3b98d8 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { CoreSetup, CoreStart, Plugin, StartServicesAccessor } from 'src/core/public'; +import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { SpacesOssPluginSetup, SpacesApi } from 'src/plugins/spaces_oss/public'; import { HomePublicPluginSetup } from 'src/plugins/home/public'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; @@ -49,7 +49,7 @@ export class SpacesPlugin implements Plugin, + getStartServices: core.getStartServices, }), activeSpace$: this.spacesManager.onActiveSpaceChange$, getActiveSpace: () => this.spacesManager.getActiveSpace(), @@ -63,7 +63,7 @@ export class SpacesPlugin implements Plugin, + getStartServices: core.getStartServices, spacesManager: this.spacesManager, }); } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index f4fa1cd5593ca..3a095ce8f3379 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -40,7 +40,11 @@ interface Props { type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; const ROW_HEIGHT = 40; -const APPEND_ACTIVE_SPACE = Current; +const APPEND_ACTIVE_SPACE = ( + + {i18n.translate('xpack.spaces.shareToSpace.currentSpaceBadge', { defaultMessage: 'Current' })} + +); const APPEND_CANNOT_SELECT = ( { return null; }; - // if space-agnostic behavior is not enabled, the active space is not selected or deselected by the user, so we have to artifically pad the count for this label + // if space-agnostic behavior is not enabled, the active space is not selected or deselected by the user, so we have to artificially pad the count for this label const selectedCountPad = enableSpaceAgnosticBehavior ? 0 : 1; const selectedCount = selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID && id !== UNKNOWN_SPACE).length + @@ -231,7 +235,8 @@ function getAdditionalProps( disabled: true, checked: 'on' as 'on', }; - } else if (space.cannotShareToSpace) { + } + if (space.cannotShareToSpace) { return { append: ( <> @@ -241,7 +246,8 @@ function getAdditionalProps( ), disabled: true, }; - } else if (space.isFeatureDisabled) { + } + if (space.isFeatureDisabled) { return { append: APPEND_FEATURE_IS_DISABLED, }; @@ -256,9 +262,11 @@ function createSpacesComparator(activeSpaceId: string | false) { return (a: ShareToSpaceTarget, b: ShareToSpaceTarget) => { if (a.id === activeSpaceId) { return -1; - } else if (b.id === activeSpaceId) { + } + if (b.id === activeSpaceId) { return 1; - } else if (a.isFeatureDisabled !== b.isFeatureDisabled) { + } + if (a.isFeatureDisabled !== b.isFeatureDisabled) { return a.isFeatureDisabled ? 1 : -1; } return 0; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx index 81dd011400745..c3ea515e804b6 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx @@ -137,9 +137,9 @@ const setup = async (opts: SetupOpts = {}) => { }; describe('ShareToSpaceFlyout', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); + // beforeAll(() => { + // jest.useFakeTimers(); + // }); it('waits for spaces to load', async () => { const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); @@ -282,9 +282,9 @@ describe('ShareToSpaceFlyout', () => { 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')); - }); + mockSpacesManager.shareSavedObjectAdd.mockRejectedValue( + Boom.serverUnavailable('Something bad happened') + ); expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); @@ -313,9 +313,9 @@ describe('ShareToSpaceFlyout', () => { 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')); - }); + mockSpacesManager.shareSavedObjectRemove.mockRejectedValue( + Boom.serverUnavailable('Something bad happened') + ); expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); @@ -464,8 +464,8 @@ describe('ShareToSpaceFlyout', () => { ) { const iconTip = wrapper.find(EuiIconTip); return { - checked: !!wrapper.prop('checked'), - disabled: !!wrapper.prop('disabled'), + checked: wrapper.prop('checked'), + disabled: wrapper.prop('disabled'), ...(iconTip.length > 0 && { tooltip: iconTip.prop('content') as string }), }; } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx index 1a1a251039029..8d9875977af18 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -243,7 +243,10 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { return ; } - const showShareWarning = + // If the object has not been shared yet (e.g., it currently exists in exactly one space), and there is at least one space that we could + // share this object to, we want to display a callout to the user that explains the ramifications of shared objects. They might actually + // want to make a copy instead, so this callout contains a link that opens the Copy flyout. + const showCreateCopyCallout = enableCreateCopyCallout && spaces.length > 1 && savedObjectTarget.namespaces.length === 1 && @@ -255,7 +258,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { objectNoun={savedObjectTarget.noun} shareOptions={shareOptions} onUpdate={setShareOptions} - showShareWarning={showShareWarning} + showCreateCopyCallout={showCreateCopyCallout} canShareToAllSpaces={canShareToAllSpaces} makeCopy={() => setShowMakeCopy(true)} enableCreateNewSpaceLink={enableCreateNewSpaceLink} 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 index 8bebf73dbcdee..2db93a7aa3c12 100644 --- 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 @@ -18,7 +18,7 @@ interface Props { objectNoun: string; onUpdate: (shareOptions: ShareOptions) => void; shareOptions: ShareOptions; - showShareWarning: boolean; + showCreateCopyCallout: boolean; canShareToAllSpaces: boolean; makeCopy: () => void; enableCreateNewSpaceLink: boolean; @@ -31,7 +31,7 @@ export const ShareToSpaceForm = (props: Props) => { objectNoun, onUpdate, shareOptions, - showShareWarning, + showCreateCopyCallout, canShareToAllSpaces, makeCopy, enableCreateNewSpaceLink, @@ -41,48 +41,42 @@ export const ShareToSpaceForm = (props: Props) => { const setSelectedSpaceIds = (selectedSpaceIds: string[]) => onUpdate({ ...shareOptions, selectedSpaceIds }); - const getShareWarning = () => { - if (!showShareWarning) { - return null; - } - - return ( - - - } - color="warning" - > + const createCopyCallout = showCreateCopyCallout ? ( + + makeCopy()}> - - - ), - }} + id="xpack.spaces.shareToSpace.shareWarningTitle" + defaultMessage="Changes will be synchronized across spaces" /> - + } + color="warning" + > + makeCopy()}> + + + ), + }} + /> + - - - ); - }; + + + ) : null; return (
- {getShareWarning()} + {createCopyCallout} { }; /** - * This node displays up to five named spaces (and an indicator for any number of unauthorized spaces) by default. The active space is - * omitted from this list unless behaviorContext='outside-space'. If more than five named spaces would be displayed, the extras (along with - * the unauthorized spaces indicator, if present) are hidden behind a button. - * If '*' (aka "All spaces") is present, it supersedes all of the above and just displays a single badge without a button. + * Displays a corresponding list of spaces for a given list of saved object namespaces. It shows up to five spaces (and an indicator for any + * number of spaces that the user is not authorized to see) by default. If more than five named spaces would be displayed, the extras (along + * with the unauthorized spaces indicator, if present) are hidden behind a button. If '*' (aka "All spaces") is present, it supersedes all + * of the above and just displays a single badge without a button. */ describe('SpaceListInternal', () => { const createSpaceList = async ({ diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx index 326eb8ff4dc76..b0250105885d2 100644 --- a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx @@ -20,6 +20,12 @@ import { SpaceAvatar } from '../space_avatar'; const DEFAULT_DISPLAY_LIMIT = 5; +/** + * Displays a corresponding list of spaces for a given list of saved object namespaces. It shows up to five spaces (and an indicator for any + * number of spaces that the user is not authorized to see) by default. If more than five named spaces would be displayed, the extras (along + * with the unauthorized spaces indicator, if present) are hidden behind a button. If '*' (aka "All spaces") is present, it supersedes all + * of the above and just displays a single badge without a button. + */ export const SpaceListInternal = ({ namespaces, displayLimit = DEFAULT_DISPLAY_LIMIT, @@ -40,8 +46,8 @@ export const SpaceListInternal = ({ return null; } - const isSharedToAllSpaces = namespaces?.includes(ALL_SPACES_ID); - const unauthorizedCount = (namespaces?.filter((namespace) => namespace === UNKNOWN_SPACE) ?? []) + const isSharedToAllSpaces = namespaces.includes(ALL_SPACES_ID); + const unauthorizedSpacesCount = namespaces.filter((namespace) => namespace === UNKNOWN_SPACE) .length; let displayedSpaces: ShareToSpaceTarget[]; let button: ReactNode = null; @@ -58,7 +64,7 @@ export const SpaceListInternal = ({ }, ]; } else { - const authorized = namespaces?.filter((namespace) => namespace !== UNKNOWN_SPACE) ?? []; + const authorized = namespaces.filter((namespace) => namespace !== UNKNOWN_SPACE); const enabledSpaceTargets: ShareToSpaceTarget[] = []; const disabledSpaceTargets: ShareToSpaceTarget[] = []; authorized.forEach((namespace) => { @@ -95,7 +101,8 @@ export const SpaceListInternal = ({ id="xpack.spaces.spaceList.showMoreSpacesLink" defaultMessage="+{count} more" values={{ - count: authorizedSpaceTargets.length + unauthorizedCount - displayedSpaces.length, + count: + authorizedSpaceTargets.length + unauthorizedSpacesCount - displayedSpaces.length, }} /> @@ -103,18 +110,18 @@ export const SpaceListInternal = ({ } } - const unauthorizedCountBadge = - !isSharedToAllSpaces && (isExpanded || button === null) && unauthorizedCount > 0 ? ( + const unauthorizedSpacesCountBadge = + !isSharedToAllSpaces && (isExpanded || button === null) && unauthorizedSpacesCount > 0 ? ( } > - +{unauthorizedCount} + +{unauthorizedSpacesCount} ) : null; @@ -130,7 +137,7 @@ export const SpaceListInternal = ({ ); })} - {unauthorizedCountBadge} + {unauthorizedSpacesCountBadge} {button} ); From 497890f4149fb59b734acf8aad984b9a531d7e8d Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 11 Feb 2021 16:04:08 -0500 Subject: [PATCH 23/29] Text changes --- .../legacy_url_conflict_internal.tsx | 4 ++-- .../components/share_mode_control.tsx | 23 +++++++++++++++++-- .../components/share_to_space_form.tsx | 7 +++--- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx index ccbfa7b5bbed5..1157725c69ee2 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx @@ -67,13 +67,13 @@ export const LegacyUrlConflictInternal = (props: InternalProps & LegacyUrlConfli title={ } > diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx index ed6e54bdaa770..a57686bc4a490 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx @@ -13,6 +13,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIconTip, + EuiLink, EuiLoadingSpinner, EuiSpacer, EuiText, @@ -21,6 +22,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { ALL_SPACES_ID } from '../../../common/constants'; +import { DocumentationLinksService } from '../../lib'; +import { useSpaces } from '../../spaces_context'; import { ShareToSpaceTarget } from '../../types'; import { ShareOptions } from '../types'; @@ -75,6 +78,8 @@ export const ShareModeControl = (props: Props) => { enableCreateNewSpaceLink, enableSpaceAgnosticBehavior, } = props; + const { services } = useSpaces(); + const { docLinks } = services; if (spaces.length === 0) { return ; @@ -129,6 +134,10 @@ export const ShareModeControl = (props: Props) => { return null; } + const kibanaPrivilegesUrl = new DocumentationLinksService( + docLinks! + ).getKibanaPrivilegesDocUrl(); + return ( <> { > + + + ), + }} /> 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 index 2db93a7aa3c12..49c581b07004b 100644 --- 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 @@ -48,21 +48,20 @@ export const ShareToSpaceForm = (props: Props) => { title={ } color="warning" > makeCopy()}> ), From e0948efcfaccb284082de33a8b07e1b2cea2a8e5 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 11 Feb 2021 16:31:29 -0500 Subject: [PATCH 24/29] Update UI text again --- .../components/selectable_spaces_control.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index 3a095ce8f3379..5fdd07369f377 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -67,7 +67,7 @@ const APPEND_FEATURE_IS_DISABLED = ( Date: Thu, 11 Feb 2021 16:34:03 -0500 Subject: [PATCH 25/29] Whoops --- .../components/selectable_spaces_control.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index 5fdd07369f377..da1b36d57b2ae 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -66,8 +66,7 @@ const APPEND_CANNOT_DESELECT = ( const APPEND_FEATURE_IS_DISABLED = ( Date: Thu, 11 Feb 2021 19:45:46 -0500 Subject: [PATCH 26/29] Last feedback --- .../components/selectable_spaces_control.tsx | 2 +- .../components/share_mode_control.tsx | 2 +- .../components/share_to_space_flyout_internal.test.tsx | 8 ++------ 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index da1b36d57b2ae..1b5870b8b540d 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -139,7 +139,7 @@ export const SelectableSpacesControl = (props: Props) => { defaultMessage="To view hidden spaces, you need {additionalPrivilegesLink}." values={{ additionalPrivilegesLink: ( - + { values={{ objectNoun, readAndWritePrivilegesLink: ( - + { }; describe('ShareToSpaceFlyout', () => { - // beforeAll(() => { - // jest.useFakeTimers(); - // }); - it('waits for spaces to load', async () => { const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); @@ -619,7 +615,7 @@ describe('ShareToSpaceFlyout', () => { /> @@ -656,7 +652,7 @@ describe('ShareToSpaceFlyout', () => { expect(option.append).toMatchInlineSnapshot(` From 265b85c9aa342e4e00c861aae12102f2dd9c0d3b Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 12 Feb 2021 09:59:10 -0500 Subject: [PATCH 27/29] Support ML DFA jobs --- .../analytics_list/analytics_list.tsx | 54 +++++++++++-------- .../components/analytics_list/use_columns.tsx | 43 +++++++-------- .../jobs_list_page/jobs_list_page.tsx | 2 +- 3 files changed, 54 insertions(+), 45 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index dc5b494d0e181..89d853570966e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -29,6 +29,7 @@ import { import { getAnalyticsFactory } from '../../services/analytics_service'; import { getTaskStateBadge, getJobTypeBadge, useColumns } from './use_columns'; import { ExpandedRow } from './expanded_row'; +import type { SpacesPluginStart } from '../../../../../../../../spaces/public'; import { AnalyticStatsBarStats, StatsBar } from '../../../../../components/stats_bar'; import { CreateAnalyticsButton } from '../create_analytics_button'; import { SourceSelection } from '../source_selection'; @@ -36,9 +37,12 @@ import { filterAnalytics } from '../../../../common/search_bar_filters'; import { AnalyticsEmptyPrompt } from './empty_prompt'; import { useTableSettings } from './use_table_settings'; import { RefreshAnalyticsListButton } from '../refresh_analytics_list_button'; +import { PLUGIN_ID } from '../../../../../../../common/constants/app'; import { ListingPageUrlState } from '../../../../../../../common/types/common'; import { JobsAwaitingNodeWarning } from '../../../../../components/jobs_awaiting_node_warning'; +const EmptyFunctionComponent: React.FC = ({ children }) => <>{children}; + const filters: EuiSearchBarProps['filters'] = [ { type: 'field_value_selection', @@ -84,7 +88,7 @@ function getItemIdToExpandedRowMap( interface Props { isManagementTable?: boolean; isMlEnabledInSpace?: boolean; - spacesEnabled?: boolean; + spacesApi?: SpacesPluginStart; blockRefresh?: boolean; pageState: ListingPageUrlState; updatePageState: (update: Partial) => void; @@ -92,7 +96,7 @@ interface Props { export const DataFrameAnalyticsList: FC = ({ isManagementTable = false, isMlEnabledInSpace = true, - spacesEnabled = false, + spacesApi, blockRefresh = false, pageState, updatePageState, @@ -178,7 +182,7 @@ export const DataFrameAnalyticsList: FC = ({ setExpandedRowItemIds, isManagementTable, isMlEnabledInSpace, - spacesEnabled, + spacesApi, refresh ); @@ -261,6 +265,8 @@ export const DataFrameAnalyticsList: FC = ({ filters, }; + const ContextWrapper = spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent; + return (
{modals} @@ -284,26 +290,28 @@ export const DataFrameAnalyticsList: FC = ({
- - allowNeutralSort={false} - columns={columns} - hasActions={false} - isExpandable={true} - itemIdToExpandedRowMap={itemIdToExpandedRowMap} - isSelectable={false} - items={analytics} - itemId={DataFrameAnalyticsListColumn.id} - loading={isLoading} - onTableChange={onTableChange} - pagination={pagination} - sorting={sorting} - search={search} - data-test-subj={isLoading ? 'mlAnalyticsTable loading' : 'mlAnalyticsTable loaded'} - rowProps={(item) => ({ - 'data-test-subj': `mlAnalyticsTableRow row-${item.id}`, - })} - error={searchError} - /> + + + allowNeutralSort={false} + columns={columns} + hasActions={false} + isExpandable={true} + itemIdToExpandedRowMap={itemIdToExpandedRowMap} + isSelectable={false} + items={analytics} + itemId={DataFrameAnalyticsListColumn.id} + loading={isLoading} + onTableChange={onTableChange} + pagination={pagination} + sorting={sorting} + search={search} + data-test-subj={isLoading ? 'mlAnalyticsTable loading' : 'mlAnalyticsTable loaded'} + rowProps={(item) => ({ + 'data-test-subj': `mlAnalyticsTableRow row-${item.id}`, + })} + error={searchError} + /> +
{isSourceIndexModalVisible === true && ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index e2e8c497ef05b..cb0e2b0092c55 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -33,7 +33,8 @@ import { import { useActions } from './use_actions'; import { useMlLink } from '../../../../../contexts/kibana'; import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator'; -// import { JobSpacesList } from '../../../../../components/job_spaces_list'; +import type { SpacesPluginStart } from '../../../../../../../../spaces/public'; +import { JobSpacesList } from '../../../../../components/job_spaces_list'; enum TASK_STATE_COLOR { analyzing = 'primary', @@ -150,7 +151,7 @@ export const useColumns = ( setExpandedRowItemIds: React.Dispatch>, isManagementTable: boolean = false, isMlEnabledInSpace: boolean = true, - spacesEnabled: boolean = true, + spacesApi?: SpacesPluginStart, refresh: () => void = () => {} ) => { const { actions, modals } = useActions(isManagementTable); @@ -281,25 +282,25 @@ export const useColumns = ( ]; if (isManagementTable === true) { - // Note: this code path is commented because it is currently unreachable, it will need to be refactored to use the SpacesApi - // if (spacesEnabled === true) { - // // insert before last column - // columns.splice(columns.length - 1, 0, { - // name: i18n.translate('xpack.ml.jobsList.analyticsSpacesLabel', { - // defaultMessage: 'Spaces', - // }), - // render: (item: DataFrameAnalyticsListRow) => - // Array.isArray(item.spaceIds) ? ( - // - // ) : null, - // width: '90px', - // }); - // } + if (spacesApi) { + // insert before last column + columns.splice(columns.length - 1, 0, { + name: i18n.translate('xpack.ml.jobsList.analyticsSpacesLabel', { + defaultMessage: 'Spaces', + }), + render: (item: DataFrameAnalyticsListRow) => + Array.isArray(item.spaceIds) ? ( + + ) : null, + width: '90px', + }); + } // Remove actions if Ml not enabled in current space if (isMlEnabledInSpace === false) { columns.pop(); diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index 5bf488c2721f0..9080a1a913b48 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -103,7 +103,7 @@ function useTabs(isMlEnabledInSpace: boolean, spacesApi: SpacesPluginStart | und From 9d77ce8619bca5147a0a5cb11468355f838c05b8 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 12 Feb 2021 10:10:26 -0500 Subject: [PATCH 28/29] Fix ESO migration unit test --- .../server/create_migration.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts index 16f9679da481f..4df51af8b16b0 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts @@ -113,7 +113,7 @@ describe('createMigration()', () => { }); describe('migration of a single legacy type', () => { - it('uses the input type as the mirgation type when omitted', async () => { + it('uses the input type as the migration type when omitted', async () => { const serviceWithLegacyType = encryptedSavedObjectsServiceMock.create(); const instantiateServiceWithLegacyType = jest.fn(() => serviceWithLegacyType); @@ -167,15 +167,16 @@ describe('createMigration()', () => { }); describe('uses the object `namespaces` field to populate the descriptor when the migration context indicates this type is being converted', () => { - const doTest = async ({ + const doTest = ({ objectNamespace, decryptDescriptorNamespace, }: { objectNamespace: string | undefined; decryptDescriptorNamespace: string | undefined; }) => { - const serviceWithLegacyType = encryptedSavedObjectsServiceMock.create(); - const instantiateServiceWithLegacyType = jest.fn(() => serviceWithLegacyType); + const instantiateServiceWithLegacyType = jest.fn(() => + encryptedSavedObjectsServiceMock.create() + ); const migrationCreator = getCreateMigration( encryptionSavedObjectService, @@ -192,7 +193,7 @@ describe('createMigration()', () => { firstAttr: 'first_attr', }; - serviceWithLegacyType.decryptAttributesSync.mockReturnValueOnce(attributes); + encryptionSavedObjectService.decryptAttributesSync.mockReturnValueOnce(attributes); encryptionSavedObjectService.encryptAttributesSync.mockReturnValueOnce(attributes); noopMigration( @@ -208,7 +209,7 @@ describe('createMigration()', () => { }) ); - expect(serviceWithLegacyType.decryptAttributesSync).toHaveBeenCalledWith( + expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith( { id: '123', type: 'known-type-1', From d28bbda435c18954c30782f022fd719d9483211c Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 12 Feb 2021 15:47:13 -0500 Subject: [PATCH 29/29] Fix SpacesContext wrapper The wrapper would recreate the underlying context object each time it is re-rendered. That was not a problem for the Saved Objects Management page, which only rendered it once -- but it turned out to be a problem for the Machine Learning Jobs management page which re-renders all of its children multiple times. --- .../analytics_list/analytics_list.tsx | 47 +++---- .../components/jobs_list/jobs_list.js | 60 ++++----- .../jobs_list_page/jobs_list_page.tsx | 123 +++++++++--------- .../spaces/public/spaces_context/wrapper.tsx | 30 +++-- 4 files changed, 132 insertions(+), 128 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index 89d853570966e..8423e569a99f2 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -37,12 +37,9 @@ import { filterAnalytics } from '../../../../common/search_bar_filters'; import { AnalyticsEmptyPrompt } from './empty_prompt'; import { useTableSettings } from './use_table_settings'; import { RefreshAnalyticsListButton } from '../refresh_analytics_list_button'; -import { PLUGIN_ID } from '../../../../../../../common/constants/app'; import { ListingPageUrlState } from '../../../../../../../common/types/common'; import { JobsAwaitingNodeWarning } from '../../../../../components/jobs_awaiting_node_warning'; -const EmptyFunctionComponent: React.FC = ({ children }) => <>{children}; - const filters: EuiSearchBarProps['filters'] = [ { type: 'field_value_selection', @@ -265,8 +262,6 @@ export const DataFrameAnalyticsList: FC = ({ filters, }; - const ContextWrapper = spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent; - return (
{modals} @@ -290,28 +285,26 @@ export const DataFrameAnalyticsList: FC = ({
- - - allowNeutralSort={false} - columns={columns} - hasActions={false} - isExpandable={true} - itemIdToExpandedRowMap={itemIdToExpandedRowMap} - isSelectable={false} - items={analytics} - itemId={DataFrameAnalyticsListColumn.id} - loading={isLoading} - onTableChange={onTableChange} - pagination={pagination} - sorting={sorting} - search={search} - data-test-subj={isLoading ? 'mlAnalyticsTable loading' : 'mlAnalyticsTable loaded'} - rowProps={(item) => ({ - 'data-test-subj': `mlAnalyticsTableRow row-${item.id}`, - })} - error={searchError} - /> - + + allowNeutralSort={false} + columns={columns} + hasActions={false} + isExpandable={true} + itemIdToExpandedRowMap={itemIdToExpandedRowMap} + isSelectable={false} + items={analytics} + itemId={DataFrameAnalyticsListColumn.id} + loading={isLoading} + onTableChange={onTableChange} + pagination={pagination} + sorting={sorting} + search={search} + data-test-subj={isLoading ? 'mlAnalyticsTable loading' : 'mlAnalyticsTable loaded'} + rowProps={(item) => ({ + 'data-test-subj': `mlAnalyticsTableRow row-${item.id}`, + })} + error={searchError} + />
{isSourceIndexModalVisible === true && ( diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index 76690131c6883..261c58bebaaa8 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -16,7 +16,6 @@ import { ResultLinks, actionsMenuContent } from '../job_actions'; import { JobDescription } from './job_description'; import { JobIcon } from '../../../../components/job_message_icon'; import { JobSpacesList } from '../../../../components/job_spaces_list'; -import { PLUGIN_ID } from '../../../../../../common/constants/app'; import { TIME_FORMAT } from '../../../../../../common/constants/time_format'; import { EuiBasicTable, EuiButtonIcon, EuiScreenReaderOnly } from '@elastic/eui'; @@ -26,8 +25,6 @@ import { AnomalyDetectionJobIdLink } from './job_id_link'; const PAGE_SIZE_OPTIONS = [10, 25, 50]; -const EmptyFunctionComponent = ({ children }) => <>{children}; - // 'isManagementTable' bool prop to determine when to configure table for use in Kibana management page export class JobsList extends Component { constructor(props) { @@ -331,38 +328,35 @@ export class JobsList extends Component { }; const selectedJobsClass = this.props.selectedJobsCount ? 'jobs-selected' : ''; - const ContextWrapper = spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent; return ( - - ({ - 'data-test-subj': `mlJobListRow row-${item.id}`, - })} - /> - + ({ + 'data-test-subj': `mlJobListRow row-${item.id}`, + })} + /> ); } } diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index 9080a1a913b48..b61a28aff732a 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -23,6 +23,7 @@ import { EuiTabbedContentTab, } from '@elastic/eui'; +import { PLUGIN_ID } from '../../../../../../common/constants/app'; import { ManagementAppMountParams } from '../../../../../../../../../src/plugins/management/public/'; import { checkGetManagementMlJobsResolver } from '../../../../capabilities/check_capabilities'; @@ -66,6 +67,8 @@ function usePageState( return [pageState, updateState]; } +const EmptyFunctionComponent: React.FC = ({ children }) => <>{children}; + function useTabs(isMlEnabledInSpace: boolean, spacesApi: SpacesPluginStart | undefined): Tab[] { const [adPageState, updateAdPageState] = usePageState(getDefaultAnomalyDetectionJobsListState()); const [dfaPageState, updateDfaPageState] = usePageState(getDefaultDFAListState()); @@ -182,70 +185,74 @@ export const JobsListPage: FC<{ return ; } + const ContextWrapper = spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent; + return ( - - - - - -

- {i18n.translate('xpack.ml.management.jobsList.jobsListTitle', { - defaultMessage: 'Machine Learning Jobs', - })} -

-
- - - {currentTabId === 'anomaly_detection_jobs' - ? anomalyDetectionDocsLabel - : analyticsDocsLabel} - - -
-
- - - - {i18n.translate('xpack.ml.management.jobsList.jobsListTagline', { - defaultMessage: 'View machine learning analytics and anomaly detection jobs.', - })} - - - - - {spacesEnabled && ( - <> - setShowSyncFlyout(true)}> - {i18n.translate('xpack.ml.management.jobsList.syncFlyoutButton', { - defaultMessage: 'Synchronize saved objects', - })} - - {showSyncFlyout && } - - - )} - {renderTabs()} - -
-
+ + + + + + +

+ {i18n.translate('xpack.ml.management.jobsList.jobsListTitle', { + defaultMessage: 'Machine Learning Jobs', + })} +

+
+ + + {currentTabId === 'anomaly_detection_jobs' + ? anomalyDetectionDocsLabel + : analyticsDocsLabel} + + +
+
+ + + + {i18n.translate('xpack.ml.management.jobsList.jobsListTagline', { + defaultMessage: 'View machine learning analytics and anomaly detection jobs.', + })} + + + + + {spacesEnabled && ( + <> + setShowSyncFlyout(true)}> + {i18n.translate('xpack.ml.management.jobsList.syncFlyoutButton', { + defaultMessage: 'Synchronize saved objects', + })} + + {showSyncFlyout && } + + + )} + {renderTabs()} + +
+
+
diff --git a/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx index 2e56a43f0f9fc..18112945ea738 100644 --- a/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx +++ b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx @@ -6,18 +6,30 @@ */ import React, { useState, useEffect, PropsWithChildren, useMemo } from 'react'; -import { StartServicesAccessor, CoreStart } from 'src/core/public'; +import { + StartServicesAccessor, + DocLinksStart, + ApplicationStart, + NotificationsStart, +} from 'src/core/public'; import type { SpacesContextProps } from '../../../../../src/plugins/spaces_oss/public'; import { createSpacesReactContext } from './context'; import { PluginsStart } from '../plugin'; import { SpacesManager } from '../spaces_manager'; import { ShareToSpacesData, ShareToSpaceTarget } from '../types'; +import { SpacesReactContext } from './types'; interface InternalProps { spacesManager: SpacesManager; getStartServices: StartServicesAccessor; } +interface Services { + application: ApplicationStart; + docLinks: DocLinksStart; + notifications: NotificationsStart; +} + async function getShareToSpacesData( spacesManager: SpacesManager, feature?: string @@ -47,26 +59,24 @@ async function getShareToSpacesData( const SpacesContextWrapper = (props: PropsWithChildren) => { const { spacesManager, getStartServices, feature, children } = props; - const [coreStart, setCoreStart] = useState(); + const [context, setContext] = useState | undefined>(); const shareToSpacesDataPromise = useMemo(() => getShareToSpacesData(spacesManager, feature), [ spacesManager, feature, ]); useEffect(() => { - getStartServices().then(([coreStartValue]) => { - setCoreStart(coreStartValue); + getStartServices().then(([coreStart]) => { + const { application, docLinks, notifications } = coreStart; + const services = { application, docLinks, notifications }; + setContext(createSpacesReactContext(services, spacesManager, shareToSpacesDataPromise)); }); - }, [getStartServices]); + }, [getStartServices, shareToSpacesDataPromise, spacesManager]); - if (!coreStart) { + if (!context) { return null; } - const { application, docLinks, notifications } = coreStart; - const services = { application, docLinks, notifications }; - const context = createSpacesReactContext(services, spacesManager, shareToSpacesDataPromise); - return {children}; };