mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
Sharing saved objects, phase 2.5 (#89344)
This commit is contained in:
parent
104eacb59a
commit
5c3c3efdd8
151 changed files with 3982 additions and 2264 deletions
|
@ -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
|
||||
|
|
|
@ -168,7 +168,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 <code>start</code>. |
|
||||
| [StringValidation](./kibana-plugin-core-public.stringvalidation.md) | Allows regex objects or a regex string |
|
||||
| [Toast](./kibana-plugin-core-public.toast.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.
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic';
|
||||
export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic';
|
||||
```
|
||||
|
|
|
@ -310,7 +310,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 <code>references</code> 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)<!-- -->. |
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[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
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
convertToMultiNamespaceTypeVersion?: string;
|
||||
```
|
|
@ -16,5 +16,7 @@ export interface SavedObjectMigrationContext
|
|||
|
||||
| Property | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md) | <code>string</code> | The version in which this object type is being converted to a multi-namespace type |
|
||||
| [log](./kibana-plugin-core-server.savedobjectmigrationcontext.log.md) | <code>SavedObjectsMigrationLogger</code> | logger instance to be used by the migration handler |
|
||||
| [migrationVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md) | <code>string</code> | The migration version that this migration function is defined for |
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[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
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
migrationVersion: string;
|
||||
```
|
|
@ -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.
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic';
|
||||
export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic';
|
||||
```
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[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'`<!-- -->.
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
aliasTargetId?: string;
|
||||
```
|
|
@ -15,6 +15,7 @@ export interface SavedObjectsResolveResponse<T = unknown>
|
|||
|
||||
| Property | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| [aliasTargetId](./kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md) | <code>string</code> | The ID of the object that the legacy URL alias points to. This is only defined when the outcome is <code>'aliasMatch'</code> or <code>'conflict'</code>. |
|
||||
| [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) | <code>'exactMatch' | 'aliasMatch' | 'conflict'</code> | The outcome for a successful <code>resolve</code> call is one of the following values:<!-- -->\* <code>'exactMatch'</code> -- One document exactly matched the given ID. \* <code>'aliasMatch'</code> -- One document with a legacy URL alias matched the given ID; in this case the <code>saved_object.id</code> field is different than the given ID. \* <code>'conflict'</code> -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the <code>saved_object</code> object is the exact match, and the <code>saved_object.id</code> field is the same as the given ID. |
|
||||
| [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | <code>SavedObject<T></code> | |
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
|
|
|
@ -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) | <code>string</code> | If defined, will be used to convert the type to an alias. |
|
||||
| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | <code>string</code> | 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) | <code>string</code> | 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) | <code>boolean</code> | Is the type hidden by default. If true, repositories will not have access to this type unless explicitly declared as an <code>extraType</code> when creating the repository.<!-- -->See [createInternalRepository](./kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md)<!-- -->. |
|
||||
| [indexPattern](./kibana-plugin-core-server.savedobjectstype.indexpattern.md) | <code>string</code> | 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) | <code>SavedObjectsTypeManagementDefinition</code> | An optional [saved objects management section](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) definition for the type. |
|
||||
|
|
|
@ -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
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[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
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
isShareable(type: string): boolean;
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| type | <code>string</code> | |
|
||||
|
||||
<b>Returns:</b>
|
||||
|
||||
`boolean`
|
||||
|
|
@ -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 <code>hidden=true</code> during registration. |
|
||||
| [isHidden(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ishidden.md) | | Returns the <code>hidden</code> property for given type, or <code>false</code> if the type is not registered. |
|
||||
| [isImportableAndExportable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isimportableandexportable.md) | | Returns the <code>management.importableAndExportable</code> property for given type, or <code>false</code> 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 <code>false</code> 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 <code>false</code> 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 <code>false</code> 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 <code>false</code> 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 <code>true</code> 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. |
|
||||
|
||||
|
|
|
@ -1385,7 +1385,7 @@ export interface SavedObjectsMigrationVersion {
|
|||
}
|
||||
|
||||
// @public
|
||||
export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic';
|
||||
export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic';
|
||||
|
||||
// @public (undocumented)
|
||||
export interface SavedObjectsStart {
|
||||
|
|
|
@ -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'.`
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
@ -374,7 +374,7 @@ function buildActiveMigrations(
|
|||
const migrationTransforms = Object.entries(migrationsMap ?? {}).map<Transform>(
|
||||
([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}`
|
||||
|
|
|
@ -21,9 +21,17 @@ export const createSavedObjectsMigrationLoggerMock = (): jest.Mocked<SavedObject
|
|||
return mock;
|
||||
};
|
||||
|
||||
const createContextMock = (): jest.Mocked<SavedObjectMigrationContext> => {
|
||||
const createContextMock = ({
|
||||
migrationVersion = '8.0.0',
|
||||
convertToMultiNamespaceTypeVersion,
|
||||
}: {
|
||||
migrationVersion?: string;
|
||||
convertToMultiNamespaceTypeVersion?: string;
|
||||
} = {}): jest.Mocked<SavedObjectMigrationContext> => {
|
||||
const mock = {
|
||||
log: createSavedObjectsMigrationLoggerMock(),
|
||||
migrationVersion,
|
||||
convertToMultiNamespaceTypeVersion,
|
||||
};
|
||||
return mock;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<SavedObjectsType>) => {
|
||||
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 });
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
|
@ -3200,10 +3249,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()
|
||||
);
|
||||
|
@ -3250,11 +3299,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);
|
||||
});
|
||||
});
|
||||
|
@ -3276,7 +3327,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),
|
||||
});
|
||||
|
@ -3451,8 +3502,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);
|
||||
});
|
||||
|
@ -3475,6 +3530,7 @@ describe('SavedObjectsRepository', () => {
|
|||
expect(result).toEqual({
|
||||
saved_object: expect.objectContaining({ type, id: aliasTargetId }),
|
||||
outcome: 'aliasMatch',
|
||||
aliasTargetId,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -3488,8 +3544,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);
|
||||
});
|
||||
|
@ -3515,6 +3571,7 @@ describe('SavedObjectsRepository', () => {
|
|||
expect(result).toEqual({
|
||||
saved_object: expect.objectContaining({ type, id }),
|
||||
outcome: 'conflict',
|
||||
aliasTargetId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -3570,7 +3627,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);
|
||||
});
|
||||
|
@ -3625,10 +3684,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()
|
||||
);
|
||||
|
@ -3693,15 +3754,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);
|
||||
});
|
||||
});
|
||||
|
@ -4009,7 +4078,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);
|
||||
|
@ -4017,6 +4086,7 @@ describe('SavedObjectsRepository', () => {
|
|||
expect(client.update).not.toHaveBeenCalled();
|
||||
};
|
||||
await test('index-pattern');
|
||||
await test(MULTI_NAMESPACE_ISOLATED_TYPE);
|
||||
await test(NAMESPACE_AGNOSTIC_TYPE);
|
||||
});
|
||||
|
||||
|
@ -4181,7 +4251,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);
|
||||
});
|
||||
|
@ -4245,7 +4315,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,
|
||||
|
@ -4300,15 +4370,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()
|
||||
|
@ -4353,7 +4425,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);
|
||||
});
|
||||
|
||||
|
@ -4361,16 +4433,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);
|
||||
});
|
||||
|
||||
|
@ -4407,7 +4481,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),
|
||||
});
|
||||
|
|
|
@ -251,7 +251,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'
|
||||
);
|
||||
|
@ -340,7 +340,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'
|
||||
);
|
||||
|
@ -1085,6 +1085,7 @@ export class SavedObjectsRepository {
|
|||
return {
|
||||
saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc),
|
||||
outcome: 'conflict',
|
||||
aliasTargetId: legacyUrlAlias.targetId,
|
||||
};
|
||||
} else if (foundExactMatch) {
|
||||
return {
|
||||
|
@ -1095,6 +1096,7 @@ export class SavedObjectsRepository {
|
|||
return {
|
||||
saved_object: this.getSavedObjectFromSource(type, legacyUrlAlias.targetId, aliasMatchDoc),
|
||||
outcome: 'aliasMatch',
|
||||
aliasTargetId: legacyUrlAlias.targetId,
|
||||
};
|
||||
}
|
||||
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
|
||||
|
@ -1194,7 +1196,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`
|
||||
);
|
||||
|
@ -1257,7 +1259,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`
|
||||
);
|
||||
|
|
|
@ -339,6 +339,10 @@ export interface SavedObjectsResolveResponse<T = unknown> {
|
|||
* `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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -213,13 +213,17 @@ export type SavedObjectsClientContract = Pick<SavedObjectsClient, keyof SavedObj
|
|||
|
||||
/**
|
||||
* 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.
|
||||
* * 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.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic';
|
||||
export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic';
|
||||
|
||||
/**
|
||||
* @remarks This is only internal for now, and will only be public when we expose the registerType API
|
||||
|
@ -259,15 +263,17 @@ export interface SavedObjectsType {
|
|||
*/
|
||||
migrations?: SavedObjectMigrationMap | (() => 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
|
||||
* {
|
||||
|
@ -278,7 +284,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
|
||||
* {
|
||||
|
@ -286,11 +304,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;
|
||||
/**
|
||||
|
|
|
@ -2094,7 +2094,9 @@ export interface SavedObjectExportBaseOptions {
|
|||
|
||||
// @public
|
||||
export interface SavedObjectMigrationContext {
|
||||
convertToMultiNamespaceTypeVersion?: string;
|
||||
log: SavedObjectsMigrationLogger;
|
||||
migrationVersion: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
|
@ -2758,7 +2760,7 @@ export interface SavedObjectsMigrationVersion {
|
|||
}
|
||||
|
||||
// @public
|
||||
export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic';
|
||||
export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic';
|
||||
|
||||
// @public (undocumented)
|
||||
export interface SavedObjectsOpenPointInTimeOptions extends SavedObjectsBaseOptions {
|
||||
|
@ -2850,6 +2852,7 @@ export interface SavedObjectsResolveImportErrorsOptions {
|
|||
|
||||
// @public (undocumented)
|
||||
export interface SavedObjectsResolveResponse<T = unknown> {
|
||||
aliasTargetId?: string;
|
||||
outcome: 'exactMatch' | 'aliasMatch' | 'conflict';
|
||||
// (undocumented)
|
||||
saved_object: SavedObject<T>;
|
||||
|
@ -2963,6 +2966,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;
|
||||
}
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
<I18nProvider>
|
||||
<Router history={history}>
|
||||
|
@ -79,6 +85,7 @@ export const mountManagementSection = async ({
|
|||
<SavedObjectsTablePage
|
||||
coreStart={coreStart}
|
||||
taggingApi={savedObjectsTaggingOss?.getTaggingApi()}
|
||||
spacesApi={spacesApi}
|
||||
dataStart={data}
|
||||
serviceRegistry={serviceRegistry}
|
||||
actionRegistry={pluginStart.actions}
|
||||
|
|
|
@ -70,7 +70,6 @@ interface TableState {
|
|||
isExportPopoverOpen: boolean;
|
||||
isIncludeReferencesDeepChecked: boolean;
|
||||
activeAction?: SavedObjectsManagementAction;
|
||||
isColumnDataLoaded: boolean;
|
||||
}
|
||||
|
||||
export class Table extends PureComponent<TableProps, TableState> {
|
||||
|
@ -80,22 +79,12 @@ export class Table extends PureComponent<TableProps, TableState> {
|
|||
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({
|
||||
|
|
|
@ -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,13 @@ import {
|
|||
} from '../services';
|
||||
import { SavedObjectsTable } from './objects_table';
|
||||
|
||||
const EmptyFunctionComponent: React.FC = ({ children }) => <>{children}</>;
|
||||
|
||||
const SavedObjectsTablePage = ({
|
||||
coreStart,
|
||||
dataStart,
|
||||
taggingApi,
|
||||
spacesApi,
|
||||
allowedTypes,
|
||||
serviceRegistry,
|
||||
actionRegistry,
|
||||
|
@ -35,6 +39,7 @@ const SavedObjectsTablePage = ({
|
|||
coreStart: CoreStart;
|
||||
dataStart: DataPublicPluginStart;
|
||||
taggingApi?: SavedObjectsTaggingApi;
|
||||
spacesApi?: SpacesAvailableStartContract;
|
||||
allowedTypes: string[];
|
||||
serviceRegistry: ISavedObjectsManagementServiceRegistry;
|
||||
actionRegistry: SavedObjectsManagementActionServiceStart;
|
||||
|
@ -65,35 +70,42 @@ const SavedObjectsTablePage = ({
|
|||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const ContextWrapper = useMemo(
|
||||
() => spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent,
|
||||
[spacesApi]
|
||||
);
|
||||
|
||||
return (
|
||||
<SavedObjectsTable
|
||||
initialQuery={initialQuery}
|
||||
allowedTypes={allowedTypes}
|
||||
serviceRegistry={serviceRegistry}
|
||||
actionRegistry={actionRegistry}
|
||||
columnRegistry={columnRegistry}
|
||||
taggingApi={taggingApi}
|
||||
savedObjectsClient={coreStart.savedObjects.client}
|
||||
indexPatterns={dataStart.indexPatterns}
|
||||
search={dataStart.search}
|
||||
http={coreStart.http}
|
||||
overlays={coreStart.overlays}
|
||||
notifications={coreStart.notifications}
|
||||
applications={coreStart.application}
|
||||
perPageConfig={itemsPerPage}
|
||||
goInspectObject={(savedObject) => {
|
||||
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;
|
||||
}}
|
||||
/>
|
||||
<ContextWrapper>
|
||||
<SavedObjectsTable
|
||||
initialQuery={initialQuery}
|
||||
allowedTypes={allowedTypes}
|
||||
serviceRegistry={serviceRegistry}
|
||||
actionRegistry={actionRegistry}
|
||||
columnRegistry={columnRegistry}
|
||||
taggingApi={taggingApi}
|
||||
savedObjectsClient={coreStart.savedObjects.client}
|
||||
indexPatterns={dataStart.indexPatterns}
|
||||
search={dataStart.search}
|
||||
http={coreStart.http}
|
||||
overlays={coreStart.overlays}
|
||||
notifications={coreStart.notifications}
|
||||
applications={coreStart.application}
|
||||
perPageConfig={itemsPerPage}
|
||||
goInspectObject={(savedObject) => {
|
||||
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;
|
||||
}}
|
||||
/>
|
||||
</ContextWrapper>
|
||||
);
|
||||
};
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -12,7 +12,4 @@ import { SavedObjectsManagementRecord } from '.';
|
|||
export interface SavedObjectsManagementColumn<T> {
|
||||
id: string;
|
||||
euiColumn: Omit<EuiTableFieldDataColumnType<SavedObjectsManagementRecord>, 'sortable'>;
|
||||
|
||||
data?: T;
|
||||
loadData: () => Promise<T>;
|
||||
}
|
||||
|
|
|
@ -21,5 +21,6 @@
|
|||
{ "path": "../kibana_react/tsconfig.json" },
|
||||
{ "path": "../management/tsconfig.json" },
|
||||
{ "path": "../visualizations/tsconfig.json" },
|
||||
{ "path": "../spaces_oss/tsconfig.json" },
|
||||
]
|
||||
}
|
||||
|
|
|
@ -7,13 +7,40 @@
|
|||
*/
|
||||
|
||||
import { of } from 'rxjs';
|
||||
import { SpacesApi } from './api';
|
||||
import { SpacesApi, SpacesApiUi, SpacesApiUiComponent } from './api';
|
||||
|
||||
const createApiMock = (): jest.Mocked<SpacesApi> => ({
|
||||
activeSpace$: of(),
|
||||
getActiveSpace: jest.fn(),
|
||||
ui: createApiUiMock(),
|
||||
});
|
||||
|
||||
type SpacesApiUiMock = Omit<jest.Mocked<SpacesApiUi>, 'components'> & {
|
||||
components: SpacesApiUiComponentMock;
|
||||
};
|
||||
|
||||
const createApiUiMock = () => {
|
||||
const mock: SpacesApiUiMock = {
|
||||
components: createApiUiComponentsMock(),
|
||||
redirectLegacyUrl: jest.fn(),
|
||||
};
|
||||
|
||||
return mock;
|
||||
};
|
||||
|
||||
type SpacesApiUiComponentMock = jest.Mocked<SpacesApiUiComponent>;
|
||||
|
||||
const createApiUiComponentsMock = () => {
|
||||
const mock: SpacesApiUiComponentMock = {
|
||||
SpacesContext: jest.fn(),
|
||||
ShareToSpaceFlyout: jest.fn(),
|
||||
SpaceList: jest.fn(),
|
||||
LegacyUrlConflict: jest.fn(),
|
||||
};
|
||||
|
||||
return mock;
|
||||
};
|
||||
|
||||
export const spacesApiMock = {
|
||||
create: createApiMock,
|
||||
};
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import { Space } from '../common';
|
||||
|
||||
/**
|
||||
|
@ -15,4 +16,238 @@ import { Space } from '../common';
|
|||
export interface SpacesApi {
|
||||
readonly activeSpace$: Observable<Space>;
|
||||
getActiveSpace(): Promise<Space>;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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.
|
||||
* @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, objectNoun?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* React UI components to be used to display the spaces feature in any application.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SpacesApiUiComponent {
|
||||
/**
|
||||
* Provides a context that is required to render some Spaces components.
|
||||
*/
|
||||
SpacesContext: FunctionComponent<SpacesContextProps>;
|
||||
/**
|
||||
* Displays a flyout to edit the spaces that an object is shared to.
|
||||
*
|
||||
* Note: must be rendered inside of a SpacesContext.
|
||||
*/
|
||||
ShareToSpaceFlyout: FunctionComponent<ShareToSpaceFlyoutProps>;
|
||||
/**
|
||||
* 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<SpaceListProps>;
|
||||
/**
|
||||
* 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<LegacyUrlConflictProps>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
* `/api/spaces/_share_saved_object_remove` and displays toast(s) indicating what occurred.
|
||||
*/
|
||||
changeSpacesHandler?: (spacesToAdd: string[], spacesToRemove: string[]) => Promise<void>;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 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).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
behaviorContext?: 'within-space' | 'outside-space';
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -8,8 +8,22 @@
|
|||
|
||||
import { SpacesOssPlugin } from './plugin';
|
||||
|
||||
export { SpacesOssPluginSetup, SpacesOssPluginStart } from './types';
|
||||
export {
|
||||
SpacesOssPluginSetup,
|
||||
SpacesOssPluginStart,
|
||||
SpacesAvailableStartContract,
|
||||
SpacesUnavailableStartContract,
|
||||
} from './types';
|
||||
|
||||
export { SpacesApi } from './api';
|
||||
export {
|
||||
SpacesApi,
|
||||
SpacesApiUi,
|
||||
SpacesApiUiComponent,
|
||||
SpacesContextProps,
|
||||
ShareToSpaceFlyoutProps,
|
||||
ShareToSpaceSavedObjectTarget,
|
||||
SpaceListProps,
|
||||
LegacyUrlConflictProps,
|
||||
} from './api';
|
||||
|
||||
export const plugin = () => new SpacesOssPlugin();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
@ -112,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);
|
||||
|
||||
|
@ -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(
|
||||
|
@ -163,6 +165,81 @@ describe('createMigration()', () => {
|
|||
attributes
|
||||
);
|
||||
});
|
||||
|
||||
describe('uses the object `namespaces` field to populate the descriptor when the migration context indicates this type is being converted', () => {
|
||||
const doTest = ({
|
||||
objectNamespace,
|
||||
decryptDescriptorNamespace,
|
||||
}: {
|
||||
objectNamespace: string | undefined;
|
||||
decryptDescriptorNamespace: string | undefined;
|
||||
}) => {
|
||||
const instantiateServiceWithLegacyType = jest.fn(() =>
|
||||
encryptedSavedObjectsServiceMock.create()
|
||||
);
|
||||
|
||||
const migrationCreator = getCreateMigration(
|
||||
encryptionSavedObjectService,
|
||||
instantiateServiceWithLegacyType
|
||||
);
|
||||
const noopMigration = migrationCreator<InputType, MigrationType>(
|
||||
function (doc): doc is SavedObjectUnsanitizedDoc<InputType> {
|
||||
return true;
|
||||
},
|
||||
(doc) => doc
|
||||
);
|
||||
|
||||
const attributes = {
|
||||
firstAttr: 'first_attr',
|
||||
};
|
||||
|
||||
encryptionSavedObjectService.decryptAttributesSync.mockReturnValueOnce(attributes);
|
||||
encryptionSavedObjectService.encryptAttributesSync.mockReturnValueOnce(attributes);
|
||||
|
||||
noopMigration(
|
||||
{
|
||||
id: '123',
|
||||
type: 'known-type-1',
|
||||
namespaces: objectNamespace ? [objectNamespace] : [],
|
||||
attributes,
|
||||
},
|
||||
migrationMocks.createContext({
|
||||
migrationVersion: '8.0.0',
|
||||
convertToMultiNamespaceTypeVersion: '8.0.0',
|
||||
})
|
||||
);
|
||||
|
||||
expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith(
|
||||
{
|
||||
id: '123',
|
||||
type: 'known-type-1',
|
||||
namespace: decryptDescriptorNamespace,
|
||||
},
|
||||
attributes,
|
||||
{ convertToMultiNamespaceType: true }
|
||||
);
|
||||
|
||||
expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith(
|
||||
{
|
||||
id: '123',
|
||||
type: 'known-type-1',
|
||||
},
|
||||
attributes
|
||||
);
|
||||
};
|
||||
|
||||
it('when namespaces is an empty array', () => {
|
||||
doTest({ objectNamespace: undefined, decryptDescriptorNamespace: undefined });
|
||||
});
|
||||
|
||||
it('when the first namespace element is "default"', () => {
|
||||
doTest({ objectNamespace: 'default', decryptDescriptorNamespace: undefined });
|
||||
});
|
||||
|
||||
it('when the first namespace element is another string', () => {
|
||||
doTest({ objectNamespace: 'foo', decryptDescriptorNamespace: 'foo' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration across two legacy types', () => {
|
||||
|
@ -216,7 +293,7 @@ describe('createMigration()', () => {
|
|||
firstAttr: '#####',
|
||||
},
|
||||
},
|
||||
{ log }
|
||||
migrationContext
|
||||
)
|
||||
).toMatchObject({
|
||||
id: '123',
|
||||
|
@ -257,7 +334,7 @@ describe('createMigration()', () => {
|
|||
nonEncryptedAttr: 'non encrypted',
|
||||
},
|
||||
},
|
||||
{ log }
|
||||
migrationContext
|
||||
)
|
||||
).toMatchObject({
|
||||
id: '123',
|
||||
|
@ -278,7 +355,8 @@ describe('createMigration()', () => {
|
|||
{
|
||||
firstAttr: '#####',
|
||||
nonEncryptedAttr: 'non encrypted',
|
||||
}
|
||||
},
|
||||
{ convertToMultiNamespaceType: false }
|
||||
);
|
||||
|
||||
expect(serviceWithMigrationLegacyType.encryptAttributesSync).toHaveBeenCalledWith(
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
SavedObjectMigrationContext,
|
||||
} from 'src/core/server';
|
||||
import { EncryptedSavedObjectTypeRegistration, EncryptedSavedObjectsService } from './crypto';
|
||||
import { normalizeNamespace } from './saved_objects';
|
||||
|
||||
type SavedObjectOptionalMigrationFn<InputAttributes, MigratedAttributes> = (
|
||||
doc: SavedObjectUnsanitizedDoc<InputAttributes> | SavedObjectUnsanitizedDoc<MigratedAttributes>,
|
||||
|
@ -63,11 +64,19 @@ export const getCreateMigration = (
|
|||
return encryptedDoc;
|
||||
}
|
||||
|
||||
const descriptor = {
|
||||
id: encryptedDoc.id!,
|
||||
type: encryptedDoc.type,
|
||||
namespace: encryptedDoc.namespace,
|
||||
};
|
||||
// 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 =
|
||||
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;
|
||||
|
||||
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 +84,14 @@ export const getCreateMigration = (
|
|||
return mapAttributes(
|
||||
migration(
|
||||
mapAttributes(encryptedDoc, (inputAttributes) =>
|
||||
inputService.decryptAttributesSync<any>(descriptor, inputAttributes)
|
||||
inputService.decryptAttributesSync<any>(decryptDescriptor, inputAttributes, {
|
||||
convertToMultiNamespaceType,
|
||||
})
|
||||
),
|
||||
context
|
||||
),
|
||||
(migratedAttributes) =>
|
||||
migratedService.encryptAttributesSync<any>(descriptor, migratedAttributes)
|
||||
migratedService.encryptAttributesSync<any>(encryptDescriptor, migratedAttributes)
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
@ -819,6 +819,55 @@ 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']),
|
||||
});
|
||||
|
||||
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, convertToMultiNamespaceType: true }
|
||||
)
|
||||
).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({
|
||||
|
@ -1017,6 +1066,47 @@ describe('#decryptAttributes', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('fails if retry decryption without namespace is not correct', async () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
encryptedAttributes = service.encryptAttributesSync(
|
||||
{ type: 'known-type-1', id: 'object-id', namespace: 'some-other-ns' },
|
||||
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, 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-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-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 () => {
|
||||
const mockUser = mockAuthenticatedUser();
|
||||
await expect(
|
||||
|
@ -1707,6 +1797,55 @@ 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']),
|
||||
});
|
||||
|
||||
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, convertToMultiNamespaceType: true }
|
||||
)
|
||||
).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({
|
||||
|
@ -1852,6 +1991,47 @@ describe('#decryptAttributesSync', () => {
|
|||
).toThrowError(EncryptionError);
|
||||
});
|
||||
|
||||
it('fails if retry decryption without namespace is not correct', () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
encryptedAttributes = service.encryptAttributesSync(
|
||||
{ type: 'known-type-1', id: 'object-id', namespace: 'some-other-ns' },
|
||||
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, 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-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-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', () => {
|
||||
expect(() =>
|
||||
service.decryptAttributesSync(
|
||||
|
|
|
@ -61,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 {
|
||||
|
@ -366,14 +374,17 @@ export class EncryptedSavedObjectsService {
|
|||
|
||||
let iteratorResult = iterator.next();
|
||||
while (!iteratorResult.done) {
|
||||
const [attributeValue, encryptionAAD] = iteratorResult.value;
|
||||
const [attributeValue, encryptionAADs] = iteratorResult.value;
|
||||
|
||||
// We check this inside of the iterator to throw only if we do need to decrypt anything.
|
||||
let decryptionError =
|
||||
decrypters.length === 0
|
||||
? new Error('Decryption is disabled because of missing decryption keys.')
|
||||
: undefined;
|
||||
for (const decrypter of decrypters) {
|
||||
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;
|
||||
|
@ -414,14 +425,17 @@ export class EncryptedSavedObjectsService {
|
|||
|
||||
let iteratorResult = iterator.next();
|
||||
while (!iteratorResult.done) {
|
||||
const [attributeValue, encryptionAAD] = iteratorResult.value;
|
||||
const [attributeValue, encryptionAADs] = iteratorResult.value;
|
||||
|
||||
// We check this inside of the iterator to throw only if we do need to decrypt anything.
|
||||
let decryptionError =
|
||||
decrypters.length === 0
|
||||
? new Error('Decryption is disabled because of missing decryption keys.')
|
||||
: undefined;
|
||||
for (const decrypter of decrypters) {
|
||||
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;
|
||||
|
@ -445,13 +459,13 @@ export class EncryptedSavedObjectsService {
|
|||
private *attributesToDecryptIterator<T extends Record<string, unknown>>(
|
||||
descriptor: SavedObjectDescriptor,
|
||||
attributes: T,
|
||||
params?: CommonParameters
|
||||
): Iterator<[string, string], T, EncryptOutput> {
|
||||
params?: DecryptParameters
|
||||
): 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<string, EncryptOutput> = {};
|
||||
for (const attributeName of typeDefinition.attributesToEncrypt) {
|
||||
const attributeValue = attributes[attributeName];
|
||||
|
@ -467,11 +481,16 @@ export class EncryptedSavedObjectsService {
|
|||
)}`
|
||||
);
|
||||
}
|
||||
if (!encryptionAAD) {
|
||||
encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes);
|
||||
if (!encryptionAADs.length) {
|
||||
encryptionAADs.push(this.getAAD(typeDefinition, descriptor, attributes));
|
||||
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));
|
||||
}
|
||||
}
|
||||
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}`
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<EncryptedSavedObjectsService>;
|
||||
|
|
|
@ -39,7 +39,6 @@
|
|||
"dashboard",
|
||||
"savedObjects",
|
||||
"home",
|
||||
"spaces",
|
||||
"maps"
|
||||
],
|
||||
"extraPublicDirs": [
|
||||
|
|
|
@ -5,4 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export { JobSpacesList, ALL_SPACES_ID } from './job_spaces_list';
|
||||
export { JobSpacesList } from './job_spaces_list';
|
||||
|
|
|
@ -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<Props> = ({ spaceIds, jobId, jobType, refresh }) => {
|
||||
const { allSpaces } = useSpacesContext();
|
||||
export const JobSpacesList: FC<Props> = ({ spacesApi, spaceIds, jobId, jobType, refresh }) => {
|
||||
const { displayErrorToast } = useToastNotificationService();
|
||||
|
||||
const [showFlyout, setShowFlyout] = useState(false);
|
||||
const [spaces, setSpaces] = useState<Space[]>([]);
|
||||
|
||||
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,
|
||||
},
|
||||
behaviorContext: 'outside-space',
|
||||
changeSpacesHandler,
|
||||
onClose,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiButtonEmpty onClick={() => setShowFlyout(true)} style={{ height: 'auto' }}>
|
||||
<EuiFlexGroup wrap responsive={false} gutterSize="xs">
|
||||
{spaces.map((space) => (
|
||||
<EuiFlexItem grow={false} key={space.id}>
|
||||
<SpaceAvatar space={space} size={'s'} />
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
<SpaceList namespaces={spaceIds} displayLimit={0} behaviorContext="outside-space" />
|
||||
</EuiButtonEmpty>
|
||||
{showFlyout && (
|
||||
<JobSpacesFlyout
|
||||
jobId={jobId}
|
||||
spaceIds={filterUnknownSpaces(spaceIds)}
|
||||
jobType={jobType}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
{showFlyout && <ShareToSpaceFlyout {...shareToSpaceFlyoutProps} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 }) => (
|
||||
<>
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
iconType="help"
|
||||
title={i18n.translate('xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.title', {
|
||||
defaultMessage: 'Insufficient permissions to edit spaces for {jobId}',
|
||||
values: { jobId },
|
||||
})}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.text"
|
||||
defaultMessage="To change the spaces for this job, you need authority to modify jobs in all spaces. Contact your system administrator for more information."
|
||||
/>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="l" />
|
||||
</>
|
||||
);
|
|
@ -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<Props> = ({ jobId, jobType, spaceIds, onClose }) => {
|
||||
const { displayErrorToast } = useToastNotificationService();
|
||||
|
||||
const [selectedSpaceIds, setSelectedSpaceIds] = useState<string[]>(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 (
|
||||
<>
|
||||
<EuiFlyout maxWidth={600} onClose={onClose}>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="m">
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.management.spacesSelectorFlyout.headerLabel"
|
||||
defaultMessage="Select spaces for {jobId}"
|
||||
values={{ jobId }}
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<SpacesSelector
|
||||
jobId={jobId}
|
||||
spaceIds={spaceIds}
|
||||
selectedSpaceIds={selectedSpaceIds}
|
||||
setSelectedSpaceIds={setSelectedSpaceIds}
|
||||
canEditSpaces={canEditSpaces}
|
||||
setCanEditSpaces={setCanEditSpaces}
|
||||
/>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty iconType="cross" onClick={onClose} flush="left">
|
||||
<FormattedMessage
|
||||
id="xpack.ml.management.spacesSelectorFlyout.closeButton"
|
||||
defaultMessage="Close"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
onClick={applySpaces}
|
||||
fill
|
||||
isDisabled={canEditSpaces === false || savable === false || saving === true}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.management.spacesSelectorFlyout.saveButton"
|
||||
defaultMessage="Save"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,3 +0,0 @@
|
|||
.mlCopyToSpace__spacesList {
|
||||
margin-top: $euiSizeXS;
|
||||
}
|
|
@ -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<Props> = ({
|
||||
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<SpaceOption>((space) => {
|
||||
return {
|
||||
label: space.name,
|
||||
prepend: <SpaceAvatar space={space} size={'s'} />,
|
||||
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 && <CannotEditCallout jobId={jobId} />}
|
||||
<EuiFormFieldset>
|
||||
<EuiCheckableCard
|
||||
id={shareToExplicitSpaces.id}
|
||||
label={createLabel(shareToExplicitSpaces)}
|
||||
checked={!isGlobalControlChecked}
|
||||
onChange={() => toggleShareOption(false)}
|
||||
disabled={shareToExplicitSpaces.disabled}
|
||||
>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.management.spacesSelectorFlyout.selectSpacesLabel"
|
||||
defaultMessage="Select spaces"
|
||||
/>
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<EuiSelectable
|
||||
options={options}
|
||||
onChange={(newOptions) => updateSelectedSpaces(newOptions as SpaceOption[])}
|
||||
listProps={{
|
||||
bordered: true,
|
||||
rowHeight: 40,
|
||||
className: 'mlCopyToSpace__spacesList',
|
||||
'data-test-subj': 'mlFormSpaceSelector',
|
||||
}}
|
||||
searchable
|
||||
>
|
||||
{(list, search) => {
|
||||
return (
|
||||
<>
|
||||
{search}
|
||||
{list}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</EuiSelectable>
|
||||
</EuiFormRow>
|
||||
</EuiCheckableCard>
|
||||
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
<EuiCheckableCard
|
||||
id={shareToAllSpaces.id}
|
||||
label={createLabel(shareToAllSpaces)}
|
||||
checked={isGlobalControlChecked}
|
||||
onChange={() => toggleShareOption(true)}
|
||||
disabled={shareToAllSpaces.disabled}
|
||||
/>
|
||||
</EuiFormFieldset>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function createLabel({
|
||||
title,
|
||||
text,
|
||||
disabled,
|
||||
tooltip,
|
||||
}: {
|
||||
title: string;
|
||||
text: string;
|
||||
disabled: boolean;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiText>{title}</EuiText>
|
||||
</EuiFlexItem>
|
||||
{tooltip && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconTip content={tooltip} position="left" type="iInCircle" />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="xs" />
|
||||
<EuiText color={disabled ? undefined : 'subdued'} size="s">
|
||||
{text}
|
||||
</EuiText>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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<Partial<SpacesContextValue>>({});
|
||||
|
||||
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;
|
||||
}
|
|
@ -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';
|
||||
|
@ -84,7 +85,7 @@ function getItemIdToExpandedRowMap(
|
|||
interface Props {
|
||||
isManagementTable?: boolean;
|
||||
isMlEnabledInSpace?: boolean;
|
||||
spacesEnabled?: boolean;
|
||||
spacesApi?: SpacesPluginStart;
|
||||
blockRefresh?: boolean;
|
||||
pageState: ListingPageUrlState;
|
||||
updatePageState: (update: Partial<ListingPageUrlState>) => void;
|
||||
|
@ -92,7 +93,7 @@ interface Props {
|
|||
export const DataFrameAnalyticsList: FC<Props> = ({
|
||||
isManagementTable = false,
|
||||
isMlEnabledInSpace = true,
|
||||
spacesEnabled = false,
|
||||
spacesApi,
|
||||
blockRefresh = false,
|
||||
pageState,
|
||||
updatePageState,
|
||||
|
@ -178,7 +179,7 @@ export const DataFrameAnalyticsList: FC<Props> = ({
|
|||
setExpandedRowItemIds,
|
||||
isManagementTable,
|
||||
isMlEnabledInSpace,
|
||||
spacesEnabled,
|
||||
spacesApi,
|
||||
refresh
|
||||
);
|
||||
|
||||
|
|
|
@ -33,6 +33,7 @@ import {
|
|||
import { useActions } from './use_actions';
|
||||
import { useMlLink } from '../../../../../contexts/kibana';
|
||||
import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator';
|
||||
import type { SpacesPluginStart } from '../../../../../../../../spaces/public';
|
||||
import { JobSpacesList } from '../../../../../components/job_spaces_list';
|
||||
|
||||
enum TASK_STATE_COLOR {
|
||||
|
@ -150,7 +151,7 @@ export const useColumns = (
|
|||
setExpandedRowItemIds: React.Dispatch<React.SetStateAction<DataFrameAnalyticsId[]>>,
|
||||
isManagementTable: boolean = false,
|
||||
isMlEnabledInSpace: boolean = true,
|
||||
spacesEnabled: boolean = true,
|
||||
spacesApi?: SpacesPluginStart,
|
||||
refresh: () => void = () => {}
|
||||
) => {
|
||||
const { actions, modals } = useActions(isManagementTable);
|
||||
|
@ -281,7 +282,7 @@ export const useColumns = (
|
|||
];
|
||||
|
||||
if (isManagementTable === true) {
|
||||
if (spacesEnabled === true) {
|
||||
if (spacesApi) {
|
||||
// insert before last column
|
||||
columns.splice(columns.length - 1, 0, {
|
||||
name: i18n.translate('xpack.ml.jobsList.analyticsSpacesLabel', {
|
||||
|
@ -290,6 +291,7 @@ export const useColumns = (
|
|||
render: (item: DataFrameAnalyticsListRow) =>
|
||||
Array.isArray(item.spaceIds) ? (
|
||||
<JobSpacesList
|
||||
spacesApi={spacesApi}
|
||||
spaceIds={item.spaceIds ?? []}
|
||||
jobId={item.id}
|
||||
jobType="data-frame-analytics"
|
||||
|
|
|
@ -96,7 +96,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 +243,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 +251,7 @@ export class JobsList extends Component {
|
|||
}),
|
||||
render: (item) => (
|
||||
<JobSpacesList
|
||||
spacesApi={spacesApi}
|
||||
spaceIds={item.spaceIds}
|
||||
jobId={item.id}
|
||||
jobType="anomaly-detector"
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -24,7 +24,6 @@ import {
|
|||
} 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 +38,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 +67,9 @@ function usePageState<T extends ListingPageUrlState>(
|
|||
return [pageState, updateState];
|
||||
}
|
||||
|
||||
function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] {
|
||||
const EmptyFunctionComponent: React.FC = ({ children }) => <>{children}</>;
|
||||
|
||||
function useTabs(isMlEnabledInSpace: boolean, spacesApi: SpacesPluginStart | undefined): Tab[] {
|
||||
const [adPageState, updateAdPageState] = usePageState(getDefaultAnomalyDetectionJobsListState());
|
||||
const [dfaPageState, updateDfaPageState] = usePageState(getDefaultDFAListState());
|
||||
|
||||
|
@ -88,7 +89,7 @@ function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] {
|
|||
onJobsViewStateUpdate={updateAdPageState}
|
||||
isManagementTable={true}
|
||||
isMlEnabledInSpace={isMlEnabledInSpace}
|
||||
spacesEnabled={spacesEnabled}
|
||||
spacesApi={spacesApi}
|
||||
/>
|
||||
</Fragment>
|
||||
),
|
||||
|
@ -105,7 +106,7 @@ function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] {
|
|||
<DataFrameAnalyticsList
|
||||
isManagementTable={true}
|
||||
isMlEnabledInSpace={isMlEnabledInSpace}
|
||||
spacesEnabled={spacesEnabled}
|
||||
spacesApi={spacesApi}
|
||||
pageState={dfaPageState}
|
||||
updatePageState={updateDfaPageState}
|
||||
/>
|
||||
|
@ -121,28 +122,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);
|
||||
}
|
||||
|
@ -191,13 +185,15 @@ export const JobsListPage: FC<{
|
|||
return <AccessDeniedPage />;
|
||||
}
|
||||
|
||||
const ContextWrapper = spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent;
|
||||
|
||||
return (
|
||||
<RedirectAppLinks application={coreStart.application}>
|
||||
<I18nContext>
|
||||
<KibanaContextProvider
|
||||
services={{ ...coreStart, share, mlServices: getMlGlobalServices(coreStart.http) }}
|
||||
>
|
||||
<SpacesContext.Provider value={spacesContext}>
|
||||
<ContextWrapper feature={PLUGIN_ID}>
|
||||
<Router history={history}>
|
||||
<EuiPageContent
|
||||
id="kibanaManagementMLSection"
|
||||
|
@ -256,7 +252,7 @@ export const JobsListPage: FC<{
|
|||
</EuiPageContentBody>
|
||||
</EuiPageContent>
|
||||
</Router>
|
||||
</SpacesContext.Provider>
|
||||
</ContextWrapper>
|
||||
</KibanaContextProvider>
|
||||
</I18nContext>
|
||||
</RedirectAppLinks>
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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(
|
||||
<CopySavedObjectsToSpaceFlyout
|
||||
savedObject={savedObjectToCopy}
|
||||
savedObjectTarget={savedObjectToCopy}
|
||||
spacesManager={(mockSpacesManager as unknown) as SpacesManager}
|
||||
toastNotifications={(mockToastNotifications as unknown) as ToastsApi}
|
||||
onClose={onClose}
|
||||
|
@ -101,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 });
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
EuiFlyout,
|
||||
EuiIcon,
|
||||
|
@ -27,18 +27,17 @@ import { ToastsStart } from 'src/core/public';
|
|||
import {
|
||||
ProcessedImportResponse,
|
||||
processImportResponse,
|
||||
SavedObjectsManagementRecord,
|
||||
} from '../../../../../../src/plugins/saved_objects_management/public';
|
||||
import { Space } from '../../../../../../src/plugins/spaces_oss/common';
|
||||
import { SpacesManager } from '../../spaces_manager';
|
||||
import { ProcessingCopyToSpace } from './processing_copy_to_space';
|
||||
import { CopyToSpaceFlyoutFooter } from './copy_to_space_flyout_footer';
|
||||
import { CopyToSpaceForm } from './copy_to_space_form';
|
||||
import { CopyOptions, ImportRetry } from '../types';
|
||||
import { CopyOptions, ImportRetry, SavedObjectTarget } from '../types';
|
||||
|
||||
interface Props {
|
||||
onClose: () => 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<CopyOptions>({
|
||||
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 (
|
||||
<CopyToSpaceForm
|
||||
savedObject={savedObject}
|
||||
savedObjectTarget={savedObjectTarget}
|
||||
spaces={spaces}
|
||||
copyOptions={copyOptions}
|
||||
onUpdate={setCopyOptions}
|
||||
|
@ -231,7 +240,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => {
|
|||
// Step3: Copy operation is in progress
|
||||
return (
|
||||
<ProcessingCopyToSpace
|
||||
savedObject={savedObject}
|
||||
savedObjectTarget={savedObjectTarget}
|
||||
copyInProgress={copyInProgress}
|
||||
conflictResolutionInProgress={conflictResolutionInProgress}
|
||||
copyResult={copyResult}
|
||||
|
@ -265,11 +274,11 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => {
|
|||
<EuiFlyoutBody>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="m">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type={savedObject.meta.icon || 'apps'} />
|
||||
<EuiIcon type={savedObjectTarget.icon} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiText>
|
||||
<p>{savedObject.meta.title}</p>
|
||||
<p>{savedObjectTarget.title}</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -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<SavedObjectTarget>;
|
||||
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<string>()
|
||||
: (savedObject.namespaces ?? []).reduce((acc, cur) => acc.add(cur), new Set<string>());
|
||||
: savedObjectTarget.namespaces.reduce((acc, cur) => acc.add(cur), new Set<string>());
|
||||
|
||||
const changeCopyMode = ({ createNewCopies, overwrite }: CopyMode) => {
|
||||
const disabled = getDisabledSpaceIds(createNewCopies);
|
||||
|
|
|
@ -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<SavedObjectTarget>;
|
||||
copyInProgress: boolean;
|
||||
conflictResolutionInProgress: boolean;
|
||||
copyResult: Record<string, ProcessedImportResponse>;
|
||||
|
@ -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 (
|
||||
<Fragment key={id}>
|
||||
|
@ -106,7 +106,6 @@ export const ProcessingCopyToSpace = (props: Props) => {
|
|||
<SpaceResultProcessing space={space} />
|
||||
) : (
|
||||
<SpaceResult
|
||||
savedObject={props.savedObject}
|
||||
space={space}
|
||||
summarizedCopyResult={summarizedSpaceCopyResult}
|
||||
retries={props.retries[space.id] || []}
|
||||
|
|
|
@ -15,7 +15,6 @@ import {
|
|||
EuiSpacer,
|
||||
EuiLoadingSpinner,
|
||||
} from '@elastic/eui';
|
||||
import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public';
|
||||
import { Space } from '../../../../../../src/plugins/spaces_oss/common';
|
||||
import { SummarizedCopyToSpaceResult } from '../index';
|
||||
import { SpaceAvatar } from '../../space_avatar';
|
||||
|
@ -24,7 +23,6 @@ import { SpaceCopyResultDetails } from './space_result_details';
|
|||
import { ImportRetry } from '../types';
|
||||
|
||||
interface Props {
|
||||
savedObject: SavedObjectsManagementRecord;
|
||||
space: Space;
|
||||
summarizedCopyResult: SummarizedCopyToSpaceResult;
|
||||
retries: ImportRetry[];
|
||||
|
@ -71,7 +69,6 @@ export const SpaceResult = (props: Props) => {
|
|||
summarizedCopyResult,
|
||||
retries,
|
||||
onRetriesChange,
|
||||
savedObject,
|
||||
conflictResolutionInProgress,
|
||||
} = props;
|
||||
const { objects } = summarizedCopyResult;
|
||||
|
@ -109,7 +106,6 @@ export const SpaceResult = (props: Props) => {
|
|||
>
|
||||
<EuiSpacer size="s" />
|
||||
<SpaceCopyResultDetails
|
||||
savedObject={savedObject}
|
||||
summarizedCopyResult={summarizedCopyResult}
|
||||
space={space}
|
||||
retries={retries}
|
||||
|
|
|
@ -24,13 +24,11 @@ import {
|
|||
import { EuiSuperSelect } from '@elastic/eui';
|
||||
import moment from 'moment';
|
||||
import { SummarizedCopyToSpaceResult } from '../index';
|
||||
import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public';
|
||||
import { Space } from '../../../../../../src/plugins/spaces_oss/common';
|
||||
import { CopyStatusIndicator } from './copy_status_indicator';
|
||||
import { ImportRetry } from '../types';
|
||||
|
||||
interface Props {
|
||||
savedObject: SavedObjectsManagementRecord;
|
||||
summarizedCopyResult: SummarizedCopyToSpaceResult;
|
||||
space: Space;
|
||||
retries: ImportRetry[];
|
||||
|
|
|
@ -46,10 +46,19 @@ export class CopyToSpaceSavedObjectsManagementAction extends SavedObjectsManagem
|
|||
if (!this.record) {
|
||||
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,
|
||||
};
|
||||
|
||||
return (
|
||||
<CopySavedObjectsToSpaceFlyout
|
||||
onClose={this.onClose}
|
||||
savedObject={this.record}
|
||||
savedObjectTarget={savedObjectTarget}
|
||||
spacesManager={this.spacesManager}
|
||||
toastNotifications={this.notifications.toasts}
|
||||
/>
|
||||
|
|
|
@ -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<SavedObjectTarget>,
|
||||
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) {
|
||||
|
|
|
@ -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<SavedObjectTarget>,
|
||||
copyResult: ProcessedImportResponse | undefined
|
||||
): SummarizedCopyToSpaceResult {
|
||||
const conflicts = copyResult?.failedImports.filter(isAnyConflict) ?? [];
|
||||
|
@ -95,12 +92,12 @@ export function summarizeCopyResult(
|
|||
};
|
||||
|
||||
const objectMap = new Map<string, SummarizedSavedObjectResult>();
|
||||
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 = (
|
||||
|
|
|
@ -19,3 +19,30 @@ export type ImportRetry = Omit<SavedObjectsImportRetry, 'replaceReferences'>;
|
|||
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;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
@ -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<SpacesPlugin['start']>;
|
|||
|
||||
export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart> {
|
||||
private spacesManager!: SpacesManager;
|
||||
private spacesApi!: SpacesApi;
|
||||
|
||||
private managementService?: ManagementService;
|
||||
|
||||
public setup(core: CoreSetup<{}, SpacesPluginStart>, plugins: PluginsSetup) {
|
||||
public setup(core: CoreSetup<PluginsStart, SpacesPluginStart>, plugins: PluginsSetup) {
|
||||
this.spacesManager = new SpacesManager(core.http);
|
||||
this.spacesApi = {
|
||||
ui: getUiApi({
|
||||
spacesManager: this.spacesManager,
|
||||
getStartServices: core.getStartServices,
|
||||
}),
|
||||
activeSpace$: this.spacesManager.onActiveSpaceChange$,
|
||||
getActiveSpace: () => this.spacesManager.getActiveSpace(),
|
||||
};
|
||||
|
||||
if (plugins.home) {
|
||||
plugins.home.featureCatalogue.register(createSpacesFeatureCatalogueEntry());
|
||||
|
@ -53,7 +63,7 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
|
|||
this.managementService = new ManagementService();
|
||||
this.managementService.setup({
|
||||
management: plugins.management,
|
||||
getStartServices: core.getStartServices as StartServicesAccessor<PluginsStart>,
|
||||
getStartServices: core.getStartServices,
|
||||
spacesManager: this.spacesManager,
|
||||
});
|
||||
}
|
||||
|
@ -69,10 +79,8 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
|
|||
if (plugins.savedObjectsManagement) {
|
||||
const shareSavedObjectsToSpaceService = new ShareSavedObjectsToSpaceService();
|
||||
shareSavedObjectsToSpaceService.setup({
|
||||
spacesManager: this.spacesManager,
|
||||
notificationsSetup: core.notifications,
|
||||
savedObjectsManagementSetup: plugins.savedObjectsManagement,
|
||||
getStartServices: core.getStartServices as StartServicesAccessor<PluginsStart>,
|
||||
spacesApiUi: this.spacesApi.ui,
|
||||
});
|
||||
const copySavedObjectsToSpaceService = new CopySavedObjectsToSpaceService();
|
||||
copySavedObjectsToSpaceService.setup({
|
||||
|
@ -88,7 +96,7 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
|
|||
spacesManager: this.spacesManager,
|
||||
});
|
||||
|
||||
plugins.spacesOss.registerSpacesApi(this.createSpacesApi());
|
||||
plugins.spacesOss.registerSpacesApi(this.spacesApi);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
@ -96,7 +104,7 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
|
|||
public start(core: CoreStart) {
|
||||
initSpacesNavControl(this.spacesManager, core);
|
||||
|
||||
return this.createSpacesApi();
|
||||
return this.spacesApi;
|
||||
}
|
||||
|
||||
public stop() {
|
||||
|
@ -105,11 +113,4 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
|
|||
this.managementService = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private createSpacesApi(): SpacesApi {
|
||||
return {
|
||||
activeSpace$: this.spacesManager.onActiveSpaceChange$,
|
||||
getActiveSpace: () => this.spacesManager.getActiveSpace(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
});
|
|
@ -1,40 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, PropsWithChildren } from 'react';
|
||||
import { StartServicesAccessor, CoreStart } from 'src/core/public';
|
||||
import { createKibanaReactContext } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import { PluginsStart } from '../../plugin';
|
||||
|
||||
interface Props {
|
||||
getStartServices: StartServicesAccessor<PluginsStart>;
|
||||
}
|
||||
|
||||
export const ContextWrapper = (props: PropsWithChildren<Props>) => {
|
||||
const { getStartServices, children } = props;
|
||||
|
||||
const [coreStart, setCoreStart] = useState<CoreStart>();
|
||||
|
||||
useEffect(() => {
|
||||
getStartServices().then((startServices) => {
|
||||
const [coreStartValue] = startServices;
|
||||
setCoreStart(coreStartValue);
|
||||
});
|
||||
}, [getStartServices]);
|
||||
|
||||
if (!coreStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { application, docLinks } = coreStart;
|
||||
const { Provider: KibanaReactContextProvider } = createKibanaReactContext({
|
||||
application,
|
||||
docLinks,
|
||||
});
|
||||
|
||||
return <KibanaReactContextProvider>{children}</KibanaReactContextProvider>;
|
||||
};
|
|
@ -5,5 +5,6 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export { ContextWrapper } from './context_wrapper';
|
||||
export { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout';
|
||||
export { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal';
|
||||
export { getShareToSpaceFlyoutComponent } from './share_to_space_flyout';
|
||||
export { getLegacyUrlConflict } from './legacy_url_conflict';
|
||||
|
|
|
@ -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<LegacyUrlConflictProps> => {
|
||||
return (props: LegacyUrlConflictProps) => {
|
||||
return <LegacyUrlConflictInternal {...{ ...internalProps, ...props }} />;
|
||||
};
|
||||
};
|
|
@ -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<string>(`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(
|
||||
<LegacyUrlConflictInternal
|
||||
getStartServices={getStartServices}
|
||||
currentObjectId={'123'}
|
||||
otherObjectId={'456'}
|
||||
otherObjectPath={PATH}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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
|
||||
});
|
||||
});
|
|
@ -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<PluginsStart>;
|
||||
}
|
||||
|
||||
export const LegacyUrlConflictInternal = (props: InternalProps & LegacyUrlConflictProps) => {
|
||||
const {
|
||||
getStartServices,
|
||||
objectNoun = DEFAULT_OBJECT_NOUN,
|
||||
currentObjectId,
|
||||
otherObjectId,
|
||||
otherObjectPath,
|
||||
} = props;
|
||||
|
||||
const [applicationStart, setApplicationStart] = useState<ApplicationStart>();
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const [appId, setAppId] = useState<string>();
|
||||
|
||||
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 (
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
iconType="help"
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.shareToSpace.legacyUrlConflictTitle"
|
||||
defaultMessage="2 objects are associated with this URL"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.shareToSpace.legacyUrlConflictBody"
|
||||
defaultMessage="You're currently looking at {objectNoun} [id={currentObjectId}]. A legacy URL for this page shows a different {objectNoun} [id={otherObjectId}]."
|
||||
values={{ objectNoun, currentObjectId, otherObjectId }}
|
||||
/>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
color="warning"
|
||||
size="s"
|
||||
onClick={clickLinkButton}
|
||||
data-test-subj="legacy-url-conflict-go-to-other-button"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.shareToSpace.legacyUrlConflictLinkButton"
|
||||
defaultMessage="Go to other {objectNoun}"
|
||||
values={{ objectNoun }}
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
color="warning"
|
||||
size="s"
|
||||
onClick={clickDismissButton}
|
||||
data-test-subj="legacy-url-conflict-dismiss-button"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.shareToSpace.legacyUrlConflictDismissButton"
|
||||
defaultMessage="Dismiss"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiCallOut>
|
||||
);
|
||||
};
|
|
@ -26,7 +26,7 @@ export const NoSpacesAvailable = (props: Props) => {
|
|||
<EuiSpacer size="xs" />
|
||||
<EuiText size="s" color="subdued">
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.text"
|
||||
id="xpack.spaces.shareToSpace.noAvailableSpaces.canCreateNewSpace.text"
|
||||
defaultMessage="You can {createANewSpaceLink} for sharing your objects."
|
||||
values={{
|
||||
createANewSpaceLink: (
|
||||
|
@ -35,7 +35,7 @@ export const NoSpacesAvailable = (props: Props) => {
|
|||
href={getUrlForApp('management', { path: 'kibana/spaces/create' })}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.linkText"
|
||||
id="xpack.spaces.shareToSpace.noAvailableSpaces.canCreateNewSpace.linkText"
|
||||
defaultMessage="create a new space"
|
||||
/>
|
||||
</EuiLink>
|
||||
|
|
|
@ -22,58 +22,82 @@ 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 { ShareToSpaceTarget } from '../../types';
|
||||
import { useSpaces } from '../../spaces_context';
|
||||
import { ShareOptions } from '../types';
|
||||
|
||||
interface Props {
|
||||
spaces: SpaceTarget[];
|
||||
selectedSpaceIds: string[];
|
||||
spaces: ShareToSpaceTarget[];
|
||||
shareOptions: ShareOptions;
|
||||
onChange: (selectedSpaceIds: string[]) => void;
|
||||
enableCreateNewSpaceLink: boolean;
|
||||
enableSpaceAgnosticBehavior: boolean;
|
||||
}
|
||||
|
||||
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: (
|
||||
<EuiIconTip
|
||||
content={checked ? partiallyAuthorizedTooltip.checked : partiallyAuthorizedTooltip.unchecked}
|
||||
position="left"
|
||||
type="iInCircle"
|
||||
/>
|
||||
),
|
||||
disabled: true,
|
||||
});
|
||||
const activeSpaceProps = {
|
||||
append: <EuiBadge color="hollow">Current</EuiBadge>,
|
||||
disabled: true,
|
||||
checked: 'on' as 'on',
|
||||
};
|
||||
const APPEND_ACTIVE_SPACE = (
|
||||
<EuiBadge color="hollow">
|
||||
{i18n.translate('xpack.spaces.shareToSpace.currentSpaceBadge', { defaultMessage: 'Current' })}
|
||||
</EuiBadge>
|
||||
);
|
||||
const APPEND_CANNOT_SELECT = (
|
||||
<EuiIconTip
|
||||
content={i18n.translate('xpack.spaces.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked', {
|
||||
defaultMessage: 'You need additional privileges to select this space.',
|
||||
})}
|
||||
position="left"
|
||||
type="iInCircle"
|
||||
/>
|
||||
);
|
||||
const APPEND_CANNOT_DESELECT = (
|
||||
<EuiIconTip
|
||||
content={i18n.translate('xpack.spaces.shareToSpace.partiallyAuthorizedSpaceTooltip.checked', {
|
||||
defaultMessage: 'You need additional privileges to deselect this space.',
|
||||
})}
|
||||
position="left"
|
||||
type="iInCircle"
|
||||
/>
|
||||
);
|
||||
const APPEND_FEATURE_IS_DISABLED = (
|
||||
<EuiIconTip
|
||||
content={i18n.translate('xpack.spaces.shareToSpace.featureIsDisabledTooltip', {
|
||||
defaultMessage: 'This feature is disabled in this space.',
|
||||
})}
|
||||
position="left"
|
||||
type="alert"
|
||||
color="warning"
|
||||
/>
|
||||
);
|
||||
|
||||
export const SelectableSpacesControl = (props: Props) => {
|
||||
const { spaces, selectedSpaceIds, onChange } = props;
|
||||
const { services } = useKibana();
|
||||
const {
|
||||
spaces,
|
||||
shareOptions,
|
||||
onChange,
|
||||
enableCreateNewSpaceLink,
|
||||
enableSpaceAgnosticBehavior,
|
||||
} = props;
|
||||
const { services } = useSpaces();
|
||||
const { application, docLinks } = services;
|
||||
const { selectedSpaceIds, initiallySelectedSpaceIds } = shareOptions;
|
||||
|
||||
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))
|
||||
.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<SpaceOption>((space) => {
|
||||
const checked = selectedSpaceIds.includes(space.id);
|
||||
const additionalProps = getAdditionalProps(space, activeSpaceId, checked);
|
||||
return {
|
||||
label: space.name,
|
||||
prepend: <SpaceAvatar space={space} size={'s'} />,
|
||||
|
@ -81,8 +105,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.isActiveSpace && activeSpaceProps),
|
||||
...additionalProps,
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -112,13 +135,13 @@ export const SelectableSpacesControl = (props: Props) => {
|
|||
<EuiSpacer size="xs" />
|
||||
<EuiText size="s" color="subdued">
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.management.shareToSpace.unknownSpacesLabel.text"
|
||||
id="xpack.spaces.shareToSpace.unknownSpacesLabel.text"
|
||||
defaultMessage="To view hidden spaces, you need {additionalPrivilegesLink}."
|
||||
values={{
|
||||
additionalPrivilegesLink: (
|
||||
<EuiLink href={kibanaPrivilegesUrl}>
|
||||
<EuiLink href={kibanaPrivilegesUrl} target="_blank">
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.management.shareToSpace.unknownSpacesLabel.additionalPrivilegesLink"
|
||||
id="xpack.spaces.shareToSpace.unknownSpacesLabel.additionalPrivilegesLink"
|
||||
defaultMessage="additional privileges"
|
||||
/>
|
||||
</EuiLink>
|
||||
|
@ -130,25 +153,28 @@ export const SelectableSpacesControl = (props: Props) => {
|
|||
);
|
||||
};
|
||||
const getNoSpacesAvailable = () => {
|
||||
if (spaces.length < 2) {
|
||||
if (enableCreateNewSpaceLink && spaces.length < 2) {
|
||||
return <NoSpacesAvailable application={application!} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 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 + 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',
|
||||
'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 ? <EuiText size="xs">{hiddenSpacesLabel}</EuiText> : null;
|
||||
|
@ -193,3 +219,55 @@ export const SelectableSpacesControl = (props: Props) => {
|
|||
</EuiFormRow>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets additional props for the selection option.
|
||||
*/
|
||||
function getAdditionalProps(
|
||||
space: ShareToSpaceTarget,
|
||||
activeSpaceId: string | false,
|
||||
checked: boolean
|
||||
) {
|
||||
if (space.id === activeSpaceId) {
|
||||
return {
|
||||
append: APPEND_ACTIVE_SPACE,
|
||||
disabled: true,
|
||||
checked: 'on' as 'on',
|
||||
};
|
||||
}
|
||||
if (space.cannotShareToSpace) {
|
||||
return {
|
||||
append: (
|
||||
<>
|
||||
{checked ? APPEND_CANNOT_DESELECT : APPEND_CANNOT_SELECT}
|
||||
{space.isFeatureDisabled ? APPEND_FEATURE_IS_DISABLED : null}
|
||||
</>
|
||||
),
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
if (space.isFeatureDisabled) {
|
||||
return {
|
||||
append: APPEND_FEATURE_IS_DISABLED,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: ShareToSpaceTarget, b: ShareToSpaceTarget) => {
|
||||
if (a.id === activeSpaceId) {
|
||||
return -1;
|
||||
}
|
||||
if (b.id === activeSpaceId) {
|
||||
return 1;
|
||||
}
|
||||
if (a.isFeatureDisabled !== b.isFeatureDisabled) {
|
||||
return a.isFeatureDisabled ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -8,27 +8,33 @@
|
|||
import './share_mode_control.scss';
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiCallOut,
|
||||
EuiCheckableCard,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormFieldset,
|
||||
EuiIconTip,
|
||||
EuiLink,
|
||||
EuiLoadingSpinner,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
} 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';
|
||||
import { DocumentationLinksService } from '../../lib';
|
||||
import { useSpaces } from '../../spaces_context';
|
||||
import { ShareToSpaceTarget } from '../../types';
|
||||
import { ShareOptions } from '../types';
|
||||
|
||||
interface Props {
|
||||
spaces: SpaceTarget[];
|
||||
spaces: ShareToSpaceTarget[];
|
||||
objectNoun: string;
|
||||
canShareToAllSpaces: boolean;
|
||||
selectedSpaceIds: string[];
|
||||
shareOptions: ShareOptions;
|
||||
onChange: (selectedSpaceIds: string[]) => void;
|
||||
disabled?: boolean;
|
||||
enableCreateNewSpaceLink: boolean;
|
||||
enableSpaceAgnosticBehavior: boolean;
|
||||
}
|
||||
|
||||
function createLabel({
|
||||
|
@ -63,31 +69,41 @@ function createLabel({
|
|||
}
|
||||
|
||||
export const ShareModeControl = (props: Props) => {
|
||||
const { spaces, canShareToAllSpaces, selectedSpaceIds, onChange } = props;
|
||||
const {
|
||||
spaces,
|
||||
objectNoun,
|
||||
canShareToAllSpaces,
|
||||
shareOptions,
|
||||
onChange,
|
||||
enableCreateNewSpaceLink,
|
||||
enableSpaceAgnosticBehavior,
|
||||
} = props;
|
||||
const { services } = useSpaces();
|
||||
const { docLinks } = services;
|
||||
|
||||
if (spaces.length === 0) {
|
||||
return <EuiLoadingSpinner />;
|
||||
}
|
||||
|
||||
const { selectedSpaceIds } = shareOptions;
|
||||
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 object available in all current and future spaces.' }
|
||||
),
|
||||
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.' }
|
||||
),
|
||||
}),
|
||||
|
@ -96,19 +112,15 @@ 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 object available in selected spaces only.' }
|
||||
),
|
||||
text: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text', {
|
||||
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
|
||||
|
@ -117,35 +129,77 @@ export const ShareModeControl = (props: Props) => {
|
|||
onChange(updatedSpaceIds);
|
||||
};
|
||||
|
||||
const getPrivilegeWarning = () => {
|
||||
if (!shareToExplicitSpaces.disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kibanaPrivilegesUrl = new DocumentationLinksService(
|
||||
docLinks!
|
||||
).getKibanaPrivilegesDocUrl();
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
iconType="help"
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.shareToSpace.privilegeWarningTitle"
|
||||
defaultMessage="Additional privileges required"
|
||||
/>
|
||||
}
|
||||
color="warning"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.shareToSpace.privilegeWarningBody"
|
||||
defaultMessage="To edit the spaces for this {objectNoun}, you need {readAndWritePrivilegesLink} in all spaces."
|
||||
values={{
|
||||
objectNoun,
|
||||
readAndWritePrivilegesLink: (
|
||||
<EuiLink href={kibanaPrivilegesUrl} target="_blank">
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.shareToSpace.privilegeWarningLink"
|
||||
defaultMessage="read and write privileges"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFormFieldset
|
||||
legend={{
|
||||
children: (
|
||||
<EuiTitle size="xs">
|
||||
<span>{shareOptionsTitle}</span>
|
||||
</EuiTitle>
|
||||
),
|
||||
}}
|
||||
{getPrivilegeWarning()}
|
||||
|
||||
<EuiCheckableCard
|
||||
id={shareToExplicitSpaces.id}
|
||||
label={createLabel(shareToExplicitSpaces)}
|
||||
checked={!isGlobalControlChecked}
|
||||
onChange={() => toggleShareOption(false)}
|
||||
disabled={shareToExplicitSpaces.disabled}
|
||||
>
|
||||
<EuiCheckableCard
|
||||
id={shareToExplicitSpaces.id}
|
||||
label={createLabel(shareToExplicitSpaces)}
|
||||
checked={!isGlobalControlChecked}
|
||||
onChange={() => toggleShareOption(false)}
|
||||
disabled={shareToExplicitSpaces.disabled}
|
||||
>
|
||||
<SelectableSpacesControl {...props} />
|
||||
</EuiCheckableCard>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiCheckableCard
|
||||
id={shareToAllSpaces.id}
|
||||
label={createLabel(shareToAllSpaces)}
|
||||
checked={isGlobalControlChecked}
|
||||
onChange={() => toggleShareOption(true)}
|
||||
disabled={shareToAllSpaces.disabled}
|
||||
<SelectableSpacesControl
|
||||
spaces={spaces}
|
||||
shareOptions={shareOptions}
|
||||
onChange={onChange}
|
||||
enableCreateNewSpaceLink={enableCreateNewSpaceLink}
|
||||
enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
|
||||
/>
|
||||
</EuiFormFieldset>
|
||||
</EuiCheckableCard>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiCheckableCard
|
||||
id={shareToAllSpaces.id}
|
||||
label={createLabel(shareToAllSpaces)}
|
||||
checked={isGlobalControlChecked}
|
||||
onChange={() => toggleShareOption(true)}
|
||||
disabled={shareToAllSpaces.disabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,489 +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 Boom from '@hapi/boom';
|
||||
import { mountWithIntl, nextTick } from '@kbn/test/jest';
|
||||
import { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout';
|
||||
import { ShareToSpaceForm } from './share_to_space_form';
|
||||
import { EuiLoadingSpinner, 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';
|
||||
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';
|
||||
import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public';
|
||||
import { ContextWrapper } from '.';
|
||||
|
||||
interface SetupOpts {
|
||||
mockSpaces?: Space[];
|
||||
namespaces?: string[];
|
||||
returnBeforeSpacesLoad?: boolean;
|
||||
}
|
||||
|
||||
const setup = async (opts: SetupOpts = {}) => {
|
||||
const onClose = jest.fn();
|
||||
const onObjectUpdated = jest.fn();
|
||||
|
||||
const mockSpacesManager = spacesManagerMock.create();
|
||||
|
||||
mockSpacesManager.getActiveSpace.mockResolvedValue({
|
||||
id: 'my-active-space',
|
||||
name: 'my active space',
|
||||
disabledFeatures: [],
|
||||
});
|
||||
|
||||
mockSpacesManager.getSpaces.mockResolvedValue(
|
||||
opts.mockSpaces || [
|
||||
{
|
||||
id: 'space-1',
|
||||
name: 'Space 1',
|
||||
disabledFeatures: [],
|
||||
},
|
||||
{
|
||||
id: 'space-2',
|
||||
name: 'Space 2',
|
||||
disabledFeatures: [],
|
||||
},
|
||||
{
|
||||
id: 'space-3',
|
||||
name: 'Space 3',
|
||||
disabledFeatures: [],
|
||||
},
|
||||
{
|
||||
id: 'my-active-space',
|
||||
name: 'my active space',
|
||||
disabledFeatures: [],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
mockSpacesManager.getShareSavedObjectPermissions.mockResolvedValue({ shareToAllSpaces: true });
|
||||
|
||||
const mockToastNotifications = {
|
||||
addError: jest.fn(),
|
||||
addSuccess: jest.fn(),
|
||||
};
|
||||
const savedObjectToShare = {
|
||||
type: 'dashboard',
|
||||
id: 'my-dash',
|
||||
references: [
|
||||
{
|
||||
type: 'visualization',
|
||||
id: 'my-viz',
|
||||
name: 'My Viz',
|
||||
},
|
||||
],
|
||||
meta: { icon: 'dashboard', title: 'foo' },
|
||||
namespaces: opts.namespaces || ['my-active-space', 'space-1'],
|
||||
} as SavedObjectsManagementRecord;
|
||||
|
||||
const { getStartServices } = coreMock.createSetup();
|
||||
const startServices = coreMock.createStart();
|
||||
startServices.application.capabilities = {
|
||||
...startServices.application.capabilities,
|
||||
spaces: { manage: true },
|
||||
};
|
||||
getStartServices.mockResolvedValue([startServices, , ,]);
|
||||
|
||||
// the 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
|
||||
const wrapper = mountWithIntl(
|
||||
<ContextWrapper getStartServices={getStartServices}>
|
||||
<ShareSavedObjectsToSpaceFlyout
|
||||
savedObject={savedObjectToShare}
|
||||
spacesManager={(mockSpacesManager as unknown) as SpacesManager}
|
||||
toastNotifications={(mockToastNotifications as unknown) as ToastsApi}
|
||||
onClose={onClose}
|
||||
onObjectUpdated={onObjectUpdated}
|
||||
/>
|
||||
</ContextWrapper>
|
||||
);
|
||||
|
||||
// wait for context wrapper to rerender
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
if (!opts.returnBeforeSpacesLoad) {
|
||||
// Wait for spaces manager to complete and flyout to rerender
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
}
|
||||
|
||||
return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare };
|
||||
};
|
||||
|
||||
describe('ShareToSpaceFlyout', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
it('waits for spaces to load', async () => {
|
||||
const { wrapper } = await setup({ returnBeforeSpacesLoad: true });
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(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: [] }],
|
||||
});
|
||||
|
||||
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 message within a NoSpacesAvailable when only the active space is available', async () => {
|
||||
const { wrapper, onClose } = await setup({
|
||||
mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }],
|
||||
});
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('does not show a warning callout when the saved object has multiple namespaces', async () => {
|
||||
const { wrapper, onClose } = await setup();
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiCallOut)).toHaveLength(0);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(onClose).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('shows a warning callout when the saved object only has one namespace', async () => {
|
||||
const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] });
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiCallOut)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(onClose).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('does not show the Copy flyout by default', async () => {
|
||||
const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] });
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(0);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(onClose).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('shows the Copy flyout if the the "Make a copy" button is clicked', async () => {
|
||||
const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] });
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(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('handles errors thrown from shareSavedObjectsAdd API call', async () => {
|
||||
const { wrapper, mockSpacesManager, mockToastNotifications } = await setup();
|
||||
|
||||
mockSpacesManager.shareSavedObjectAdd.mockImplementation(() => {
|
||||
return Promise.reject(Boom.serverUnavailable('Something bad happened'));
|
||||
});
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
|
||||
|
||||
// Using props callback instead of simulating clicks,
|
||||
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||
act(() => {
|
||||
spaceSelector.props().onChange(['space-2', 'space-3']);
|
||||
});
|
||||
|
||||
const startButton = findTestSubject(wrapper, 'sts-initiate-button');
|
||||
|
||||
await act(async () => {
|
||||
startButton.simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled();
|
||||
expect(mockSpacesManager.shareSavedObjectRemove).not.toHaveBeenCalled();
|
||||
expect(mockToastNotifications.addError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles errors thrown from shareSavedObjectsRemove API call', async () => {
|
||||
const { wrapper, mockSpacesManager, mockToastNotifications } = await setup();
|
||||
|
||||
mockSpacesManager.shareSavedObjectRemove.mockImplementation(() => {
|
||||
return Promise.reject(Boom.serverUnavailable('Something bad happened'));
|
||||
});
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
|
||||
|
||||
// Using props callback instead of simulating clicks,
|
||||
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||
act(() => {
|
||||
spaceSelector.props().onChange(['space-2', 'space-3']);
|
||||
});
|
||||
|
||||
const startButton = findTestSubject(wrapper, 'sts-initiate-button');
|
||||
|
||||
await act(async () => {
|
||||
startButton.simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled();
|
||||
expect(mockSpacesManager.shareSavedObjectRemove).toHaveBeenCalled();
|
||||
expect(mockToastNotifications.addError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the form to be filled out to add a space', async () => {
|
||||
const {
|
||||
wrapper,
|
||||
onClose,
|
||||
mockSpacesManager,
|
||||
mockToastNotifications,
|
||||
savedObjectToShare,
|
||||
} = await setup();
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
|
||||
|
||||
// Using props callback instead of simulating clicks,
|
||||
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||
|
||||
act(() => {
|
||||
spaceSelector.props().onChange(['space-1', 'space-2', 'space-3']);
|
||||
});
|
||||
|
||||
const startButton = findTestSubject(wrapper, 'sts-initiate-button');
|
||||
|
||||
await act(async () => {
|
||||
startButton.simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
const { type, id } = savedObjectToShare;
|
||||
const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager;
|
||||
expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']);
|
||||
expect(shareSavedObjectRemove).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockToastNotifications.addError).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows the form to be filled out to remove a space', async () => {
|
||||
const {
|
||||
wrapper,
|
||||
onClose,
|
||||
mockSpacesManager,
|
||||
mockToastNotifications,
|
||||
savedObjectToShare,
|
||||
} = await setup();
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
|
||||
|
||||
// Using props callback instead of simulating clicks,
|
||||
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||
|
||||
act(() => {
|
||||
spaceSelector.props().onChange([]);
|
||||
});
|
||||
|
||||
const startButton = findTestSubject(wrapper, 'sts-initiate-button');
|
||||
|
||||
await act(async () => {
|
||||
startButton.simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
const { type, id } = savedObjectToShare;
|
||||
const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager;
|
||||
expect(shareSavedObjectAdd).not.toHaveBeenCalled();
|
||||
expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']);
|
||||
|
||||
expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockToastNotifications.addError).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows the form to be filled out to add and remove a space', async () => {
|
||||
const {
|
||||
wrapper,
|
||||
onClose,
|
||||
mockSpacesManager,
|
||||
mockToastNotifications,
|
||||
savedObjectToShare,
|
||||
} = await setup();
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
|
||||
|
||||
// Using props callback instead of simulating clicks,
|
||||
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||
|
||||
act(() => {
|
||||
spaceSelector.props().onChange(['space-2', 'space-3']);
|
||||
});
|
||||
|
||||
const startButton = findTestSubject(wrapper, 'sts-initiate-button');
|
||||
|
||||
await act(async () => {
|
||||
startButton.simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
const { type, id } = savedObjectToShare;
|
||||
const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager;
|
||||
expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']);
|
||||
expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']);
|
||||
|
||||
expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(2);
|
||||
expect(mockToastNotifications.addError).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('space selection', () => {
|
||||
const mockSpaces = [
|
||||
{
|
||||
// normal "fully authorized" space selection option -- not the active space
|
||||
id: 'space-1',
|
||||
name: 'Space 1',
|
||||
disabledFeatures: [],
|
||||
},
|
||||
{
|
||||
// "partially authorized" space selection option -- not the active space
|
||||
id: 'space-2',
|
||||
name: 'Space 2',
|
||||
disabledFeatures: [],
|
||||
authorizedPurposes: { shareSavedObjectsIntoSpace: false },
|
||||
},
|
||||
{
|
||||
// "active space" selection option (determined by an ID that matches the result of `getActiveSpace`, mocked at top)
|
||||
id: 'my-active-space',
|
||||
name: 'my active space',
|
||||
disabledFeatures: [],
|
||||
},
|
||||
];
|
||||
|
||||
const expectActiveSpace = (option: any) => {
|
||||
expect(option.append).toMatchInlineSnapshot(`
|
||||
<EuiBadge
|
||||
color="hollow"
|
||||
>
|
||||
Current
|
||||
</EuiBadge>
|
||||
`);
|
||||
// by definition, the active space will always be checked
|
||||
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) {
|
||||
expect(option.append).toMatchInlineSnapshot(`
|
||||
<EuiIconTip
|
||||
content="You need additional privileges to deselect this space."
|
||||
position="left"
|
||||
type="iInCircle"
|
||||
/>
|
||||
`);
|
||||
} else {
|
||||
expect(option.append).toMatchInlineSnapshot(`
|
||||
<EuiIconTip
|
||||
content="You need additional privileges to select this space."
|
||||
position="left"
|
||||
type="iInCircle"
|
||||
/>
|
||||
`);
|
||||
}
|
||||
expect(option.checked).toEqual(checked ? 'on' : undefined);
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,288 +5,12 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
EuiFlyout,
|
||||
EuiIcon,
|
||||
EuiFlyoutHeader,
|
||||
EuiTitle,
|
||||
EuiText,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiLoadingSpinner,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { ToastsStart } from 'src/core/public';
|
||||
import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public';
|
||||
import { 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';
|
||||
import React from 'react';
|
||||
import type { ShareToSpaceFlyoutProps } from '../../../../../../src/plugins/spaces_oss/public';
|
||||
import { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onObjectUpdated: () => void;
|
||||
savedObject: SavedObjectsManagementRecord;
|
||||
spacesManager: SpacesManager;
|
||||
toastNotifications: ToastsStart;
|
||||
}
|
||||
|
||||
const arraysAreEqual = (a: unknown[], b: unknown[]) =>
|
||||
a.every((x) => b.includes(x)) && b.every((x) => a.includes(x));
|
||||
|
||||
export const ShareSavedObjectsToSpaceFlyout = (props: Props) => {
|
||||
const { onClose, onObjectUpdated, savedObject, spacesManager, toastNotifications } = props;
|
||||
const { namespaces: currentNamespaces = [] } = savedObject;
|
||||
const [shareOptions, setShareOptions] = useState<ShareOptions>({ selectedSpaceIds: [] });
|
||||
const [canShareToAllSpaces, setCanShareToAllSpaces] = useState<boolean>(false);
|
||||
const [showMakeCopy, setShowMakeCopy] = useState<boolean>(false);
|
||||
|
||||
const [{ isLoading, spaces }, setSpacesState] = useState<{
|
||||
isLoading: boolean;
|
||||
spaces: SpaceTarget[];
|
||||
}>({ isLoading: true, spaces: [] });
|
||||
useEffect(() => {
|
||||
const getSpaces = spacesManager.getSpaces({ includeAuthorizedPurposes: true });
|
||||
const getActiveSpace = spacesManager.getActiveSpace();
|
||||
const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObject.type);
|
||||
Promise.all([getSpaces, getActiveSpace, getPermissions])
|
||||
.then(([allSpaces, activeSpace, permissions]) => {
|
||||
setShareOptions({
|
||||
selectedSpaceIds: currentNamespaces.filter((spaceId) => spaceId !== activeSpace.id),
|
||||
});
|
||||
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)),
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
toastNotifications.addError(e, {
|
||||
title: i18n.translate('xpack.spaces.management.shareToSpace.spacesLoadErrorTitle', {
|
||||
defaultMessage: 'Error loading available spaces',
|
||||
}),
|
||||
});
|
||||
});
|
||||
}, [currentNamespaces, spacesManager, savedObject, toastNotifications]);
|
||||
|
||||
const getSelectionChanges = () => {
|
||||
const activeSpace = spaces.find((space) => space.isActiveSpace);
|
||||
if (!activeSpace) {
|
||||
return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] };
|
||||
}
|
||||
const initialSelection = currentNamespaces.filter(
|
||||
(spaceId) => spaceId !== activeSpace.id && spaceId !== UNKNOWN_SPACE
|
||||
);
|
||||
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 selectedSpacesChanged =
|
||||
!filteredSelection.includes(ALL_SPACES_ID) &&
|
||||
!arraysAreEqual(initialSelection, filteredSelection);
|
||||
const isSelectionChanged =
|
||||
isSharedToAllSpaces ||
|
||||
isUnsharedFromAllSpaces ||
|
||||
(!isSharedToAllSpaces && !isUnsharedFromAllSpaces && selectedSpacesChanged);
|
||||
|
||||
const selectedSpacesToAdd = filteredSelection.filter(
|
||||
(spaceId) => !initialSelection.includes(spaceId)
|
||||
);
|
||||
const selectedSpacesToRemove = initialSelection.filter(
|
||||
(spaceId) => !filteredSelection.includes(spaceId)
|
||||
);
|
||||
|
||||
const spacesToAdd = isSharedToAllSpaces
|
||||
? [ALL_SPACES_ID]
|
||||
: isUnsharedFromAllSpaces
|
||||
? [activeSpace.id, ...selectedSpacesToAdd]
|
||||
: selectedSpacesToAdd;
|
||||
const spacesToRemove = isUnsharedFromAllSpaces
|
||||
? [ALL_SPACES_ID]
|
||||
: isSharedToAllSpaces
|
||||
? [activeSpace.id, ...initialSelection]
|
||||
: selectedSpacesToRemove;
|
||||
return { isSelectionChanged, spacesToAdd, spacesToRemove };
|
||||
export const getShareToSpaceFlyoutComponent = (): React.FC<ShareToSpaceFlyoutProps> => {
|
||||
return (props: ShareToSpaceFlyoutProps) => {
|
||||
return <ShareToSpaceFlyoutInternal {...props} />;
|
||||
};
|
||||
const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges();
|
||||
|
||||
const [shareInProgress, setShareInProgress] = useState(false);
|
||||
|
||||
async function startShare() {
|
||||
setShareInProgress(true);
|
||||
try {
|
||||
const { type, id, meta } = savedObject;
|
||||
const title =
|
||||
currentNamespaces.length === 1
|
||||
? i18n.translate('xpack.spaces.management.shareToSpace.shareNewSuccessTitle', {
|
||||
defaultMessage: '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();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setShareInProgress(false);
|
||||
toastNotifications.addError(e, {
|
||||
title: i18n.translate('xpack.spaces.management.shareToSpace.shareErrorTitle', {
|
||||
defaultMessage: 'Error updating saved object',
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const getFlyoutBody = () => {
|
||||
// Step 1: loading assets for main form
|
||||
if (isLoading) {
|
||||
return <EuiLoadingSpinner />;
|
||||
}
|
||||
|
||||
const activeSpace = spaces.find((x) => x.isActiveSpace)!;
|
||||
const showShareWarning =
|
||||
spaces.length > 1 && arraysAreEqual(currentNamespaces, [activeSpace.id]);
|
||||
// Step 2: Share has not been initiated yet; User must fill out form to continue.
|
||||
return (
|
||||
<ShareToSpaceForm
|
||||
spaces={spaces}
|
||||
shareOptions={shareOptions}
|
||||
onUpdate={setShareOptions}
|
||||
showShareWarning={showShareWarning}
|
||||
canShareToAllSpaces={canShareToAllSpaces}
|
||||
makeCopy={() => setShowMakeCopy(true)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (showMakeCopy) {
|
||||
return (
|
||||
<CopySavedObjectsToSpaceFlyout
|
||||
onClose={onClose}
|
||||
savedObject={savedObject}
|
||||
spacesManager={spacesManager}
|
||||
toastNotifications={toastNotifications}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFlyout onClose={onClose} maxWidth={500} data-test-subj="share-to-space-flyout">
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="m">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon size="m" type="share" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="m">
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.management.shareToSpaceFlyoutHeader"
|
||||
defaultMessage="Share to space"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="m">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type={savedObject.meta.icon || 'apps'} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiText>
|
||||
<p>{savedObject.meta.title}</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiHorizontalRule margin="m" />
|
||||
|
||||
{getFlyoutBody()}
|
||||
</EuiFlyoutBody>
|
||||
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
onClick={() => onClose()}
|
||||
data-test-subj="sts-cancel-button"
|
||||
disabled={shareInProgress}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.management.shareToSpace.cancelButton"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
fill
|
||||
onClick={() => startShare()}
|
||||
data-test-subj="sts-initiate-button"
|
||||
disabled={!isSelectionChanged || shareInProgress}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.management.shareToSpace.shareToSpacesButton"
|
||||
defaultMessage="Save & close"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,741 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license 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 Boom from '@hapi/boom';
|
||||
import { mountWithIntl, nextTick, findTestSubject } from '@kbn/test/jest';
|
||||
import { ShareToSpaceForm } from './share_to_space_form';
|
||||
import {
|
||||
EuiCallOut,
|
||||
EuiCheckableCard,
|
||||
EuiCheckableCardProps,
|
||||
EuiIconTip,
|
||||
EuiLoadingSpinner,
|
||||
EuiSelectable,
|
||||
} from '@elastic/eui';
|
||||
import { Space } from '../../../../../../src/plugins/spaces_oss/common';
|
||||
import { SelectableSpacesControl } from './selectable_spaces_control';
|
||||
import { act } from '@testing-library/react';
|
||||
import { spacesManagerMock } from '../../spaces_manager/mocks';
|
||||
import { coreMock } from '../../../../../../src/core/public/mocks';
|
||||
import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components';
|
||||
import { NoSpacesAvailable } from './no_spaces_available';
|
||||
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[];
|
||||
namespaces?: string[];
|
||||
returnBeforeSpacesLoad?: boolean;
|
||||
canShareToAllSpaces?: boolean; // default: true
|
||||
enableCreateCopyCallout?: boolean;
|
||||
enableCreateNewSpaceLink?: boolean;
|
||||
behaviorContext?: 'within-space' | 'outside-space';
|
||||
mockFeatureId?: string; // optional feature ID to use for the SpacesContext
|
||||
}
|
||||
|
||||
const setup = async (opts: SetupOpts = {}) => {
|
||||
const onClose = jest.fn();
|
||||
const onUpdate = jest.fn();
|
||||
|
||||
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 || [
|
||||
{
|
||||
id: 'space-1',
|
||||
name: 'Space 1',
|
||||
disabledFeatures: [],
|
||||
},
|
||||
{
|
||||
id: 'space-2',
|
||||
name: 'Space 2',
|
||||
disabledFeatures: [],
|
||||
},
|
||||
{
|
||||
id: 'space-3',
|
||||
name: 'Space 3',
|
||||
disabledFeatures: [],
|
||||
},
|
||||
{
|
||||
id: 'my-active-space',
|
||||
name: 'my active space',
|
||||
disabledFeatures: [],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
mockSpacesManager.getShareSavedObjectPermissions.mockResolvedValue({
|
||||
shareToAllSpaces: opts.canShareToAllSpaces ?? true,
|
||||
});
|
||||
|
||||
const savedObjectToShare = {
|
||||
type: 'dashboard',
|
||||
id: 'my-dash',
|
||||
namespaces: opts.namespaces || ['my-active-space', 'space-1'],
|
||||
icon: 'dashboard',
|
||||
title: 'foo',
|
||||
};
|
||||
|
||||
const { getStartServices } = coreMock.createSetup();
|
||||
const startServices = coreMock.createStart();
|
||||
startServices.application.capabilities = {
|
||||
...startServices.application.capabilities,
|
||||
spaces: { manage: true },
|
||||
};
|
||||
const mockToastNotifications = startServices.notifications.toasts;
|
||||
getStartServices.mockResolvedValue([startServices, , ,]);
|
||||
|
||||
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(
|
||||
<SpacesContext feature={opts.mockFeatureId}>
|
||||
<ShareToSpaceFlyout
|
||||
savedObjectTarget={savedObjectToShare}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
enableCreateCopyCallout={opts.enableCreateCopyCallout}
|
||||
enableCreateNewSpaceLink={opts.enableCreateNewSpaceLink}
|
||||
behaviorContext={opts.behaviorContext}
|
||||
/>
|
||||
</SpacesContext>
|
||||
);
|
||||
|
||||
// wait for context wrapper to rerender
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
if (!opts.returnBeforeSpacesLoad) {
|
||||
// Wait for spaces manager to complete and flyout to rerender
|
||||
wrapper.update();
|
||||
}
|
||||
|
||||
return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare };
|
||||
};
|
||||
|
||||
describe('ShareToSpaceFlyout', () => {
|
||||
it('waits for spaces to load', async () => {
|
||||
const { wrapper } = await setup({ returnBeforeSpacesLoad: true });
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1);
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
|
||||
});
|
||||
|
||||
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(EuiCallOut)).toHaveLength(0);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(onClose).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('shows a warning callout when the saved object only has one namespace', async () => {
|
||||
const { wrapper, onClose } = await setup({
|
||||
enableCreateCopyCallout,
|
||||
namespaces: ['my-active-space'],
|
||||
});
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiCallOut)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(onClose).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('does not show the Copy flyout by default', async () => {
|
||||
const { wrapper, onClose } = await setup({
|
||||
enableCreateCopyCallout,
|
||||
namespaces: ['my-active-space'],
|
||||
});
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(0);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(onClose).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('shows the Copy flyout if the the "Make a copy" button is clicked', async () => {
|
||||
const { wrapper, onClose } = await setup({
|
||||
enableCreateCopyCallout,
|
||||
namespaces: ['my-active-space'],
|
||||
});
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(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);
|
||||
});
|
||||
});
|
||||
|
||||
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(onClose).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
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: [] }],
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { wrapper, mockSpacesManager, mockToastNotifications } = await setup();
|
||||
|
||||
mockSpacesManager.shareSavedObjectAdd.mockRejectedValue(
|
||||
Boom.serverUnavailable('Something bad happened')
|
||||
);
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
|
||||
|
||||
// Using props callback instead of simulating clicks,
|
||||
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||
act(() => {
|
||||
spaceSelector.props().onChange(['space-2', 'space-3']);
|
||||
});
|
||||
|
||||
const startButton = findTestSubject(wrapper, 'sts-initiate-button');
|
||||
|
||||
await act(async () => {
|
||||
startButton.simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled();
|
||||
expect(mockSpacesManager.shareSavedObjectRemove).not.toHaveBeenCalled();
|
||||
expect(mockToastNotifications.addError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles errors thrown from shareSavedObjectsRemove API call', async () => {
|
||||
const { wrapper, mockSpacesManager, mockToastNotifications } = await setup();
|
||||
|
||||
mockSpacesManager.shareSavedObjectRemove.mockRejectedValue(
|
||||
Boom.serverUnavailable('Something bad happened')
|
||||
);
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
|
||||
|
||||
// Using props callback instead of simulating clicks,
|
||||
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||
act(() => {
|
||||
spaceSelector.props().onChange(['space-2', 'space-3']);
|
||||
});
|
||||
|
||||
const startButton = findTestSubject(wrapper, 'sts-initiate-button');
|
||||
|
||||
await act(async () => {
|
||||
startButton.simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled();
|
||||
expect(mockSpacesManager.shareSavedObjectRemove).toHaveBeenCalled();
|
||||
expect(mockToastNotifications.addError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the form to be filled out to add a space', async () => {
|
||||
const {
|
||||
wrapper,
|
||||
onClose,
|
||||
mockSpacesManager,
|
||||
mockToastNotifications,
|
||||
savedObjectToShare,
|
||||
} = await setup();
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
|
||||
|
||||
// Using props callback instead of simulating clicks,
|
||||
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||
|
||||
act(() => {
|
||||
spaceSelector.props().onChange(['space-1', 'space-2', 'space-3']);
|
||||
});
|
||||
|
||||
const startButton = findTestSubject(wrapper, 'sts-initiate-button');
|
||||
|
||||
await act(async () => {
|
||||
startButton.simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
const { type, id } = savedObjectToShare;
|
||||
const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager;
|
||||
expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']);
|
||||
expect(shareSavedObjectRemove).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockToastNotifications.addError).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows the form to be filled out to remove a space', async () => {
|
||||
const {
|
||||
wrapper,
|
||||
onClose,
|
||||
mockSpacesManager,
|
||||
mockToastNotifications,
|
||||
savedObjectToShare,
|
||||
} = await setup();
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
|
||||
|
||||
// Using props callback instead of simulating clicks,
|
||||
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||
|
||||
act(() => {
|
||||
spaceSelector.props().onChange([]);
|
||||
});
|
||||
|
||||
const startButton = findTestSubject(wrapper, 'sts-initiate-button');
|
||||
|
||||
await act(async () => {
|
||||
startButton.simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
const { type, id } = savedObjectToShare;
|
||||
const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager;
|
||||
expect(shareSavedObjectAdd).not.toHaveBeenCalled();
|
||||
expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']);
|
||||
|
||||
expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockToastNotifications.addError).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows the form to be filled out to add and remove a space', async () => {
|
||||
const {
|
||||
wrapper,
|
||||
onClose,
|
||||
mockSpacesManager,
|
||||
mockToastNotifications,
|
||||
savedObjectToShare,
|
||||
} = await setup();
|
||||
|
||||
expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
|
||||
|
||||
// Using props callback instead of simulating clicks,
|
||||
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||
|
||||
act(() => {
|
||||
spaceSelector.props().onChange(['space-2', 'space-3']);
|
||||
});
|
||||
|
||||
const startButton = findTestSubject(wrapper, 'sts-initiate-button');
|
||||
|
||||
await act(async () => {
|
||||
startButton.simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
const { type, id } = savedObjectToShare;
|
||||
const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager;
|
||||
expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']);
|
||||
expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']);
|
||||
|
||||
expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(2);
|
||||
expect(mockToastNotifications.addError).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('correctly renders checkable cards', () => {
|
||||
function getCheckableCardProps(
|
||||
wrapper: ReactWrapper<React.PropsWithChildren<EuiCheckableCardProps>>
|
||||
) {
|
||||
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<T>(wrapper: ReactWrapper<T, never>) {
|
||||
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 mockFeatureId = 'some-feature';
|
||||
|
||||
const mockSpaces = [
|
||||
{
|
||||
// normal "fully authorized" space selection option -- not the active space
|
||||
id: 'space-1',
|
||||
name: 'Space 1',
|
||||
disabledFeatures: [],
|
||||
},
|
||||
{
|
||||
// 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',
|
||||
name: 'my active space',
|
||||
disabledFeatures: [],
|
||||
},
|
||||
];
|
||||
|
||||
const expectActiveSpace = (option: any, { spaceId }: { spaceId: string }) => {
|
||||
expect(option['data-space-id']).toEqual(spaceId);
|
||||
expect(option.append).toMatchInlineSnapshot(`
|
||||
<EuiBadge
|
||||
color="hollow"
|
||||
>
|
||||
Current
|
||||
</EuiBadge>
|
||||
`);
|
||||
// by definition, the active space will always be checked
|
||||
expect(option.checked).toEqual('on');
|
||||
expect(option.disabled).toEqual(true);
|
||||
};
|
||||
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(`
|
||||
<React.Fragment>
|
||||
<EuiIconTip
|
||||
content="You need additional privileges to deselect this space."
|
||||
position="left"
|
||||
type="iInCircle"
|
||||
/>
|
||||
<EuiIconTip
|
||||
color="warning"
|
||||
content="This feature is disabled in this space."
|
||||
position="left"
|
||||
type="alert"
|
||||
/>
|
||||
</React.Fragment>
|
||||
`);
|
||||
} else if (checked && !featureIsDisabled) {
|
||||
expect(option.append).toMatchInlineSnapshot(`
|
||||
<React.Fragment>
|
||||
<EuiIconTip
|
||||
content="You need additional privileges to deselect this space."
|
||||
position="left"
|
||||
type="iInCircle"
|
||||
/>
|
||||
</React.Fragment>
|
||||
`);
|
||||
} else if (!checked && !featureIsDisabled) {
|
||||
expect(option.append).toMatchInlineSnapshot(`
|
||||
<React.Fragment>
|
||||
<EuiIconTip
|
||||
content="You need additional privileges to select this space."
|
||||
position="left"
|
||||
type="iInCircle"
|
||||
/>
|
||||
</React.Fragment>
|
||||
`);
|
||||
} 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(`
|
||||
<EuiIconTip
|
||||
color="warning"
|
||||
content="This feature is disabled in this space."
|
||||
position="left"
|
||||
type="alert"
|
||||
/>
|
||||
`);
|
||||
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('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 });
|
||||
|
||||
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 });
|
||||
expectInactiveSpace(options[2], { spaceId: 'space-2', checked: false });
|
||||
expectNeedAdditionalPrivileges(options[3], { spaceId: 'space-3', checked: true });
|
||||
expectNeedAdditionalPrivileges(options[4], { spaceId: 'space-4', checked: false });
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
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 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({ behaviorContext, mockSpaces, namespaces });
|
||||
|
||||
const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,352 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license 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, useMemo } from 'react';
|
||||
import {
|
||||
EuiFlyout,
|
||||
EuiIcon,
|
||||
EuiFlyoutHeader,
|
||||
EuiTitle,
|
||||
EuiText,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiLoadingSpinner,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
} 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 { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants';
|
||||
import { SpacesManager } from '../../spaces_manager';
|
||||
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';
|
||||
import { DEFAULT_OBJECT_NOUN } from './constants';
|
||||
|
||||
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));
|
||||
|
||||
function createDefaultChangeSpacesHandler(
|
||||
object: Required<Omit<ShareToSpaceSavedObjectTarget, 'icon'>>,
|
||||
spacesManager: SpacesManager,
|
||||
toastNotifications: ToastsStart
|
||||
) {
|
||||
return async (spacesToAdd: string[], spacesToRemove: string[]) => {
|
||||
const { type, id, title } = object;
|
||||
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_SPACES_TARGET : `${spacesToAdd.length}`;
|
||||
const toastText =
|
||||
!isSharedToAllSpaces && spacesToAdd.length === 1
|
||||
? i18n.translate('xpack.spaces.shareToSpace.shareAddSuccessTextSingular', {
|
||||
defaultMessage: `'{object}' was added to 1 space.`,
|
||||
values: { object: title },
|
||||
})
|
||||
: i18n.translate('xpack.spaces.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_SPACES_TARGET : `${spacesToRemove.length}`;
|
||||
const toastText =
|
||||
!isUnsharedFromAllSpaces && spacesToRemove.length === 1
|
||||
? i18n.translate('xpack.spaces.shareToSpace.shareRemoveSuccessTextSingular', {
|
||||
defaultMessage: `'{object}' was removed from 1 space.`,
|
||||
values: { object: title },
|
||||
})
|
||||
: i18n.translate('xpack.spaces.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: ShareToSpaceFlyoutProps) => {
|
||||
const { spacesManager, shareToSpacesDataPromise, services } = useSpaces();
|
||||
const { notifications } = services;
|
||||
const toastNotifications = notifications!.toasts;
|
||||
|
||||
const { savedObjectTarget: object } = props;
|
||||
const savedObjectTarget = useMemo(
|
||||
() => ({
|
||||
type: object.type,
|
||||
id: object.id,
|
||||
namespaces: object.namespaces,
|
||||
icon: object.icon,
|
||||
title: object.title || `${object.type} [id=${object.id}]`,
|
||||
noun: object.noun || DEFAULT_OBJECT_NOUN,
|
||||
}),
|
||||
[object]
|
||||
);
|
||||
const {
|
||||
flyoutIcon,
|
||||
flyoutTitle = i18n.translate('xpack.spaces.shareToSpace.flyoutTitle', {
|
||||
defaultMessage: 'Edit spaces for {objectNoun}',
|
||||
values: { objectNoun: savedObjectTarget.noun },
|
||||
}),
|
||||
enableCreateCopyCallout = false,
|
||||
enableCreateNewSpaceLink = false,
|
||||
behaviorContext,
|
||||
changeSpacesHandler = createDefaultChangeSpacesHandler(
|
||||
savedObjectTarget,
|
||||
spacesManager,
|
||||
toastNotifications
|
||||
),
|
||||
onUpdate = () => null,
|
||||
onClose = () => null,
|
||||
} = props;
|
||||
const enableSpaceAgnosticBehavior = behaviorContext === 'outside-space';
|
||||
|
||||
const [shareOptions, setShareOptions] = useState<ShareOptions>({
|
||||
selectedSpaceIds: [],
|
||||
initiallySelectedSpaceIds: [],
|
||||
});
|
||||
const [canShareToAllSpaces, setCanShareToAllSpaces] = useState<boolean>(false);
|
||||
const [showMakeCopy, setShowMakeCopy] = useState<boolean>(false);
|
||||
|
||||
const [{ isLoading, spaces }, setSpacesState] = useState<{
|
||||
isLoading: boolean;
|
||||
spaces: ShareToSpaceTarget[];
|
||||
}>({ isLoading: true, spaces: [] });
|
||||
useEffect(() => {
|
||||
const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObjectTarget.type);
|
||||
Promise.all([shareToSpacesDataPromise, getPermissions])
|
||||
.then(([shareToSpacesData, permissions]) => {
|
||||
const activeSpaceId = !enableSpaceAgnosticBehavior && shareToSpacesData.activeSpaceId;
|
||||
const selectedSpaceIds = savedObjectTarget.namespaces.filter(
|
||||
(spaceId) => spaceId !== activeSpaceId
|
||||
);
|
||||
setShareOptions({
|
||||
selectedSpaceIds,
|
||||
initiallySelectedSpaceIds: selectedSpaceIds,
|
||||
});
|
||||
setCanShareToAllSpaces(permissions.shareToAllSpaces);
|
||||
setSpacesState({
|
||||
isLoading: false,
|
||||
spaces: [...shareToSpacesData.spacesMap].map(([, spaceTarget]) => spaceTarget),
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
toastNotifications.addError(e, {
|
||||
title: i18n.translate('xpack.spaces.shareToSpace.spacesLoadErrorTitle', {
|
||||
defaultMessage: 'Error loading available spaces',
|
||||
}),
|
||||
});
|
||||
});
|
||||
}, [
|
||||
savedObjectTarget,
|
||||
spacesManager,
|
||||
shareToSpacesDataPromise,
|
||||
toastNotifications,
|
||||
enableSpaceAgnosticBehavior,
|
||||
]);
|
||||
|
||||
const getSelectionChanges = () => {
|
||||
if (!spaces.length) {
|
||||
return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] };
|
||||
}
|
||||
const activeSpaceId =
|
||||
!enableSpaceAgnosticBehavior && spaces.find((space) => space.isActiveSpace)!.id;
|
||||
const initialSelection = savedObjectTarget.namespaces.filter(
|
||||
(spaceId) => spaceId !== activeSpaceId && spaceId !== UNKNOWN_SPACE
|
||||
);
|
||||
const { selectedSpaceIds } = shareOptions;
|
||||
const filteredSelection = selectedSpaceIds.filter((x) => x !== UNKNOWN_SPACE);
|
||||
|
||||
const initiallySharedToAllSpaces = initialSelection.includes(ALL_SPACES_ID);
|
||||
const selectionIncludesAllSpaces = filteredSelection.includes(ALL_SPACES_ID);
|
||||
|
||||
const isSharedToAllSpaces = !initiallySharedToAllSpaces && selectionIncludesAllSpaces;
|
||||
const isUnsharedFromAllSpaces = initiallySharedToAllSpaces && !selectionIncludesAllSpaces;
|
||||
|
||||
const selectedSpacesChanged =
|
||||
!selectionIncludesAllSpaces && !arraysAreEqual(initialSelection, filteredSelection);
|
||||
const isSelectionChanged =
|
||||
isSharedToAllSpaces ||
|
||||
isUnsharedFromAllSpaces ||
|
||||
(!isSharedToAllSpaces && !isUnsharedFromAllSpaces && selectedSpacesChanged);
|
||||
|
||||
const selectedSpacesToAdd = filteredSelection.filter(
|
||||
(spaceId) => !initialSelection.includes(spaceId)
|
||||
);
|
||||
const selectedSpacesToRemove = initialSelection.filter(
|
||||
(spaceId) => !filteredSelection.includes(spaceId)
|
||||
);
|
||||
|
||||
const activeSpaceArray = activeSpaceId ? [activeSpaceId] : []; // if we have an active space, it is automatically selected
|
||||
const spacesToAdd = isSharedToAllSpaces
|
||||
? [ALL_SPACES_ID]
|
||||
: isUnsharedFromAllSpaces
|
||||
? [...activeSpaceArray, ...selectedSpacesToAdd]
|
||||
: selectedSpacesToAdd;
|
||||
const spacesToRemove =
|
||||
isUnsharedFromAllSpaces || !isSharedToAllSpaces
|
||||
? selectedSpacesToRemove
|
||||
: [...activeSpaceArray, ...initialSelection];
|
||||
return { isSelectionChanged, spacesToAdd, spacesToRemove };
|
||||
};
|
||||
const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges();
|
||||
|
||||
const [shareInProgress, setShareInProgress] = useState(false);
|
||||
|
||||
async function startShare() {
|
||||
setShareInProgress(true);
|
||||
try {
|
||||
await changeSpacesHandler(spacesToAdd, spacesToRemove);
|
||||
onUpdate();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setShareInProgress(false);
|
||||
toastNotifications.addError(e, {
|
||||
title: i18n.translate('xpack.spaces.shareToSpace.shareErrorTitle', {
|
||||
values: { objectNoun: savedObjectTarget.noun },
|
||||
defaultMessage: 'Error updating {objectNoun}',
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const getFlyoutBody = () => {
|
||||
// Step 1: loading assets for main form
|
||||
if (isLoading) {
|
||||
return <EuiLoadingSpinner />;
|
||||
}
|
||||
|
||||
// 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 &&
|
||||
!arraysAreEqual(savedObjectTarget.namespaces, [ALL_SPACES_ID]);
|
||||
// Step 2: Share has not been initiated yet; User must fill out form to continue.
|
||||
return (
|
||||
<ShareToSpaceForm
|
||||
spaces={spaces}
|
||||
objectNoun={savedObjectTarget.noun}
|
||||
shareOptions={shareOptions}
|
||||
onUpdate={setShareOptions}
|
||||
showCreateCopyCallout={showCreateCopyCallout}
|
||||
canShareToAllSpaces={canShareToAllSpaces}
|
||||
makeCopy={() => setShowMakeCopy(true)}
|
||||
enableCreateNewSpaceLink={enableCreateNewSpaceLink}
|
||||
enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (showMakeCopy) {
|
||||
return (
|
||||
<CopySavedObjectsToSpaceFlyout
|
||||
onClose={onClose}
|
||||
savedObjectTarget={savedObjectTarget}
|
||||
spacesManager={spacesManager}
|
||||
toastNotifications={toastNotifications}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isStartShareButtonDisabled =
|
||||
!isSelectionChanged ||
|
||||
shareInProgress ||
|
||||
(enableSpaceAgnosticBehavior && !shareOptions.selectedSpaceIds.length); // the object must exist in at least one space, or all spaces
|
||||
|
||||
return (
|
||||
<EuiFlyout onClose={onClose} maxWidth={500} data-test-subj="share-to-space-flyout">
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="m">
|
||||
{flyoutIcon && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon size="m" type={flyoutIcon} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="m">
|
||||
<h2>{flyoutTitle}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="m">
|
||||
{savedObjectTarget.icon && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type={savedObjectTarget.icon} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem>
|
||||
<EuiText>
|
||||
<p>{savedObjectTarget.title}</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiHorizontalRule margin="m" />
|
||||
|
||||
{getFlyoutBody()}
|
||||
</EuiFlyoutBody>
|
||||
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
onClick={() => onClose()}
|
||||
data-test-subj="sts-cancel-button"
|
||||
disabled={shareInProgress}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.shareToSpace.cancelButton"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
fill
|
||||
onClick={() => startShare()}
|
||||
data-test-subj="sts-initiate-button"
|
||||
disabled={isStartShareButtonDisabled}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.shareToSpace.shareToSpacesButton"
|
||||
defaultMessage="Save & close"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
};
|
|
@ -7,73 +7,84 @@
|
|||
|
||||
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 { ShareToSpaceTarget } from '../../types';
|
||||
import { ShareOptions } from '../types';
|
||||
import { ShareModeControl } from './share_mode_control';
|
||||
|
||||
interface Props {
|
||||
spaces: SpaceTarget[];
|
||||
spaces: ShareToSpaceTarget[];
|
||||
objectNoun: string;
|
||||
onUpdate: (shareOptions: ShareOptions) => void;
|
||||
shareOptions: ShareOptions;
|
||||
showShareWarning: boolean;
|
||||
showCreateCopyCallout: boolean;
|
||||
canShareToAllSpaces: boolean;
|
||||
makeCopy: () => void;
|
||||
enableCreateNewSpaceLink: boolean;
|
||||
enableSpaceAgnosticBehavior: boolean;
|
||||
}
|
||||
|
||||
export const ShareToSpaceForm = (props: Props) => {
|
||||
const { spaces, onUpdate, shareOptions, showShareWarning, canShareToAllSpaces, makeCopy } = props;
|
||||
const {
|
||||
spaces,
|
||||
objectNoun,
|
||||
onUpdate,
|
||||
shareOptions,
|
||||
showCreateCopyCallout,
|
||||
canShareToAllSpaces,
|
||||
makeCopy,
|
||||
enableCreateNewSpaceLink,
|
||||
enableSpaceAgnosticBehavior,
|
||||
} = props;
|
||||
|
||||
const setSelectedSpaceIds = (selectedSpaceIds: string[]) =>
|
||||
onUpdate({ ...shareOptions, selectedSpaceIds });
|
||||
|
||||
const getShareWarning = () => {
|
||||
if (!showShareWarning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.management.shareToSpace.shareWarningTitle"
|
||||
defaultMessage="Editing a shared object applies the changes in every space"
|
||||
/>
|
||||
}
|
||||
color="warning"
|
||||
>
|
||||
const createCopyCallout = showCreateCopyCallout ? (
|
||||
<Fragment>
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.management.shareToSpace.shareWarningBody"
|
||||
defaultMessage="To edit in only one space, {makeACopyLink} instead."
|
||||
values={{
|
||||
makeACopyLink: (
|
||||
<EuiLink data-test-subj="sts-copy-link" onClick={() => makeCopy()}>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.management.shareToSpace.shareWarningLink"
|
||||
defaultMessage="make a copy"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
id="xpack.spaces.shareToSpace.shareWarningTitle"
|
||||
defaultMessage="Changes are synchronized across spaces"
|
||||
/>
|
||||
</EuiCallOut>
|
||||
}
|
||||
color="warning"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.shareToSpace.shareWarningBody"
|
||||
defaultMessage="Your changes appear in each space you select. {makeACopyLink} if you don't want to synchronize your changes."
|
||||
values={{
|
||||
makeACopyLink: (
|
||||
<EuiLink data-test-subj="sts-copy-link" onClick={() => makeCopy()}>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.shareToSpace.shareWarningLink"
|
||||
defaultMessage="Make a copy"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
|
||||
<EuiHorizontalRule margin="m" />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
<EuiSpacer size="m" />
|
||||
</Fragment>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div data-test-subj="share-to-space-form">
|
||||
{getShareWarning()}
|
||||
{createCopyCallout}
|
||||
|
||||
<ShareModeControl
|
||||
spaces={spaces}
|
||||
objectNoun={objectNoun}
|
||||
canShareToAllSpaces={canShareToAllSpaces}
|
||||
selectedSpaceIds={shareOptions.selectedSpaceIds}
|
||||
shareOptions={shareOptions}
|
||||
onChange={(selection) => setSelectedSpaceIds(selection)}
|
||||
enableCreateNewSpaceLink={enableCreateNewSpaceLink}
|
||||
enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -6,3 +6,5 @@
|
|||
*/
|
||||
|
||||
export { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space_service';
|
||||
export { getShareToSpaceFlyoutComponent, getLegacyUrlConflict } from './components';
|
||||
export { createRedirectLegacyUrl } from './utils';
|
||||
|
|
|
@ -5,21 +5,14 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { coreMock, notificationServiceMock } 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 notificationsStart = notificationServiceMock.createStartContract();
|
||||
const { getStartServices } = coreMock.createSetup();
|
||||
return new ShareToSpaceSavedObjectsManagementAction(
|
||||
spacesManager,
|
||||
notificationsStart,
|
||||
getStartServices
|
||||
);
|
||||
const spacesApiUi = uiApiMock.create();
|
||||
return new ShareToSpaceSavedObjectsManagementAction(spacesApiUi);
|
||||
};
|
||||
describe('#euiAction.available', () => {
|
||||
describe('with an object type that has a namespaceType of "multiple"', () => {
|
||||
|
|
|
@ -7,23 +7,20 @@
|
|||
|
||||
import React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { NotificationsStart, StartServicesAccessor } from 'src/core/public';
|
||||
import {
|
||||
SavedObjectsManagementAction,
|
||||
SavedObjectsManagementRecord,
|
||||
} from '../../../../../src/plugins/saved_objects_management/public';
|
||||
import { ContextWrapper, ShareSavedObjectsToSpaceFlyout } 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';
|
||||
|
||||
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',
|
||||
|
@ -43,11 +40,7 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage
|
|||
|
||||
private isDataChanged: boolean = false;
|
||||
|
||||
constructor(
|
||||
private readonly spacesManager: SpacesManager,
|
||||
private readonly notifications: NotificationsStart,
|
||||
private readonly getStartServices: StartServicesAccessor<PluginsStart>
|
||||
) {
|
||||
constructor(private readonly spacesApiUi: SpacesApiUi) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
@ -56,16 +49,24 @@ 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 (
|
||||
<ContextWrapper getStartServices={this.getStartServices}>
|
||||
<ShareSavedObjectsToSpaceFlyout
|
||||
onClose={this.onClose}
|
||||
onObjectUpdated={() => (this.isDataChanged = true)}
|
||||
savedObject={this.record}
|
||||
spacesManager={this.spacesManager}
|
||||
toastNotifications={this.notifications.toasts}
|
||||
/>
|
||||
</ContextWrapper>
|
||||
<ShareToSpaceFlyout
|
||||
savedObjectTarget={savedObjectTarget}
|
||||
flyoutIcon="share"
|
||||
onUpdate={() => (this.isDataChanged = true)}
|
||||
onClose={this.onClose}
|
||||
enableCreateCopyCallout={true}
|
||||
enableCreateNewSpaceLink={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,207 +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 { shallowWithIntl } from '@kbn/test/jest';
|
||||
import { SpacesManager } from '../spaces_manager';
|
||||
import { spacesManagerMock } from '../spaces_manager/mocks';
|
||||
import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column';
|
||||
import { SpaceTarget } from './types';
|
||||
|
||||
const ACTIVE_SPACE: SpaceTarget = {
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
color: '#ffffff',
|
||||
isActiveSpace: true,
|
||||
};
|
||||
const getSpaceData = (inactiveSpaceCount: number = 0) => {
|
||||
const inactive = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel']
|
||||
.map<SpaceTarget>((name) => ({
|
||||
id: name.toLowerCase(),
|
||||
name,
|
||||
color: `#123456`, // must be a valid color as `render()` is used below
|
||||
isActiveSpace: false,
|
||||
}))
|
||||
.slice(0, inactiveSpaceCount);
|
||||
const spaceTargets = [ACTIVE_SPACE, ...inactive];
|
||||
const namespaces = spaceTargets.map(({ id }) => id);
|
||||
return { spaceTargets, 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<string, SpaceTarget>()
|
||||
);
|
||||
const element = column.euiColumn.render(namespaces);
|
||||
return shallowWithIntl(element);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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', () => {
|
||||
describe('with only the active space', () => {
|
||||
const { spaceTargets, namespaces } = getSpaceData();
|
||||
const wrapper = createColumn(spaceTargets, namespaces);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with the active space and one inactive space', () => {
|
||||
const { spaceTargets, namespaces } = getSpaceData(1);
|
||||
const wrapper = createColumn(spaceTargets, namespaces);
|
||||
|
||||
it('shows one badge without button', async () => {
|
||||
const badges = wrapper.find('EuiBadge');
|
||||
expect(badges).toMatchInlineSnapshot(`
|
||||
<EuiBadge
|
||||
color="#123456"
|
||||
>
|
||||
Alpha
|
||||
</EuiBadge>
|
||||
`);
|
||||
const button = wrapper.find('EuiButtonEmpty');
|
||||
expect(button).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with the active space and five inactive spaces', () => {
|
||||
const { spaceTargets, namespaces } = getSpaceData(5);
|
||||
const wrapper = createColumn(spaceTargets, namespaces);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with the active space, five inactive spaces, and one unauthorized space', () => {
|
||||
const { spaceTargets, namespaces } = getSpaceData(5);
|
||||
const wrapper = createColumn(spaceTargets, [...namespaces, '?']);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with the active space, five inactive spaces, and two unauthorized spaces', () => {
|
||||
const { spaceTargets, namespaces } = getSpaceData(5);
|
||||
const wrapper = createColumn(spaceTargets, [...namespaces, '?', '?']);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with the active space and six inactive spaces', () => {
|
||||
const { spaceTargets, namespaces } = getSpaceData(6);
|
||||
const wrapper = createColumn(spaceTargets, namespaces);
|
||||
|
||||
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');
|
||||
expect(button.find('FormattedMessage').props()).toEqual({
|
||||
defaultMessage: '+{count} more',
|
||||
id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink',
|
||||
values: { count: 1 },
|
||||
});
|
||||
|
||||
button.simulate('click');
|
||||
badgeText = wrapper.find('EuiBadge').map((x) => x.render().text());
|
||||
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, '?']);
|
||||
|
||||
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');
|
||||
expect(button.find('FormattedMessage').props()).toEqual({
|
||||
defaultMessage: '+{count} more',
|
||||
id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink',
|
||||
values: { count: 2 },
|
||||
});
|
||||
|
||||
button.simulate('click');
|
||||
badgeText = wrapper.find('EuiBadge').map((x) => x.render().text());
|
||||
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, '?', '?']);
|
||||
|
||||
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');
|
||||
expect(button.find('FormattedMessage').props()).toEqual({
|
||||
defaultMessage: '+{count} more',
|
||||
id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink',
|
||||
values: { count: 3 },
|
||||
});
|
||||
|
||||
button.simulate('click');
|
||||
badgeText = wrapper.find('EuiBadge').map((x) => x.render().text());
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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, '?']);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,151 +5,30 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState, ReactNode } 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 { SpacesManager } from '../spaces_manager';
|
||||
import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants';
|
||||
import { getSpaceColor } from '..';
|
||||
|
||||
const SPACES_DISPLAY_COUNT = 5;
|
||||
|
||||
type SpaceMap = Map<string, SpaceTarget>;
|
||||
interface ColumnDataProps {
|
||||
namespaces?: string[];
|
||||
data?: SpaceMap;
|
||||
}
|
||||
|
||||
const ColumnDisplay = ({ namespaces, data }: ColumnDataProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (!data) {
|
||||
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 = data.get(namespace);
|
||||
if (spaceTarget === undefined) {
|
||||
// in the event that a new space was created after this page has loaded, fall back to displaying the space ID
|
||||
authorizedSpaceTargets.push({ id: namespace, name: namespace, 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 ? (
|
||||
<EuiButtonEmpty size="xs" onClick={() => setIsExpanded(false)}>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.management.shareToSpace.showLessSpacesLink"
|
||||
defaultMessage="show less"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
) : (
|
||||
<EuiButtonEmpty size="xs" onClick={() => setIsExpanded(true)}>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.management.shareToSpace.showMoreSpacesLink"
|
||||
defaultMessage="+{count} more"
|
||||
values={{
|
||||
count: authorizedSpaceTargets.length + unauthorizedCount - displayedSpaces.length,
|
||||
}}
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const unauthorizedCountBadge =
|
||||
!isSharedToAllSpaces && (isExpanded || button === null) && unauthorizedCount > 0 ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.management.shareToSpace.columnUnauthorizedLabel"
|
||||
defaultMessage="You don't have permission to view these spaces."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiBadge color="#DDD">+{unauthorizedCount}</EuiBadge>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<EuiFlexGroup wrap responsive={false} gutterSize="xs">
|
||||
{displayedSpaces.map(({ id, name, color }) => (
|
||||
<EuiFlexItem grow={false} key={id}>
|
||||
<EuiBadge color={color || 'hollow'}>{name}</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
{unauthorizedCountBadge}
|
||||
{button}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public';
|
||||
|
||||
export class ShareToSpaceSavedObjectsManagementColumn
|
||||
implements SavedObjectsManagementColumn<SpaceMap> {
|
||||
implements SavedObjectsManagementColumn<void> {
|
||||
public id: string = 'share_saved_objects_to_space';
|
||||
public data: Map<string, SpaceTarget> | undefined;
|
||||
|
||||
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) => (
|
||||
<ColumnDisplay namespaces={namespaces} data={this.data} />
|
||||
),
|
||||
};
|
||||
|
||||
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<SpaceTarget>((space) => ({
|
||||
...space,
|
||||
isActiveSpace: space.id === activeSpace.id,
|
||||
color: getSpaceColor(space),
|
||||
}))
|
||||
.reduce((acc, cur) => acc.set(cur.id, cur), new Map<string, SpaceTarget>());
|
||||
return this.data;
|
||||
render: (namespaces: string[] | undefined) => {
|
||||
if (!namespaces) {
|
||||
return null;
|
||||
}
|
||||
);
|
||||
return <this.spacesApiUi.components.SpaceList namespaces={namespaces} />;
|
||||
},
|
||||
};
|
||||
|
||||
constructor(private readonly spacesApiUi: SpacesApiUi) {}
|
||||
}
|
||||
|
|
|
@ -7,19 +7,16 @@
|
|||
|
||||
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 { coreMock, notificationServiceMock } 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', () => {
|
||||
it('registers the ShareToSpaceSavedObjectsManagement Action and Column', () => {
|
||||
const deps = {
|
||||
spacesManager: spacesManagerMock.create(),
|
||||
notificationsSetup: notificationServiceMock.createSetupContract(),
|
||||
savedObjectsManagementSetup: savedObjectsManagementPluginMock.createSetupContract(),
|
||||
getStartServices: coreMock.createSetup().getStartServices,
|
||||
spacesApiUi: uiApiMock.create(),
|
||||
};
|
||||
|
||||
const service = new ShareSavedObjectsToSpaceService();
|
||||
|
|
|
@ -5,35 +5,22 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { NotificationsSetup, 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;
|
||||
notificationsSetup: NotificationsSetup;
|
||||
getStartServices: StartServicesAccessor<PluginsStart>;
|
||||
spacesApiUi: SpacesApiUi;
|
||||
}
|
||||
|
||||
export class ShareSavedObjectsToSpaceService {
|
||||
public setup({
|
||||
spacesManager,
|
||||
savedObjectsManagementSetup,
|
||||
notificationsSetup,
|
||||
getStartServices,
|
||||
}: SetupDeps) {
|
||||
const action = new ShareToSpaceSavedObjectsManagementAction(
|
||||
spacesManager,
|
||||
notificationsSetup,
|
||||
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);
|
||||
// const column = new ShareToSpaceSavedObjectsManagementColumn(spacesApiUi);
|
||||
// savedObjectsManagementSetup.columns.register(column);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,10 +6,10 @@
|
|||
*/
|
||||
|
||||
import { SavedObjectsImportRetry, SavedObjectsImportResponse } from 'src/core/public';
|
||||
import { GetSpaceResult } from '..';
|
||||
|
||||
export interface ShareOptions {
|
||||
selectedSpaceIds: string[];
|
||||
initiallySelectedSpaceIds: string[];
|
||||
}
|
||||
|
||||
export type ImportRetry = Omit<SavedObjectsImportRetry, 'replaceReferences'>;
|
||||
|
@ -17,8 +17,3 @@ export type ImportRetry = Omit<SavedObjectsImportRetry, 'replaceReferences'>;
|
|||
export interface ShareSavedObjectsToSpaceResponse {
|
||||
[spaceId: string]: SavedObjectsImportResponse;
|
||||
}
|
||||
|
||||
export interface SpaceTarget extends Omit<GetSpaceResult, 'disabledFeatures'> {
|
||||
isActiveSpace: boolean;
|
||||
isPartiallyAuthorized?: boolean;
|
||||
}
|
||||
|
|
|
@ -5,9 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export {
|
||||
SpacesContext,
|
||||
SpacesContextValue,
|
||||
createSpacesContext,
|
||||
useSpacesContext,
|
||||
} from './spaces_context';
|
||||
export { createRedirectLegacyUrl } from './redirect_legacy_url';
|
|
@ -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<string>(`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 });
|
||||
});
|
||||
});
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { 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';
|
||||
import { DEFAULT_OBJECT_NOUN } from '../components/constants';
|
||||
|
||||
export function createRedirectLegacyUrl(
|
||||
getStartServices: StartServicesAccessor<PluginsStart>
|
||||
): SpacesApiUi['redirectLegacyUrl'] {
|
||||
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: `We redirected you to a new URL`,
|
||||
});
|
||||
const text = i18n.translate('xpack.spaces.shareToSpace.redirectLegacyUrlToast.text', {
|
||||
defaultMessage: `The {objectNoun} you're looking for has a new location. Use this URL from now on.`,
|
||||
values: { objectNoun },
|
||||
});
|
||||
notifications.toasts.addInfo({ title, text });
|
||||
await navigateToApp(appId!, { replace: true, path });
|
||||
};
|
||||
}
|
|
@ -5,4 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export { JobSpacesFlyout } from './jobs_spaces_flyout';
|
||||
export { getSpaceListComponent } from './space_list';
|
16
x-pack/plugins/spaces/public/space_list/space_list.tsx
Normal file
16
x-pack/plugins/spaces/public/space_list/space_list.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public';
|
||||
import { SpaceListInternal } from './space_list_internal';
|
||||
|
||||
export const getSpaceListComponent = (): React.FC<SpaceListProps> => {
|
||||
return (props: SpaceListProps) => {
|
||||
return <SpaceListInternal {...props} />;
|
||||
};
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue