Sharing saved objects, phase 2.5 (#89344)

This commit is contained in:
Joe Portner 2021-02-13 04:28:35 -05:00 committed by GitHub
parent 104eacb59a
commit 5c3c3efdd8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
151 changed files with 3982 additions and 2264 deletions

View file

@ -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

View file

@ -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) | |

View file

@ -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';
```

View file

@ -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 &lt; 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)<!-- -->. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) &gt; [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;
```

View file

@ -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 |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) &gt; [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;
```

View file

@ -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';
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) &gt; [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;
```

View file

@ -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' &#124; 'aliasMatch' &#124; '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&lt;T&gt;</code> | |

View file

@ -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>

View file

@ -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. |

View file

@ -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>

View file

@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistry.md) &gt; [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`

View file

@ -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. |

View file

@ -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 {

View file

@ -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'.`
);
});

View file

@ -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}`

View file

@ -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;
};

View file

@ -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;
}
/**

View file

@ -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;

View file

@ -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 });
});

View file

@ -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';
}

View file

@ -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),
});

View file

@ -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`
);

View file

@ -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;
}
/**

View file

@ -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;
/**

View file

@ -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;
}

View file

@ -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"]
}

View file

@ -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}

View file

@ -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({

View file

@ -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

View file

@ -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

View file

@ -12,7 +12,4 @@ import { SavedObjectsManagementRecord } from '.';
export interface SavedObjectsManagementColumn<T> {
id: string;
euiColumn: Omit<EuiTableFieldDataColumnType<SavedObjectsManagementRecord>, 'sortable'>;
data?: T;
loadData: () => Promise<T>;
}

View file

@ -21,5 +21,6 @@
{ "path": "../kibana_react/tsconfig.json" },
{ "path": "../management/tsconfig.json" },
{ "path": "../visualizations/tsconfig.json" },
{ "path": "../spaces_oss/tsconfig.json" },
]
}

View file

@ -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,
};

View file

@ -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;
}

View file

@ -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();

View file

@ -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;
}

View file

@ -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

View file

@ -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,

View file

@ -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(

View file

@ -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)
);
};
};

View file

@ -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(

View file

@ -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}`

View file

@ -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);

View file

@ -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>;

View file

@ -39,7 +39,6 @@
"dashboard",
"savedObjects",
"home",
"spaces",
"maps"
],
"extraPublicDirs": [

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { JobSpacesList, ALL_SPACES_ID } from './job_spaces_list';
export { JobSpacesList } from './job_spaces_list';

View file

@ -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} />}
</>
);
};

View file

@ -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" />
</>
);

View file

@ -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>
</>
);
};

View file

@ -1,3 +0,0 @@
.mlCopyToSpace__spacesList {
margin-top: $euiSizeXS;
}

View file

@ -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>
</>
);
}

View file

@ -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;
}

View file

@ -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
);

View file

@ -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"

View file

@ -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"

View file

@ -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)}

View file

@ -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>

View file

@ -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 () => {

View file

@ -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 });

View file

@ -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>

View file

@ -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);

View file

@ -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] || []}

View file

@ -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}

View file

@ -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[];

View file

@ -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}
/>

View file

@ -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) {

View file

@ -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 = (

View file

@ -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;
}

View file

@ -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';

View file

@ -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(),
};
}
}

View file

@ -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',
});

View file

@ -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>;
};

View file

@ -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';

View file

@ -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 }} />;
};
};

View file

@ -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
});
});

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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;
};
}

View file

@ -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}
/>
</>
);
};

View file

@ -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);
});
});
});

View file

@ -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 &amp; close"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -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 });
});
});
});
});

View file

@ -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 &amp; close"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -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>
);

View file

@ -6,3 +6,5 @@
*/
export { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space_service';
export { getShareToSpaceFlyoutComponent, getLegacyUrlConflict } from './components';
export { createRedirectLegacyUrl } from './utils';

View file

@ -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"', () => {

View file

@ -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}
/>
);
};

View file

@ -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);
});
});
});
});

View file

@ -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) {}
}

View file

@ -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();

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -5,9 +5,4 @@
* 2.0.
*/
export {
SpacesContext,
SpacesContextValue,
createSpacesContext,
useSpacesContext,
} from './spaces_context';
export { createRedirectLegacyUrl } from './redirect_legacy_url';

View file

@ -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 });
});
});

View file

@ -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 });
};
}

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { JobSpacesFlyout } from './jobs_spaces_flyout';
export { getSpaceListComponent } from './space_list';

View 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