Sharing saved objects phase 3.5 (#100424) (#103575)

Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2021-06-28 20:33:16 -04:00 committed by GitHub
parent 1e066f2615
commit 06313f3b94
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
118 changed files with 2792 additions and 640 deletions

View file

@ -98,6 +98,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [OverlayStart](./kibana-plugin-core-public.overlaystart.md) | |
| [Plugin](./kibana-plugin-core-public.plugin.md) | The interface that should be returned by a <code>PluginInitializer</code>. |
| [PluginInitializerContext](./kibana-plugin-core-public.plugininitializercontext.md) | The available core services passed to a <code>PluginInitializer</code> |
| [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) | This interface is a very simple wrapper for SavedObjects resolved from the server with the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md)<!-- -->. |
| [SavedObject](./kibana-plugin-core-public.savedobject.md) | |
| [SavedObjectAttributes](./kibana-plugin-core-public.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the <code>attributes</code> property. |
| [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) | |
@ -126,6 +127,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [SavedObjectsImportUnknownError](./kibana-plugin-core-public.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. |
| [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-core-public.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. |
| [SavedObjectsMigrationVersion](./kibana-plugin-core-public.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. |
| [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) | |
| [SavedObjectsStart](./kibana-plugin-core-public.savedobjectsstart.md) | |
| [SavedObjectsUpdateOptions](./kibana-plugin-core-public.savedobjectsupdateoptions.md) | |
| [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. |

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-public](./kibana-plugin-core-public.md) &gt; [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) &gt; [aliasTargetId](./kibana-plugin-core-public.resolvedsimplesavedobject.aliastargetid.md)
## ResolvedSimpleSavedObject.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?: SavedObjectsResolveResponse['aliasTargetId'];
```

View file

@ -0,0 +1,22 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md)
## ResolvedSimpleSavedObject interface
This interface is a very simple wrapper for SavedObjects resolved from the server with the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md)<!-- -->.
<b>Signature:</b>
```typescript
export interface ResolvedSimpleSavedObject<T = unknown>
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [aliasTargetId](./kibana-plugin-core-public.resolvedsimplesavedobject.aliastargetid.md) | <code>SavedObjectsResolveResponse['aliasTargetId']</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-public.resolvedsimplesavedobject.outcome.md) | <code>SavedObjectsResolveResponse['outcome']</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. |
| [savedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.savedobject.md) | <code>SimpleSavedObject&lt;T&gt;</code> | The saved object that was found. |

View file

@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) &gt; [outcome](./kibana-plugin-core-public.resolvedsimplesavedobject.outcome.md)
## ResolvedSimpleSavedObject.outcome property
The outcome for a successful `resolve` call is one of the following values:
\* `'exactMatch'` -- One document exactly matched the given ID. \* `'aliasMatch'` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than the given ID. \* `'conflict'` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID.
<b>Signature:</b>
```typescript
outcome: SavedObjectsResolveResponse['outcome'];
```

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-public](./kibana-plugin-core-public.md) &gt; [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) &gt; [savedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.savedobject.md)
## ResolvedSimpleSavedObject.savedObject property
The saved object that was found.
<b>Signature:</b>
```typescript
savedObject: SimpleSavedObject<T>;
```

View file

@ -19,7 +19,7 @@ export interface SavedObject<T = unknown>
| [error](./kibana-plugin-core-public.savedobject.error.md) | <code>SavedObjectError</code> | |
| [id](./kibana-plugin-core-public.savedobject.id.md) | <code>string</code> | The ID of this Saved Object, guaranteed to be unique for all objects of the same <code>type</code> |
| [migrationVersion](./kibana-plugin-core-public.savedobject.migrationversion.md) | <code>SavedObjectsMigrationVersion</code> | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. |
| [namespaces](./kibana-plugin-core-public.savedobject.namespaces.md) | <code>string[]</code> | Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. |
| [namespaces](./kibana-plugin-core-public.savedobject.namespaces.md) | <code>string[]</code> | Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with <code>namespaceType: 'agnostic'</code>. |
| [originId](./kibana-plugin-core-public.savedobject.originid.md) | <code>string</code> | The ID of the saved object this originated from. This is set if this object's <code>id</code> was regenerated; that can happen during migration from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given space. |
| [references](./kibana-plugin-core-public.savedobject.references.md) | <code>SavedObjectReference[]</code> | A reference to another saved object. |
| [type](./kibana-plugin-core-public.savedobject.type.md) | <code>string</code> | The type of Saved Object. Each plugin can define it's own custom Saved Object types. |

View file

@ -4,7 +4,7 @@
## SavedObject.namespaces property
Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types.
Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with `namespaceType: 'agnostic'`<!-- -->.
<b>Signature:</b>

View file

@ -26,6 +26,7 @@ The constructor for this class is marked as internal. Third-party code should no
| [delete](./kibana-plugin-core-public.savedobjectsclient.delete.md) | | <code>(type: string, id: string, options?: SavedObjectsDeleteOptions &#124; undefined) =&gt; ReturnType&lt;SavedObjectsApi['delete']&gt;</code> | Deletes an object |
| [find](./kibana-plugin-core-public.savedobjectsclient.find.md) | | <code>&lt;T = unknown, A = unknown&gt;(options: SavedObjectsFindOptions) =&gt; Promise&lt;SavedObjectsFindResponsePublic&lt;T, unknown&gt;&gt;</code> | Search for objects |
| [get](./kibana-plugin-core-public.savedobjectsclient.get.md) | | <code>&lt;T = unknown&gt;(type: string, id: string) =&gt; Promise&lt;SimpleSavedObject&lt;T&gt;&gt;</code> | Fetches a single object |
| [resolve](./kibana-plugin-core-public.savedobjectsclient.resolve.md) | | <code>&lt;T = unknown&gt;(type: string, id: string) =&gt; Promise&lt;ResolvedSimpleSavedObject&lt;T&gt;&gt;</code> | Resolves a single object |
## Methods

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-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) &gt; [resolve](./kibana-plugin-core-public.savedobjectsclient.resolve.md)
## SavedObjectsClient.resolve property
Resolves a single object
<b>Signature:</b>
```typescript
resolve: <T = unknown>(type: string, id: string) => Promise<ResolvedSimpleSavedObject<T>>;
```

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-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) &gt; [aliasTargetId](./kibana-plugin-core-public.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

@ -0,0 +1,21 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md)
## SavedObjectsResolveResponse interface
<b>Signature:</b>
```typescript
export interface SavedObjectsResolveResponse<T = unknown>
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [aliasTargetId](./kibana-plugin-core-public.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-public.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-public.savedobjectsresolveresponse.saved_object.md) | <code>SavedObject&lt;T&gt;</code> | The saved object that was found. |

View file

@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) &gt; [outcome](./kibana-plugin-core-public.savedobjectsresolveresponse.outcome.md)
## SavedObjectsResolveResponse.outcome property
The outcome for a successful `resolve` call is one of the following values:
\* `'exactMatch'` -- One document exactly matched the given ID. \* `'aliasMatch'` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than the given ID. \* `'conflict'` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID.
<b>Signature:</b>
```typescript
outcome: 'exactMatch' | 'aliasMatch' | 'conflict';
```

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-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) &gt; [saved\_object](./kibana-plugin-core-public.savedobjectsresolveresponse.saved_object.md)
## SavedObjectsResolveResponse.saved\_object property
The saved object that was found.
<b>Signature:</b>
```typescript
saved_object: SavedObject<T>;
```

View file

@ -9,7 +9,7 @@ Constructs a new instance of the `SimpleSavedObject` class
<b>Signature:</b>
```typescript
constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, }: SavedObjectType<T>);
constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, }: SavedObjectType<T>);
```
## Parameters
@ -17,5 +17,5 @@ constructor(client: SavedObjectsClientContract, { id, type, version, attributes,
| Parameter | Type | Description |
| --- | --- | --- |
| client | <code>SavedObjectsClientContract</code> | |
| { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, } | <code>SavedObjectType&lt;T&gt;</code> | |
| { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, } | <code>SavedObjectType&lt;T&gt;</code> | |

View file

@ -18,7 +18,7 @@ export declare class SimpleSavedObject<T = unknown>
| Constructor | Modifiers | Description |
| --- | --- | --- |
| [(constructor)(client, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, })](./kibana-plugin-core-public.simplesavedobject._constructor_.md) | | Constructs a new instance of the <code>SimpleSavedObject</code> class |
| [(constructor)(client, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, })](./kibana-plugin-core-public.simplesavedobject._constructor_.md) | | Constructs a new instance of the <code>SimpleSavedObject</code> class |
## Properties
@ -30,6 +30,7 @@ export declare class SimpleSavedObject<T = unknown>
| [error](./kibana-plugin-core-public.simplesavedobject.error.md) | | <code>SavedObjectType&lt;T&gt;['error']</code> | |
| [id](./kibana-plugin-core-public.simplesavedobject.id.md) | | <code>SavedObjectType&lt;T&gt;['id']</code> | |
| [migrationVersion](./kibana-plugin-core-public.simplesavedobject.migrationversion.md) | | <code>SavedObjectType&lt;T&gt;['migrationVersion']</code> | |
| [namespaces](./kibana-plugin-core-public.simplesavedobject.namespaces.md) | | <code>SavedObjectType&lt;T&gt;['namespaces']</code> | Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with <code>namespaceType: 'agnostic'</code>. |
| [references](./kibana-plugin-core-public.simplesavedobject.references.md) | | <code>SavedObjectType&lt;T&gt;['references']</code> | |
| [type](./kibana-plugin-core-public.simplesavedobject.type.md) | | <code>SavedObjectType&lt;T&gt;['type']</code> | |

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-public](./kibana-plugin-core-public.md) &gt; [SimpleSavedObject](./kibana-plugin-core-public.simplesavedobject.md) &gt; [namespaces](./kibana-plugin-core-public.simplesavedobject.namespaces.md)
## SimpleSavedObject.namespaces property
Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with `namespaceType: 'agnostic'`<!-- -->.
<b>Signature:</b>
```typescript
namespaces: SavedObjectType<T>['namespaces'];
```

View file

@ -19,7 +19,7 @@ export interface SavedObject<T = unknown>
| [error](./kibana-plugin-core-server.savedobject.error.md) | <code>SavedObjectError</code> | |
| [id](./kibana-plugin-core-server.savedobject.id.md) | <code>string</code> | The ID of this Saved Object, guaranteed to be unique for all objects of the same <code>type</code> |
| [migrationVersion](./kibana-plugin-core-server.savedobject.migrationversion.md) | <code>SavedObjectsMigrationVersion</code> | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. |
| [namespaces](./kibana-plugin-core-server.savedobject.namespaces.md) | <code>string[]</code> | Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. |
| [namespaces](./kibana-plugin-core-server.savedobject.namespaces.md) | <code>string[]</code> | Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with <code>namespaceType: 'agnostic'</code>. |
| [originId](./kibana-plugin-core-server.savedobject.originid.md) | <code>string</code> | The ID of the saved object this originated from. This is set if this object's <code>id</code> was regenerated; that can happen during migration from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given space. |
| [references](./kibana-plugin-core-server.savedobject.references.md) | <code>SavedObjectReference[]</code> | A reference to another saved object. |
| [type](./kibana-plugin-core-server.savedobject.type.md) | <code>string</code> | The type of Saved Object. Each plugin can define it's own custom Saved Object types. |

View file

@ -4,7 +4,7 @@
## SavedObject.namespaces property
Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types.
Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with `namespaceType: 'agnostic'`<!-- -->.
<b>Signature:</b>

View file

@ -17,5 +17,5 @@ export interface SavedObjectsResolveResponse<T = unknown>
| --- | --- | --- |
| [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> | |
| [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | <code>SavedObject&lt;T&gt;</code> | The saved object that was found. |

View file

@ -4,6 +4,8 @@
## SavedObjectsResolveResponse.saved\_object property
The saved object that was found.
<b>Signature:</b>
```typescript

View file

@ -99,6 +99,7 @@ export type {
} from './application';
export { SimpleSavedObject } from './saved_objects';
export type { ResolvedSimpleSavedObject } from './saved_objects';
export type {
SavedObjectsBatchResponse,
SavedObjectsBulkCreateObject,
@ -107,6 +108,7 @@ export type {
SavedObjectsBulkUpdateOptions,
SavedObjectsCreateOptions,
SavedObjectsFindResponsePublic,
SavedObjectsResolveResponse,
SavedObjectsUpdateOptions,
SavedObject,
SavedObjectAttribute,

View file

@ -1118,6 +1118,13 @@ export type ResolveDeprecationResponse = {
reason: string;
};
// @public
export interface ResolvedSimpleSavedObject<T = unknown> {
aliasTargetId?: SavedObjectsResolveResponse['aliasTargetId'];
outcome: SavedObjectsResolveResponse['outcome'];
savedObject: SimpleSavedObject<T>;
}
// Warning: (ae-missing-release-tag) "SavedObject" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@ -1247,6 +1254,7 @@ export class SavedObjectsClient {
// Warning: (ae-forgotten-export) The symbol "SavedObjectsFindOptions" needs to be exported by the entry point index.d.ts
find: <T = unknown, A = unknown>(options: SavedObjectsFindOptions_2) => Promise<SavedObjectsFindResponsePublic<T, unknown>>;
get: <T = unknown>(type: string, id: string) => Promise<SimpleSavedObject<T>>;
resolve: <T = unknown>(type: string, id: string) => Promise<ResolvedSimpleSavedObject<T>>;
update<T = unknown>(type: string, id: string, attributes: T, { version, references, upsert }?: SavedObjectsUpdateOptions): Promise<SimpleSavedObject<T>>;
}
@ -1467,6 +1475,13 @@ export interface SavedObjectsMigrationVersion {
// @public
export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic';
// @public (undocumented)
export interface SavedObjectsResolveResponse<T = unknown> {
aliasTargetId?: string;
outcome: 'exactMatch' | 'aliasMatch' | 'conflict';
saved_object: SavedObject<T>;
}
// @public (undocumented)
export interface SavedObjectsStart {
// (undocumented)
@ -1504,7 +1519,7 @@ export class ScopedHistory<HistoryLocationState = unknown> implements History<Hi
// @public
export class SimpleSavedObject<T = unknown> {
constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, }: SavedObject<T>);
constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, }: SavedObject<T>);
// (undocumented)
attributes: T;
// (undocumented)
@ -1521,6 +1536,7 @@ export class SimpleSavedObject<T = unknown> {
id: SavedObject<T>['id'];
// (undocumented)
migrationVersion: SavedObject<T>['migrationVersion'];
namespaces: SavedObject<T>['namespaces'];
// (undocumented)
references: SavedObject<T>['references'];
// (undocumented)

View file

@ -17,9 +17,11 @@ export type {
SavedObjectsCreateOptions,
SavedObjectsFindResponsePublic,
SavedObjectsUpdateOptions,
SavedObjectsResolveResponse,
SavedObjectsBulkUpdateOptions,
} from './saved_objects_client';
export { SimpleSavedObject } from './simple_saved_object';
export type { ResolvedSimpleSavedObject } from './types';
export type { SavedObjectsStart } from './saved_objects_service';
export type {
SavedObjectsBaseOptions,

View file

@ -6,6 +6,8 @@
* Side Public License, v 1.
*/
import type { SavedObjectsResolveResponse } from 'src/core/server';
import { SavedObjectsClient } from './saved_objects_client';
import { SimpleSavedObject } from './simple_saved_object';
import { httpServiceMock } from '../http/http_service.mock';
@ -147,6 +149,62 @@ describe('SavedObjectsClient', () => {
});
});
describe('#resolve', () => {
beforeEach(() => {
beforeEach(() => {
http.fetch.mockResolvedValue({
saved_object: doc,
outcome: 'conflict',
aliasTargetId: 'another-id',
} as SavedObjectsResolveResponse);
});
});
test('rejects if `type` is undefined', async () => {
expect(savedObjectsClient.resolve(undefined as any, doc.id)).rejects.toMatchInlineSnapshot(
`[Error: requires type and id]`
);
});
test('rejects if `id` is undefined', async () => {
expect(savedObjectsClient.resolve(doc.type, undefined as any)).rejects.toMatchInlineSnapshot(
`[Error: requires type and id]`
);
});
test('makes HTTP call', () => {
savedObjectsClient.resolve(doc.type, doc.id);
expect(http.fetch.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/api/saved_objects/resolve/config/AVwSwFxtcMV38qjDZoQg",
Object {
"body": undefined,
"method": undefined,
"query": undefined,
},
],
]
`);
});
test('rejects when HTTP call fails', async () => {
http.fetch.mockRejectedValueOnce(new Error('Request failed'));
await expect(savedObjectsClient.resolve(doc.type, doc.id)).rejects.toMatchInlineSnapshot(
`[Error: Request failed]`
);
});
test('resolves with ResolvedSimpleSavedObject instance', async () => {
const result = await savedObjectsClient.resolve(doc.type, doc.id);
expect(result.savedObject).toBeInstanceOf(SimpleSavedObject);
expect(result.savedObject.type).toBe(doc.type);
expect(result.savedObject.get('title')).toBe('Example title');
expect(result.outcome).toBe('conflict');
expect(result.aliasTargetId).toBe('another-id');
});
});
describe('#delete', () => {
beforeEach(() => {
http.fetch.mockResolvedValue({});

View file

@ -16,11 +16,15 @@ import {
SavedObjectsClientContract as SavedObjectsApi,
SavedObjectsFindOptions as SavedObjectFindOptionsServer,
SavedObjectsMigrationVersion,
SavedObjectsResolveResponse,
} from '../../server';
import { SimpleSavedObject } from './simple_saved_object';
import type { ResolvedSimpleSavedObject } from './types';
import { HttpFetchOptions, HttpSetup } from '../http';
export type { SavedObjectsResolveResponse };
type PromiseType<T extends Promise<any>> = T extends Promise<infer U> ? U : never;
type SavedObjectsFindOptions = Omit<
@ -421,6 +425,29 @@ export class SavedObjectsClient {
return request;
}
/**
* Resolves a single object
*
* @param {string} type
* @param {string} id
* @returns The resolve result for the saved object for the given type and id.
*/
public resolve = <T = unknown>(
type: string,
id: string
): Promise<ResolvedSimpleSavedObject<T>> => {
if (!type || !id) {
return Promise.reject(new Error('requires type and id'));
}
const path = `${this.getPath(['resolve'])}/${type}/${id}`;
const request: Promise<SavedObjectsResolveResponse<T>> = this.savedObjectsFetch(path, {});
return request.then(({ saved_object: object, outcome, aliasTargetId }) => {
const savedObject = new SimpleSavedObject<T>(this, object);
return { savedObject, outcome, aliasTargetId };
});
};
/**
* Updates an object
*

View file

@ -18,6 +18,7 @@ const createStartContractMock = () => {
bulkGet: jest.fn(),
find: jest.fn(),
get: jest.fn(),
resolve: jest.fn(),
update: jest.fn(),
},
};

View file

@ -30,6 +30,11 @@ export class SimpleSavedObject<T = unknown> {
public coreMigrationVersion: SavedObjectType<T>['coreMigrationVersion'];
public error: SavedObjectType<T>['error'];
public references: SavedObjectType<T>['references'];
/**
* Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with
* `namespaceType: 'agnostic'`.
*/
public namespaces: SavedObjectType<T>['namespaces'];
constructor(
private client: SavedObjectsClientContract,
@ -42,6 +47,7 @@ export class SimpleSavedObject<T = unknown> {
references,
migrationVersion,
coreMigrationVersion,
namespaces,
}: SavedObjectType<T>
) {
this.id = id;
@ -51,6 +57,7 @@ export class SimpleSavedObject<T = unknown> {
this._version = version;
this.migrationVersion = migrationVersion;
this.coreMigrationVersion = coreMigrationVersion;
this.namespaces = namespaces;
if (error) {
this.error = error;
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { SavedObjectsResolveResponse } from '../../server';
import { SimpleSavedObject } from './simple_saved_object';
/**
* This interface is a very simple wrapper for SavedObjects resolved from the server
* with the {@link SavedObjectsClient}.
*
* @public
*/
export interface ResolvedSimpleSavedObject<T = unknown> {
/**
* The saved object that was found.
*/
savedObject: SimpleSavedObject<T>;
/**
* The outcome for a successful `resolve` call is one of the following values:
*
* * `'exactMatch'` -- One document exactly matched the given ID.
* * `'aliasMatch'` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different
* than the given ID.
* * `'conflict'` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the
* `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID.
*/
outcome: SavedObjectsResolveResponse['outcome'];
/**
* The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`.
*/
aliasTargetId?: SavedObjectsResolveResponse['aliasTargetId'];
}

View file

@ -139,6 +139,12 @@ const createStartContractMock = () => {
storeSizeBytes: 1,
},
],
legacyUrlAliases: {
inactiveCount: 1,
activeCount: 1,
disabledCount: 1,
totalCount: 3,
},
},
},
})

View file

@ -183,6 +183,19 @@ describe('CoreUsageDataService', () => {
},
],
} as any);
elasticsearch.client.asInternalUser.search.mockResolvedValueOnce({
body: {
hits: { total: { value: 6 } },
aggregations: {
aliases: {
buckets: {
active: { doc_count: 1 },
disabled: { doc_count: 2 },
},
},
},
},
} as any);
const typeRegistry = savedObjectsServiceMock.createTypeRegistryMock();
typeRegistry.getAllTypes.mockReturnValue([
{ name: 'type 1', indexPattern: '.kibana' },
@ -329,6 +342,12 @@ describe('CoreUsageDataService', () => {
"storeSizeBytes": 2000,
},
],
"legacyUrlAliases": Object {
"activeCount": 1,
"disabledCount": 2,
"inactiveCount": 3,
"totalCount": 6,
},
},
},
}

View file

@ -13,6 +13,11 @@ import { hasConfigPathIntersection, ChangedDeprecatedPaths } from '@kbn/config';
import { CoreService } from 'src/core/types';
import { Logger, SavedObjectsServiceStart, SavedObjectTypeRegistry } from 'src/core/server';
import {
AggregationsFiltersAggregate,
AggregationsFiltersBucketItem,
SearchTotalHits,
} from '@elastic/elasticsearch/api/types';
import { CoreContext } from '../core_context';
import { ElasticsearchConfigType } from '../elasticsearch/elasticsearch_config';
import { HttpConfigType, InternalHttpServiceSetup } from '../http';
@ -29,6 +34,7 @@ import { isConfigured } from './is_configured';
import { ElasticsearchServiceStart } from '../elasticsearch';
import { KibanaConfigType } from '../kibana_config';
import { coreUsageStatsType } from './core_usage_stats';
import { LEGACY_URL_ALIAS_TYPE } from '../saved_objects/object_types';
import { CORE_USAGE_STATS_TYPE } from './constants';
import { CoreUsageStatsClient } from './core_usage_stats_client';
import { MetricsServiceSetup, OpsMetrics } from '..';
@ -98,11 +104,25 @@ export class CoreUsageDataService implements CoreService<CoreUsageDataSetup, Cor
this.stop$ = new Subject();
}
private async getSavedObjectIndicesUsageData(
private async getSavedObjectUsageData(
savedObjects: SavedObjectsServiceStart,
elasticsearch: ElasticsearchServiceStart
): Promise<CoreServicesUsageData['savedObjects']> {
const indices = await Promise.all(
const [indices, legacyUrlAliases] = await Promise.all([
this.getSavedObjectIndicesUsageData(savedObjects, elasticsearch),
this.getSavedObjectAliasUsageData(elasticsearch),
]);
return {
indices,
legacyUrlAliases,
};
}
private async getSavedObjectIndicesUsageData(
savedObjects: SavedObjectsServiceStart,
elasticsearch: ElasticsearchServiceStart
) {
return Promise.all(
Array.from(
savedObjects
.getTypeRegistry()
@ -136,10 +156,44 @@ export class CoreUsageDataService implements CoreService<CoreUsageDataSetup, Cor
});
})
);
}
return {
indices,
};
private async getSavedObjectAliasUsageData(elasticsearch: ElasticsearchServiceStart) {
// Note: this agg can be changed to use `savedObjectsRepository.find` in the future after `filters` is supported.
// See src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts for supported aggregations.
const { body: resp } = await elasticsearch.client.asInternalUser.search({
index: this.kibanaConfig!.index,
body: {
track_total_hits: true,
query: { match: { type: LEGACY_URL_ALIAS_TYPE } },
aggs: {
aliases: {
filters: {
filters: {
disabled: { term: { [`${LEGACY_URL_ALIAS_TYPE}.disabled`]: true } },
active: {
bool: {
must_not: { term: { [`${LEGACY_URL_ALIAS_TYPE}.disabled`]: true } },
must: { range: { [`${LEGACY_URL_ALIAS_TYPE}.resolveCounter`]: { gte: 1 } } },
},
},
},
},
},
},
size: 0,
},
});
const { hits, aggregations } = resp;
const totalCount = (hits.total as SearchTotalHits).value;
const aggregate = aggregations!.aliases as AggregationsFiltersAggregate;
const buckets = aggregate.buckets as Record<string, AggregationsFiltersBucketItem>;
const disabledCount = buckets.disabled.doc_count as number;
const activeCount = buckets.active.doc_count as number;
const inactiveCount = totalCount - disabledCount - activeCount;
return { totalCount, disabledCount, activeCount, inactiveCount };
}
private async getCoreUsageData(
@ -162,7 +216,7 @@ export class CoreUsageDataService implements CoreService<CoreUsageDataSetup, Cor
}
const es = this.elasticsearchConfig;
const soUsageData = await this.getSavedObjectIndicesUsageData(savedObjects, elasticsearch);
const soUsageData = await this.getSavedObjectUsageData(savedObjects, elasticsearch);
const coreUsageStatsData = await this.coreUsageStatsClient.getUsageStats();
const http = this.httpConfig;

View file

@ -45,6 +45,13 @@ export const UPDATE_STATS_PREFIX = 'apiCalls.savedObjectsUpdate';
export const IMPORT_STATS_PREFIX = 'apiCalls.savedObjectsImport';
export const RESOLVE_IMPORT_STATS_PREFIX = 'apiCalls.savedObjectsResolveImportErrors';
export const EXPORT_STATS_PREFIX = 'apiCalls.savedObjectsExport';
export const REPOSITORY_RESOLVE_OUTCOME_STATS = {
EXACT_MATCH: 'savedObjectsRepository.resolvedOutcome.exactMatch',
ALIAS_MATCH: 'savedObjectsRepository.resolvedOutcome.aliasMatch',
CONFLICT: 'savedObjectsRepository.resolvedOutcome.conflict',
NOT_FOUND: 'savedObjectsRepository.resolvedOutcome.notFound',
TOTAL: 'savedObjectsRepository.resolvedOutcome.total',
};
const ALL_COUNTER_FIELDS = [
// Saved Objects Client APIs
...getFieldsForCounter(BULK_CREATE_STATS_PREFIX),
@ -68,6 +75,12 @@ const ALL_COUNTER_FIELDS = [
...getFieldsForCounter(EXPORT_STATS_PREFIX),
`${EXPORT_STATS_PREFIX}.allTypesSelected.yes`,
`${EXPORT_STATS_PREFIX}.allTypesSelected.no`,
// Saved Objects Repository counters; these are included here for stats collection, but are incremented in the repository itself
REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH,
REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH,
REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT,
REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND,
REPOSITORY_RESOLVE_OUTCOME_STATS.TOTAL,
];
const SPACE_CONTEXT_REGEX = /^\/s\/([a-z0-9_\-]+)/;

View file

@ -6,9 +6,10 @@
* Side Public License, v 1.
*/
export { CORE_USAGE_STATS_TYPE, CORE_USAGE_STATS_ID } from './constants';
export type { CoreUsageDataSetup, ConfigUsageData, CoreUsageDataStart } from './types';
export { CoreUsageDataService } from './core_usage_data_service';
export { CoreUsageStatsClient } from './core_usage_stats_client';
export { CoreUsageStatsClient, REPOSITORY_RESOLVE_OUTCOME_STATS } from './core_usage_stats_client';
// Because of #79265 we need to explicity import, then export these types for
// scripts/telemetry_check.js to work as expected

View file

@ -110,6 +110,12 @@ export interface CoreUsageStats {
'apiCalls.savedObjectsExport.namespace.custom.kibanaRequest.no'?: number;
'apiCalls.savedObjectsExport.allTypesSelected.yes'?: number;
'apiCalls.savedObjectsExport.allTypesSelected.no'?: number;
// Saved Objects Repository counters
'savedObjectsRepository.resolvedOutcome.exactMatch'?: number;
'savedObjectsRepository.resolvedOutcome.aliasMatch'?: number;
'savedObjectsRepository.resolvedOutcome.conflict'?: number;
'savedObjectsRepository.resolvedOutcome.notFound'?: number;
'savedObjectsRepository.resolvedOutcome.total'?: number;
}
/**
@ -150,6 +156,12 @@ export interface CoreServicesUsageData {
storeSizeBytes: number;
primaryStoreSizeBytes: number;
}[];
legacyUrlAliases: {
activeCount: number;
inactiveCount: number;
disabledCount: number;
totalCount: number;
};
};
}

View file

@ -17,6 +17,7 @@ const legacyUrlAliasType: SavedObjectsType = {
properties: {
sourceId: { type: 'keyword' },
targetType: { type: 'keyword' },
resolveCounter: { type: 'long' },
disabled: { type: 'boolean' },
// other properties exist, but we aren't querying or aggregating on those, so we don't need to specify them (because we use `dynamic: false` above)
},

View file

@ -13,6 +13,7 @@ import {
mockUpdateObjectsSpaces,
} from './repository.test.mock';
import { CORE_USAGE_STATS_TYPE, REPOSITORY_RESOLVE_OUTCOME_STATS } from '../../../core_usage_data';
import { SavedObjectsRepository } from './repository';
import * as getSearchDslNS from './search_dsl/search_dsl';
import { SavedObjectsErrorHelpers } from './errors';
@ -3272,6 +3273,24 @@ describe('SavedObjectsRepository', () => {
},
});
/** Each time resolve is called, usage stats are incremented depending upon the outcome. */
const expectIncrementCounter = (n, outcomeStatString) => {
expect(client.update).toHaveBeenNthCalledWith(
n,
expect.objectContaining({
body: expect.objectContaining({
upsert: expect.objectContaining({
[CORE_USAGE_STATS_TYPE]: {
[outcomeStatString]: 1,
[REPOSITORY_RESOLVE_OUTCOME_STATS.TOTAL]: 1,
},
}),
}),
}),
expect.anything()
);
};
describe('outcomes', () => {
describe('error', () => {
const expectNotFoundError = async (type, id, options) => {
@ -3302,9 +3321,10 @@ describe('SavedObjectsRepository', () => {
);
await expectNotFoundError(type, id, options);
expect(client.update).not.toHaveBeenCalled();
expect(client.update).toHaveBeenCalledTimes(1); // incremented stats
expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target
expect(client.mget).not.toHaveBeenCalled();
expectIncrementCounter(1, REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND);
});
it('because actual object and alias object are both not found', async () => {
@ -3320,9 +3340,10 @@ describe('SavedObjectsRepository', () => {
);
await expectNotFoundError(type, id, options);
expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object
expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target
expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND);
});
});
@ -3335,9 +3356,10 @@ describe('SavedObjectsRepository', () => {
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).not.toHaveBeenCalled();
expect(client.update).toHaveBeenCalledTimes(1); // incremented stats
expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target
expect(client.mget).not.toHaveBeenCalled();
expectIncrementCounter(1, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH);
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id }),
outcome: 'exactMatch',
@ -3354,9 +3376,10 @@ describe('SavedObjectsRepository', () => {
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object
expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats
expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target
expect(client.mget).not.toHaveBeenCalled();
expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH);
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id }),
outcome: 'exactMatch',
@ -3388,9 +3411,10 @@ describe('SavedObjectsRepository', () => {
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object
expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target
expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH);
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id }),
outcome: 'exactMatch',
@ -3429,9 +3453,10 @@ describe('SavedObjectsRepository', () => {
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object
expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target
expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH);
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id: aliasTargetId }),
outcome: 'aliasMatch',
@ -3470,9 +3495,10 @@ describe('SavedObjectsRepository', () => {
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object
expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target
expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT);
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id }),
outcome: 'conflict',

View file

@ -8,6 +8,11 @@
import { omit, isObject } from 'lodash';
import type { estypes } from '@elastic/elasticsearch';
import {
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
REPOSITORY_RESOLVE_OUTCOME_STATS,
} from '../../../core_usage_data';
import type { ElasticsearchClient } from '../../../elasticsearch/';
import type { Logger } from '../../../logging';
import { getRootPropertiesObjects, IndexMapping } from '../../mappings';
@ -1057,7 +1062,7 @@ export class SavedObjectsRepository {
const time = this._getCurrentTime();
// retrieve the alias, and if it is not disabled, update it
const aliasResponse = await this.client.update<{ 'legacy-url-alias': LegacyUrlAlias }>(
const aliasResponse = await this.client.update<{ [LEGACY_URL_ALIAS_TYPE]: LegacyUrlAlias }>(
{
id: rawAliasId,
index: this.getIndexForType(LEGACY_URL_ALIAS_TYPE),
@ -1128,21 +1133,25 @@ export class SavedObjectsRepository {
// @ts-expect-error MultiGetHit._source is optional
aliasMatchDoc.found && this.rawDocExistsInNamespace(aliasMatchDoc, namespace);
let result: SavedObjectsResolveResponse<T> | null = null;
let outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND;
if (foundExactMatch && foundAliasMatch) {
return {
result = {
// @ts-expect-error MultiGetHit._source is optional
saved_object: getSavedObjectFromSource(this._registry, type, id, exactMatchDoc),
outcome: 'conflict',
aliasTargetId: legacyUrlAlias.targetId,
};
outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT;
} else if (foundExactMatch) {
return {
result = {
// @ts-expect-error MultiGetHit._source is optional
saved_object: getSavedObjectFromSource(this._registry, type, id, exactMatchDoc),
outcome: 'exactMatch',
};
outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH;
} else if (foundAliasMatch) {
return {
result = {
saved_object: getSavedObjectFromSource(
this._registry,
type,
@ -1153,6 +1162,13 @@ export class SavedObjectsRepository {
outcome: 'aliasMatch',
aliasTargetId: legacyUrlAlias.targetId,
};
outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH;
}
await this.incrementResolveOutcomeStats(outcomeStatString);
if (result !== null) {
return result;
}
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
}
@ -1649,8 +1665,8 @@ export class SavedObjectsRepository {
type: string,
id: string,
counterFields: Array<string | SavedObjectsIncrementCounterField>,
options: SavedObjectsIncrementCounterOptions<T> = {}
): Promise<SavedObject<T>> {
options?: SavedObjectsIncrementCounterOptions<T>
) {
if (typeof type !== 'string') {
throw new Error('"type" argument must be a string');
}
@ -1671,6 +1687,16 @@ export class SavedObjectsRepository {
throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type);
}
return this.incrementCounterInternal<T>(type, id, counterFields, options);
}
/** @internal incrementCounter function that is used interally and bypasses validation checks. */
private async incrementCounterInternal<T = unknown>(
type: string,
id: string,
counterFields: Array<string | SavedObjectsIncrementCounterField>,
options: SavedObjectsIncrementCounterOptions<T> = {}
): Promise<SavedObject<T>> {
const {
migrationVersion,
refresh = DEFAULT_REFRESH_SETTING,
@ -2064,8 +2090,25 @@ export class SavedObjectsRepository {
id: string,
options: SavedObjectsBaseOptions
): Promise<SavedObjectsResolveResponse<T>> {
const object = await this.get<T>(type, id, options);
return { saved_object: object, outcome: 'exactMatch' };
try {
const object = await this.get<T>(type, id, options);
await this.incrementResolveOutcomeStats(REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH);
return { saved_object: object, outcome: 'exactMatch' };
} catch (err) {
if (SavedObjectsErrorHelpers.isNotFoundError(err)) {
await this.incrementResolveOutcomeStats(REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND);
}
throw err;
}
}
private async incrementResolveOutcomeStats(outcomeStatString: string) {
await this.incrementCounterInternal(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[outcomeStatString, REPOSITORY_RESOLVE_OUTCOME_STATS.TOTAL],
{ refresh: false }
).catch(() => {}); // if the call fails for some reason, intentionally swallow the error
}
private validateInitialNamespaces(type: string, initialNamespaces: string[] | undefined) {

View file

@ -311,6 +311,9 @@ export interface SavedObjectsUpdateResponse<T = unknown>
* @public
*/
export interface SavedObjectsResolveResponse<T = unknown> {
/**
* The saved object that was found.
*/
saved_object: SavedObject<T>;
/**
* The outcome for a successful `resolve` call is one of the following values:

View file

@ -503,6 +503,12 @@ export interface CoreServicesUsageData {
storeSizeBytes: number;
primaryStoreSizeBytes: number;
}[];
legacyUrlAliases: {
activeCount: number;
inactiveCount: number;
disabledCount: number;
totalCount: number;
};
};
}
@ -765,6 +771,16 @@ export interface CoreUsageStats {
'apiCalls.savedObjectsUpdate.namespace.default.total'?: number;
// (undocumented)
'apiCalls.savedObjectsUpdate.total'?: number;
// (undocumented)
'savedObjectsRepository.resolvedOutcome.aliasMatch'?: number;
// (undocumented)
'savedObjectsRepository.resolvedOutcome.conflict'?: number;
// (undocumented)
'savedObjectsRepository.resolvedOutcome.exactMatch'?: number;
// (undocumented)
'savedObjectsRepository.resolvedOutcome.notFound'?: number;
// (undocumented)
'savedObjectsRepository.resolvedOutcome.total'?: number;
}
// @public (undocumented)
@ -2925,7 +2941,6 @@ export interface SavedObjectsResolveImportErrorsOptions {
export interface SavedObjectsResolveResponse<T = unknown> {
aliasTargetId?: string;
outcome: 'exactMatch' | 'aliasMatch' | 'conflict';
// (undocumented)
saved_object: SavedObject<T>;
}

View file

@ -84,7 +84,10 @@ export interface SavedObject<T = unknown> {
migrationVersion?: SavedObjectsMigrationVersion;
/** A semver value that is used when upgrading objects between Kibana versions. */
coreMigrationVersion?: string;
/** Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. */
/**
* Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with
* `namespaceType: 'agnostic'`.
*/
namespaces?: string[];
/**
* The ID of the saved object this originated from. This is set if this object's `id` was regenerated; that can happen during migration

View file

@ -377,6 +377,34 @@ export function getCoreUsageCollector(
},
},
},
legacyUrlAliases: {
inactiveCount: {
type: 'long',
_meta: {
description:
'Count of legacy URL aliases that are inactive; they are not disabled, but they have not been resolved.',
},
},
activeCount: {
type: 'long',
_meta: {
description:
'Count of legacy URL aliases that are active; they are not disabled, and they have been resolved at least once.',
},
},
disabledCount: {
type: 'long',
_meta: {
description: 'Count of legacy URL aliases that are disabled.',
},
},
totalCount: {
type: 'long',
_meta: {
description: 'Total count of legacy URL aliases.',
},
},
},
},
},
// Saved Objects Client APIs
@ -914,6 +942,38 @@ export function getCoreUsageCollector(
description: 'How many times this API has been called without all types selected.',
},
},
// Saved Objects Repository counters
'savedObjectsRepository.resolvedOutcome.exactMatch': {
type: 'long',
_meta: {
description: 'How many times a saved object has resolved with an exact match outcome.',
},
},
'savedObjectsRepository.resolvedOutcome.aliasMatch': {
type: 'long',
_meta: {
description: 'How many times a saved object has resolved with an alias match outcome.',
},
},
'savedObjectsRepository.resolvedOutcome.conflict': {
type: 'long',
_meta: {
description: 'How many times a saved object has resolved with a conflict outcome.',
},
},
'savedObjectsRepository.resolvedOutcome.notFound': {
type: 'long',
_meta: {
description: 'How many times a saved object has resolved with a not found outcome.',
},
},
'savedObjectsRepository.resolvedOutcome.total': {
type: 'long',
_meta: {
description:
'How many times a saved object has resolved with any of the four possible outcomes.',
},
},
},
fetch() {
return getCoreUsageDataService().getCoreUsageData();

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { HttpStart } from 'src/core/public';
import { SavedObjectWithMetadata } from '../types';
export async function bulkGetObjects(
http: HttpStart,
objects: Array<{ type: string; id: string }>
): Promise<SavedObjectWithMetadata[]> {
return await http.post<SavedObjectWithMetadata[]>(
`/api/kibana/management/saved_objects/_bulk_get`,
{ body: JSON.stringify(objects) }
);
}

View file

@ -35,13 +35,3 @@ export async function findObjects(
return keysToCamelCaseShallow(response) as SavedObjectsFindResponse;
}
export async function findObject(
http: HttpStart,
type: string,
id: string
): Promise<SavedObjectWithMetadata> {
return await http.get<SavedObjectWithMetadata>(
`/api/kibana/management/saved_objects/${encodeURIComponent(type)}/${encodeURIComponent(id)}`
);
}

View file

@ -30,7 +30,8 @@ export {
FailedImport,
} from './process_import_response';
export { getDefaultTitle } from './get_default_title';
export { findObjects, findObject } from './find_objects';
export { findObjects } from './find_objects';
export { bulkGetObjects } from './bulk_get_objects';
export { extractExportDetails, SavedObjectsExportResultDetails } from './extract_export_details';
export { createFieldList } from './create_field_list';
export { getAllowedTypes } from './get_allowed_types';

View file

@ -19,7 +19,7 @@ import {
} from '../../../../../core/public';
import { ISavedObjectsManagementServiceRegistry } from '../../services';
import { Header, NotFoundErrors, Intro, Form } from './components';
import { canViewInApp, findObject } from '../../lib';
import { canViewInApp, bulkGetObjects } from '../../lib';
import { SubmittedFormData } from '../types';
import { SavedObjectWithMetadata } from '../../types';
@ -41,6 +41,11 @@ interface SavedObjectEditionState {
object?: SavedObjectWithMetadata<any>;
}
const unableFindSavedObjectNotificationMessage = i18n.translate(
'savedObjectsManagement.objectView.unableFindSavedObjectNotificationMessage',
{ defaultMessage: 'Unable to find saved object' }
);
export class SavedObjectEdition extends Component<
SavedObjectEditionProps,
SavedObjectEditionState
@ -58,13 +63,26 @@ export class SavedObjectEdition extends Component<
}
componentDidMount() {
const { http, id } = this.props;
const { http, id, notifications } = this.props;
const { type } = this.state;
findObject(http, type, id).then((object) => {
this.setState({
object,
bulkGetObjects(http, [{ type, id }])
.then(([object]) => {
if (object.error) {
const { message } = object.error;
notifications.toasts.addDanger({
title: unableFindSavedObjectNotificationMessage,
text: message,
});
} else {
this.setState({ object });
}
})
.catch((err) => {
notifications.toasts.addDanger({
title: unableFindSavedObjectNotificationMessage,
text: err.message ?? 'Unknown error',
});
});
});
}
render() {

View file

@ -7,15 +7,21 @@
*/
import React from 'react';
import { findTestSubject } from '@elastic/eui/lib/test';
import { mountWithIntl } from '@kbn/test/jest';
import { SavedObjectWithMetadata } from '../../../../common';
import { DeleteConfirmModal } from './delete_confirm_modal';
const createObject = (): SavedObjectWithMetadata => ({
interface CreateObjectOptions {
namespaces?: string[];
}
const createObject = ({ namespaces }: CreateObjectOptions = {}): SavedObjectWithMetadata => ({
id: 'foo',
type: 'bar',
attributes: {},
references: [],
namespaces,
meta: {},
});
@ -83,4 +89,45 @@ describe('DeleteConfirmModal', () => {
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onCancel).not.toHaveBeenCalled();
});
describe('shared objects warning', () => {
it('does not display a callout when no objects are shared', () => {
const objs = [
createObject(), // if for some reason an object has no namespaces array, it does not count as shared
createObject({ namespaces: [] }), // if for some reason an object has an empty namespaces array, it does not count as shared
createObject({ namespaces: ['one-space'] }), // an object in a single space does not count as shared
];
const wrapper = mountWithIntl(
<DeleteConfirmModal
isDeleting={false}
onConfirm={onConfirm}
onCancel={onCancel}
selectedObjects={objs}
/>
);
const callout = findTestSubject(wrapper, 'sharedObjectsWarning');
expect(callout).toHaveLength(0);
});
it('displays a callout when one or more objects are shared', () => {
const objs = [
createObject({ namespaces: ['one-space'] }), // an object in a single space does not count as shared
createObject({ namespaces: ['one-space', 'another-space'] }), // an object in two spaces counts as shared
createObject({ namespaces: ['*'] }), // an object in all spaces counts as shared
];
const wrapper = mountWithIntl(
<DeleteConfirmModal
isDeleting={false}
onConfirm={onConfirm}
onCancel={onCancel}
selectedObjects={objs}
/>
);
const callout = findTestSubject(wrapper, 'sharedObjectsWarning');
expect(callout).toHaveLength(1);
expect(callout.text()).toMatchInlineSnapshot(
`"2 of your saved objects are sharedShared objects are deleted from every space they are in."`
);
});
});
});

View file

@ -47,8 +47,17 @@ export const DeleteConfirmModal: FC<DeleteConfirmModalProps> = ({
return selectedObjects.filter((obj) => obj.meta.hiddenType);
}, [selectedObjects]);
const deletableObjects = useMemo(() => {
return selectedObjects.filter((obj) => !obj.meta.hiddenType);
return selectedObjects
.filter((obj) => !obj.meta.hiddenType)
.map(({ type, id, meta, namespaces = [] }) => {
const { title = '', icon = 'apps' } = meta;
const isShared = namespaces.length > 1 || namespaces.includes('*');
return { type, id, icon, title, isShared };
});
}, [selectedObjects]);
const sharedObjectsCount = useMemo(() => {
return deletableObjects.filter((obj) => obj.isShared).length;
}, [deletableObjects]);
if (isDeleting) {
return (
@ -93,6 +102,30 @@ export const DeleteConfirmModal: FC<DeleteConfirmModalProps> = ({
<EuiSpacer size="s" />
</>
)}
{sharedObjectsCount > 0 && (
<>
<EuiCallOut
data-test-subj="sharedObjectsWarning"
title={
<FormattedMessage
id="savedObjectsManagement.objectsTable.deleteConfirmModal.sharedObjectsCallout.title"
defaultMessage="{sharedObjectsCount, plural, one {# saved object is shared} other {# of your saved objects are shared}}"
values={{ sharedObjectsCount }}
/>
}
iconType="alert"
color="warning"
>
<p>
<FormattedMessage
id="savedObjectsManagement.objectsTable.deleteConfirmModal.sharedObjectsCallout.content"
defaultMessage="Shared objects are deleted from every space they are in."
/>
</p>
</EuiCallOut>
<EuiSpacer size="s" />
</>
)}
<p>
<FormattedMessage
id="savedObjectsManagement.deleteSavedObjectsConfirmModalDescription"
@ -110,9 +143,9 @@ export const DeleteConfirmModal: FC<DeleteConfirmModalProps> = ({
{ defaultMessage: 'Type' }
),
width: '50px',
render: (type, object) => (
render: (type, { icon }) => (
<EuiToolTip position="top" content={getSavedObjectLabel(type)}>
<EuiIcon type={object.meta.icon || 'apps'} />
<EuiIcon type={icon} />
</EuiToolTip>
),
},
@ -124,7 +157,7 @@ export const DeleteConfirmModal: FC<DeleteConfirmModalProps> = ({
),
},
{
field: 'meta.title',
field: 'title',
name: i18n.translate(
'savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName',
{ defaultMessage: 'Title' }
@ -157,7 +190,8 @@ export const DeleteConfirmModal: FC<DeleteConfirmModalProps> = ({
>
<FormattedMessage
id="savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.deleteButtonLabel"
defaultMessage="Delete"
defaultMessage="Delete {objectsCount, plural, one {# object} other {# objects}}"
values={{ objectsCount: deletableObjects.length }}
/>
</EuiButton>
</EuiFlexItem>

View file

@ -48,7 +48,7 @@ export interface TableProps {
filterOptions: any[];
capabilities: ApplicationStart['capabilities'];
onDelete: () => void;
onActionRefresh: (object: SavedObjectWithMetadata) => void;
onActionRefresh: (objects: Array<{ type: string; id: string }>) => void;
onExport: (includeReferencesDeep: boolean) => void;
goInspectObject: (obj: SavedObjectWithMetadata) => void;
pageIndex: number;
@ -277,10 +277,9 @@ export class Table extends PureComponent<TableProps, TableState> {
this.setState({
activeAction: undefined,
});
const { refreshOnFinish = () => false } = action;
if (refreshOnFinish()) {
onActionRefresh(object);
}
const { refreshOnFinish = () => [] } = action;
const objectsToRefresh = refreshOnFinish();
onActionRefresh(objectsToRefresh);
});
if (action.euiAction.onClick) {

View file

@ -30,7 +30,7 @@ import {
fetchExportObjects,
fetchExportByTypeAndSearch,
findObjects,
findObject,
bulkGetObjects,
extractExportDetails,
SavedObjectsExportResultDetails,
getTagFindReferences,
@ -96,6 +96,14 @@ export interface SavedObjectsTableState {
isIncludeReferencesDeepChecked: boolean;
}
const unableFindSavedObjectsNotificationMessage = i18n.translate(
'savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage',
{ defaultMessage: 'Unable find saved objects' }
);
const unableFindSavedObjectNotificationMessage = i18n.translate(
'savedObjectsManagement.objectsTable.unableFindSavedObjectNotificationMessage',
{ defaultMessage: 'Unable to find saved object' }
);
export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedObjectsTableState> {
private _isMounted = false;
@ -129,13 +137,14 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
componentDidMount() {
this._isMounted = true;
this.fetchSavedObjects();
this.fetchAllSavedObjects();
this.fetchCounts();
}
componentWillUnmount() {
this._isMounted = false;
this.debouncedFetchObjects.cancel();
this.debouncedFindObjects.cancel();
this.debouncedBulkGetObjects.cancel();
}
fetchCounts = async () => {
@ -188,15 +197,15 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
}));
};
fetchSavedObjects = () => {
this.setState({ isSearching: true }, this.debouncedFetchObjects);
fetchAllSavedObjects = () => {
this.setState({ isSearching: true }, this.debouncedFindObjects);
};
fetchSavedObject = (type: string, id: string) => {
this.setState({ isSearching: true }, () => this.debouncedFetchObject(type, id));
fetchSavedObjects = (objects: Array<{ type: string; id: string }>) => {
this.setState({ isSearching: true }, () => this.debouncedBulkGetObjects(objects));
};
debouncedFetchObjects = debounce(async () => {
debouncedFindObjects = debounce(async () => {
const { activeQuery: query, page, perPage } = this.state;
const { notifications, http, allowedTypes, taggingApi } = this.props;
const { queryText, visibleTypes, selectedTags } = parseQuery(query);
@ -240,27 +249,45 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
});
}
notifications.toasts.addDanger({
title: i18n.translate(
'savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage',
{ defaultMessage: 'Unable find saved objects' }
),
title: unableFindSavedObjectsNotificationMessage,
text: `${error}`,
});
}
}, 300);
debouncedFetchObject = debounce(async (type: string, id: string) => {
debouncedBulkGetObjects = debounce(async (objects: Array<{ type: string; id: string }>) => {
const { notifications, http } = this.props;
try {
const resp = await findObject(http, type, id);
const resp = await bulkGetObjects(http, objects);
if (!this._isMounted) {
return;
}
const { map: fetchedObjectsMap, errors: objectErrors } = resp.reduce(
({ map, errors }, obj) => {
if (obj.error) {
errors.push(obj.error.message);
} else {
map.set(getObjectKey(obj), obj);
}
return { map, errors };
},
{ map: new Map<string, SavedObjectWithMetadata>(), errors: [] as string[] }
);
if (objectErrors.length) {
notifications.toasts.addDanger({
title: unableFindSavedObjectNotificationMessage,
text: objectErrors.join(', '),
});
}
this.setState(({ savedObjects, filteredItemCount }) => {
const refreshedSavedObjects = savedObjects.map((object) =>
object.type === type && object.id === id ? resp : object
);
// modify the existing objects array, replacing any existing objects with the newly fetched ones
const refreshedSavedObjects = savedObjects.map((obj) => {
const fetchedObject = fetchedObjectsMap.get(getObjectKey(obj));
return fetchedObject ?? obj;
});
return {
savedObjects: refreshedSavedObjects,
filteredItemCount,
@ -274,21 +301,25 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
});
}
notifications.toasts.addDanger({
title: i18n.translate(
'savedObjectsManagement.objectsTable.unableFindSavedObjectNotificationMessage',
{ defaultMessage: 'Unable to find saved object' }
),
title: unableFindSavedObjectsNotificationMessage,
text: `${error}`,
});
}
}, 300);
refreshObjects = async () => {
await Promise.all([this.fetchSavedObjects(), this.fetchCounts()]);
refreshAllObjects = async () => {
await Promise.all([this.fetchAllSavedObjects(), this.fetchCounts()]);
};
refreshObject = async ({ type, id }: SavedObjectWithMetadata) => {
await this.fetchSavedObject(type, id);
refreshObjects = async (objects: Array<{ type: string; id: string }>) => {
const currentObjectsSet = this.state.savedObjects.reduce(
(acc, obj) => acc.add(getObjectKey(obj)),
new Set<string>()
);
const objectsToFetch = objects.filter((obj) => currentObjectsSet.has(getObjectKey(obj)));
if (objectsToFetch.length) {
this.fetchSavedObjects(objectsToFetch);
}
};
onSelectionChanged = (selection: SavedObjectWithMetadata[]) => {
@ -305,7 +336,7 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
selectedSavedObjects: [],
},
() => {
this.fetchSavedObjects();
this.fetchAllSavedObjects();
this.fetchCounts();
}
);
@ -320,7 +351,7 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
perPage,
selectedSavedObjects: [],
},
this.fetchSavedObjects
this.fetchAllSavedObjects
);
};
@ -438,7 +469,7 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
finishImport = () => {
this.hideImportFlyout();
this.fetchSavedObjects();
this.fetchAllSavedObjects();
this.fetchCounts();
};
@ -480,7 +511,7 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
});
// Fetching all data
await this.fetchSavedObjects();
this.fetchAllSavedObjects();
await this.fetchCounts();
// Allow the user to interact with the table once the saved objects have been re-fetched.
@ -625,7 +656,7 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
<Header
onExportAll={() => this.setState({ isShowingExportAllOptionsModal: true })}
onImport={this.showImportFlyout}
onRefresh={this.refreshObjects}
onRefresh={this.refreshAllObjects}
filteredCount={filteredItemCount}
/>
<EuiSpacer size="l" />
@ -645,7 +676,7 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
onExport={this.onExport}
capabilities={applications.capabilities}
onDelete={this.onDelete}
onActionRefresh={this.refreshObject}
onActionRefresh={this.refreshObjects}
goInspectObject={this.props.goInspectObject}
pageIndex={page}
pageSize={perPage}
@ -660,3 +691,7 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
);
}
}
function getObjectKey(obj: { type: string; id: string }) {
return `${obj.type}:${obj.id}`;
}

View file

@ -27,7 +27,7 @@ export abstract class SavedObjectsManagementAction {
onClick?: (item: SavedObjectsManagementRecord) => void;
render?: (item: SavedObjectsManagementRecord) => any;
};
public refreshOnFinish?: () => boolean;
public refreshOnFinish?: () => Array<{ type: string; id: string }>;
private callbacks: Function[] = [];

View file

@ -11,34 +11,42 @@ import { IRouter } from 'src/core/server';
import { injectMetaAttributes } from '../lib';
import { ISavedObjectsManagement } from '../services';
export const registerGetRoute = (
export const registerBulkGetRoute = (
router: IRouter,
managementServicePromise: Promise<ISavedObjectsManagement>
) => {
router.get(
router.post(
{
path: '/api/kibana/management/saved_objects/{type}/{id}',
path: '/api/kibana/management/saved_objects/_bulk_get',
validate: {
params: schema.object({
type: schema.string(),
id: schema.string(),
}),
body: schema.arrayOf(
schema.object({
type: schema.string(),
id: schema.string(),
})
),
},
},
router.handleLegacyErrors(async (context, req, res) => {
const { type, id } = req.params;
const managementService = await managementServicePromise;
const { getClient, typeRegistry } = context.core.savedObjects;
const includedHiddenTypes = [type].filter(
(entry) => typeRegistry.isHidden(entry) && typeRegistry.isImportableAndExportable(entry)
const objects = req.body;
const uniqueTypes = objects.reduce((acc, { type }) => acc.add(type), new Set<string>());
const includedHiddenTypes = Array.from(uniqueTypes).filter(
(type) => typeRegistry.isHidden(type) && typeRegistry.isImportableAndExportable(type)
);
const client = getClient({ includedHiddenTypes });
const findResponse = await client.get<any>(type, id);
const response = await client.bulkGet<unknown>(objects);
const enhancedObjects = response.saved_objects.map((obj) => {
if (!obj.error) {
return injectMetaAttributes(obj, managementService);
}
return obj;
});
const enhancedSavedObject = injectMetaAttributes(findResponse, managementService);
return res.ok({ body: enhancedSavedObject });
return res.ok({ body: enhancedObjects });
})
);
};

View file

@ -23,8 +23,8 @@ describe('registerRoutes', () => {
});
expect(httpSetup.createRouter).toHaveBeenCalledTimes(1);
expect(router.get).toHaveBeenCalledTimes(4);
expect(router.post).toHaveBeenCalledTimes(2);
expect(router.get).toHaveBeenCalledTimes(3);
expect(router.post).toHaveBeenCalledTimes(3);
expect(router.get).toHaveBeenCalledWith(
expect.objectContaining({
@ -32,9 +32,9 @@ describe('registerRoutes', () => {
}),
expect.any(Function)
);
expect(router.get).toHaveBeenCalledWith(
expect(router.post).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/kibana/management/saved_objects/{type}/{id}',
path: '/api/kibana/management/saved_objects/_bulk_get',
}),
expect.any(Function)
);

View file

@ -9,7 +9,7 @@
import { HttpServiceSetup } from 'src/core/server';
import { ISavedObjectsManagement } from '../services';
import { registerFindRoute } from './find';
import { registerGetRoute } from './get';
import { registerBulkGetRoute } from './bulk_get';
import { registerScrollForCountRoute } from './scroll_count';
import { registerScrollForExportRoute } from './scroll_export';
import { registerRelationshipsRoute } from './relationships';
@ -23,7 +23,7 @@ interface RegisterRouteOptions {
export function registerRoutes({ http, managementServicePromise }: RegisterRouteOptions) {
const router = http.createRouter();
registerFindRoute(router, managementServicePromise);
registerGetRoute(router, managementServicePromise);
registerBulkGetRoute(router, managementServicePromise);
registerScrollForCountRoute(router);
registerScrollForExportRoute(router);
registerRelationshipsRoute(router, managementServicePromise);

View file

@ -168,15 +168,19 @@ export interface ShareToSpaceFlyoutProps {
*/
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/_update_objects_spaces` and displays a toast indicating
* what occurred.
* 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 and
* its relatives. If this is not defined, a default handler will be used that calls `/api/spaces/_update_objects_spaces` and displays a
* toast indicating what occurred.
*/
changeSpacesHandler?: (spacesToAdd: string[], spacesToRemove: string[]) => Promise<void>;
changeSpacesHandler?: (
objects: Array<{ type: string; id: string }>,
spacesToAdd: string[],
spacesToRemove: string[]
) => Promise<void>;
/**
* Optional callback when the target object is updated.
* Optional callback when the target object and its relatives are updated.
*/
onUpdate?: () => void;
onUpdate?: (updatedObjects: Array<{ type: string; id: string }>) => void;
/**
* Optional callback when the flyout is closed.
*/
@ -288,4 +292,11 @@ export interface SpaceAvatarProps {
* Default value is true.
*/
announceSpaceName?: boolean;
/**
* Whether or not to render the avatar in a disabled state.
*
* Default value is false.
*/
isDisabled?: boolean;
}

View file

@ -7188,6 +7188,34 @@
}
}
}
},
"legacyUrlAliases": {
"properties": {
"inactiveCount": {
"type": "long",
"_meta": {
"description": "Count of legacy URL aliases that are inactive; they are not disabled, but they have not been resolved."
}
},
"activeCount": {
"type": "long",
"_meta": {
"description": "Count of legacy URL aliases that are active; they are not disabled, and they have been resolved at least once."
}
},
"disabledCount": {
"type": "long",
"_meta": {
"description": "Count of legacy URL aliases that are disabled."
}
},
"totalCount": {
"type": "long",
"_meta": {
"description": "Total count of legacy URL aliases."
}
}
}
}
}
}
@ -7744,6 +7772,36 @@
"_meta": {
"description": "How many times this API has been called without all types selected."
}
},
"savedObjectsRepository.resolvedOutcome.exactMatch": {
"type": "long",
"_meta": {
"description": "How many times a saved object has resolved with an exact match outcome."
}
},
"savedObjectsRepository.resolvedOutcome.aliasMatch": {
"type": "long",
"_meta": {
"description": "How many times a saved object has resolved with an alias match outcome."
}
},
"savedObjectsRepository.resolvedOutcome.conflict": {
"type": "long",
"_meta": {
"description": "How many times a saved object has resolved with a conflict outcome."
}
},
"savedObjectsRepository.resolvedOutcome.notFound": {
"type": "long",
"_meta": {
"description": "How many times a saved object has resolved with a not found outcome."
}
},
"savedObjectsRepository.resolvedOutcome.total": {
"type": "long",
"_meta": {
"description": "How many times a saved object has resolved with any of the four possible outcomes."
}
}
}
},

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import type { Response } from 'supertest';
import type { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
describe('_bulk_get', () => {
const URL = '/api/kibana/management/saved_objects/_bulk_get';
const validObject = { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab' };
const invalidObject = { type: 'wigwags', id: 'foo' };
before(() =>
kibanaServer.importExport.load(
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
)
);
after(() =>
kibanaServer.importExport.unload(
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
)
);
function expectSuccess(index: number, { body }: Response) {
const { type, id, meta, error } = body[index];
expect(type).to.eql(validObject.type);
expect(id).to.eql(validObject.id);
expect(meta).to.not.equal(undefined);
expect(error).to.equal(undefined);
}
function expectBadRequest(index: number, { body }: Response) {
const { type, id, error } = body[index];
expect(type).to.eql(invalidObject.type);
expect(id).to.eql(invalidObject.id);
expect(error).to.eql({
message: `Unsupported saved object type: '${invalidObject.type}': Bad Request`,
statusCode: 400,
error: 'Bad Request',
});
}
it('should return 200 for object that exists and inject metadata', async () =>
await supertest
.post(URL)
.send([validObject])
.expect(200)
.then((response: Response) => {
expect(response.body).to.have.length(1);
expectSuccess(0, response);
}));
it('should return error for invalid object type', async () =>
await supertest
.post(URL)
.send([invalidObject])
.expect(200)
.then((response: Response) => {
expect(response.body).to.have.length(1);
expectBadRequest(0, response);
}));
it('should return mix of successes and errors', async () =>
await supertest
.post(URL)
.send([validObject, invalidObject])
.expect(200)
.then((response: Response) => {
expect(response.body).to.have.length(2);
expectSuccess(0, response);
expectBadRequest(1, response);
}));
});
}

View file

@ -1,47 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { Response } from 'supertest';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
describe('get', () => {
const existingObject = 'visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab';
const nonexistentObject = 'wigwags/foo';
before(async () => {
await kibanaServer.importExport.load(
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
});
after(async () => {
await kibanaServer.importExport.unload(
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
});
it('should return 200 for object that exists and inject metadata', async () =>
await supertest
.get(`/api/kibana/management/saved_objects/${existingObject}`)
.expect(200)
.then((resp: Response) => {
const { body } = resp;
const { type, id, meta } = body;
expect(type).to.eql('visualization');
expect(id).to.eql('dd7caf20-9efd-11e7-acb3-3dab96693fab');
expect(meta).to.not.equal(undefined);
}));
it('should return 404 for object that does not exist', async () =>
await supertest.get(`/api/kibana/management/saved_objects/${nonexistentObject}`).expect(404));
});
}

View file

@ -11,7 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('saved objects management apis', () => {
loadTestFile(require.resolve('./find'));
loadTestFile(require.resolve('./get'));
loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./relationships'));
loadTestFile(require.resolve('./scroll_count'));
});

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import type { Response } from 'supertest';
import type { PluginFunctionalProviderContext } from '../../services';
export default function ({ getService }: PluginFunctionalProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('_bulk_get', () => {
describe('saved objects with hidden type', () => {
before(() =>
esArchiver.load(
'test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects'
)
);
after(() =>
esArchiver.unload(
'test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects'
)
);
const URL = '/api/kibana/management/saved_objects/_bulk_get';
const hiddenTypeExportableImportable = {
type: 'test-hidden-importable-exportable',
id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab',
};
const hiddenTypeNonExportableImportable = {
type: 'test-hidden-non-importable-exportable',
id: 'op3767a1-9rcg-53u7-jkb3-3dnb74193awc',
};
function expectSuccess(index: number, { body }: Response) {
const { type, id, meta, error } = body[index];
expect(type).to.eql(hiddenTypeExportableImportable.type);
expect(id).to.eql(hiddenTypeExportableImportable.id);
expect(meta).to.not.equal(undefined);
expect(error).to.equal(undefined);
}
function expectBadRequest(index: number, { body }: Response) {
const { type, id, error } = body[index];
expect(type).to.eql(hiddenTypeNonExportableImportable.type);
expect(id).to.eql(hiddenTypeNonExportableImportable.id);
expect(error).to.eql({
message: `Unsupported saved object type: '${hiddenTypeNonExportableImportable.type}': Bad Request`,
statusCode: 400,
error: 'Bad Request',
});
}
it('should return 200 for hidden types that are importableAndExportable', async () =>
await supertest
.post(URL)
.send([hiddenTypeExportableImportable])
.set('kbn-xsrf', 'true')
.expect(200)
.then((response: Response) => {
expect(response.body).to.have.length(1);
expectSuccess(0, response);
}));
it('should return error for hidden types that are not importableAndExportable', async () =>
await supertest
.post(URL)
.send([hiddenTypeNonExportableImportable])
.set('kbn-xsrf', 'true')
.expect(200)
.then((response: Response) => {
expect(response.body).to.have.length(1);
expectBadRequest(0, response);
}));
it('should return mix of successes and errors', async () =>
await supertest
.post(URL)
.send([hiddenTypeExportableImportable, hiddenTypeNonExportableImportable])
.set('kbn-xsrf', 'true')
.expect(200)
.then((response: Response) => {
expect(response.body).to.have.length(2);
expectSuccess(0, response);
expectBadRequest(1, response);
}));
});
});
}

View file

@ -1,53 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { PluginFunctionalProviderContext } from '../../services';
export default function ({ getService }: PluginFunctionalProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('get', () => {
describe('saved objects with hidden type', () => {
before(() =>
esArchiver.load(
'test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects'
)
);
after(() =>
esArchiver.unload(
'test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects'
)
);
const hiddenTypeExportableImportable =
'test-hidden-importable-exportable/ff3733a0-9fty-11e7-ahb3-3dcb94193fab';
const hiddenTypeNonExportableImportable =
'test-hidden-non-importable-exportable/op3767a1-9rcg-53u7-jkb3-3dnb74193awc';
it('should return 200 for hidden types that are importableAndExportable', async () =>
await supertest
.get(`/api/kibana/management/saved_objects/${hiddenTypeExportableImportable}`)
.set('kbn-xsrf', 'true')
.expect(200)
.then((resp) => {
const { body } = resp;
const { type, id, meta } = body;
expect(type).to.eql('test-hidden-importable-exportable');
expect(id).to.eql('ff3733a0-9fty-11e7-ahb3-3dcb94193fab');
expect(meta).to.not.equal(undefined);
}));
it('should return 404 for hidden types that are not importableAndExportable', async () =>
await supertest
.get(`/api/kibana/management/saved_objects/${hiddenTypeNonExportableImportable}`)
.set('kbn-xsrf', 'true')
.expect(404));
});
});
}

View file

@ -12,7 +12,7 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) {
describe('Saved Objects Management', function () {
loadTestFile(require.resolve('./find'));
loadTestFile(require.resolve('./scroll_count'));
loadTestFile(require.resolve('./get'));
loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./export_transform'));
loadTestFile(require.resolve('./import_warnings'));
loadTestFile(require.resolve('./hidden_types'));

View file

@ -37,7 +37,11 @@ export const JobSpacesList: FC<Props> = ({ spacesApi, spaceIds, jobId, jobType,
const [showFlyout, setShowFlyout] = useState(false);
async function changeSpacesHandler(spacesToAdd: string[], spacesToMaybeRemove: string[]) {
async function changeSpacesHandler(
_objects: Array<{ type: string; id: string }>, // this is ignored because ML jobs do not have references
spacesToAdd: string[],
spacesToMaybeRemove: string[]
) {
// If the user is adding the job to all current and future spaces, don't remove it from any specified spaces
const spacesToRemove = spacesToAdd.includes(ALL_SPACES_ID) ? [] : spacesToMaybeRemove;

View file

@ -94,6 +94,7 @@ export interface AuthorizationServiceSetup {
actions: Actions;
checkPrivilegesWithRequest: CheckPrivilegesWithRequest;
checkPrivilegesDynamicallyWithRequest: CheckPrivilegesDynamicallyWithRequest;
checkSavedObjectsPrivilegesWithRequest: CheckSavedObjectsPrivilegesWithRequest;
mode: AuthorizationMode;
}

View file

@ -23,6 +23,7 @@ function createSetupMock() {
actions: mockAuthz.actions,
checkPrivilegesWithRequest: mockAuthz.checkPrivilegesWithRequest,
checkPrivilegesDynamicallyWithRequest: mockAuthz.checkPrivilegesDynamicallyWithRequest,
checkSavedObjectsPrivilegesWithRequest: mockAuthz.checkSavedObjectsPrivilegesWithRequest,
mode: mockAuthz.mode,
},
registerSpacesService: jest.fn(),
@ -42,6 +43,7 @@ function createStartMock() {
actions: mockAuthz.actions,
checkPrivilegesWithRequest: mockAuthz.checkPrivilegesWithRequest,
checkPrivilegesDynamicallyWithRequest: mockAuthz.checkPrivilegesDynamicallyWithRequest,
checkSavedObjectsPrivilegesWithRequest: mockAuthz.checkSavedObjectsPrivilegesWithRequest,
mode: mockAuthz.mode,
},
};

View file

@ -101,6 +101,7 @@ describe('Security Plugin', () => {
},
"checkPrivilegesDynamicallyWithRequest": [Function],
"checkPrivilegesWithRequest": [Function],
"checkSavedObjectsPrivilegesWithRequest": [Function],
"mode": Object {
"useRbacForRequest": [Function],
},
@ -171,6 +172,7 @@ describe('Security Plugin', () => {
},
"checkPrivilegesDynamicallyWithRequest": [Function],
"checkPrivilegesWithRequest": [Function],
"checkSavedObjectsPrivilegesWithRequest": [Function],
"mode": Object {
"useRbacForRequest": [Function],
},

View file

@ -328,6 +328,8 @@ export class SecurityPlugin
checkPrivilegesWithRequest: this.authorizationSetup.checkPrivilegesWithRequest,
checkPrivilegesDynamicallyWithRequest: this.authorizationSetup
.checkPrivilegesDynamicallyWithRequest,
checkSavedObjectsPrivilegesWithRequest: this.authorizationSetup
.checkSavedObjectsPrivilegesWithRequest,
mode: this.authorizationSetup.mode,
},
@ -386,6 +388,8 @@ export class SecurityPlugin
checkPrivilegesWithRequest: this.authorizationSetup!.checkPrivilegesWithRequest,
checkPrivilegesDynamicallyWithRequest: this.authorizationSetup!
.checkPrivilegesDynamicallyWithRequest,
checkSavedObjectsPrivilegesWithRequest: this.authorizationSetup!
.checkSavedObjectsPrivilegesWithRequest,
mode: this.authorizationSetup!.mode,
},
});

View file

@ -11,7 +11,11 @@ import type { CheckSavedObjectsPrivileges } from '../authorization';
import { Actions } from '../authorization';
import type { CheckPrivilegesResponse } from '../authorization/types';
import type { EnsureAuthorizedResult } from './ensure_authorized';
import { ensureAuthorized, getEnsureAuthorizedActionResult } from './ensure_authorized';
import {
ensureAuthorized,
getEnsureAuthorizedActionResult,
isAuthorizedForObjectInAllSpaces,
} from './ensure_authorized';
describe('ensureAuthorized', () => {
function setupDependencies() {
@ -224,3 +228,46 @@ describe('getEnsureAuthorizedActionResult', () => {
expect(result).toEqual({ authorizedSpaces: [] });
});
});
describe('isAuthorizedForObjectInAllSpaces', () => {
const typeActionMap: EnsureAuthorizedResult<'action'>['typeActionMap'] = new Map([
['type-1', { action: { authorizedSpaces: [], isGloballyAuthorized: true } }],
['type-2', { action: { authorizedSpaces: ['space-1', 'space-2'] } }],
['type-3', { action: { authorizedSpaces: [] } }],
// type-4 is not present in the results
]);
test('returns true if the user is authorized for the type in the given spaces', () => {
const type1Result = isAuthorizedForObjectInAllSpaces('type-1', 'action', typeActionMap, [
'space-1',
'space-2',
'space-3',
]);
expect(type1Result).toBe(true);
const type2Result = isAuthorizedForObjectInAllSpaces('type-2', 'action', typeActionMap, [
'space-1',
'space-2',
]);
expect(type2Result).toBe(true);
});
test('returns false if the user is not authorized for the type in the given spaces', () => {
const type2Result = isAuthorizedForObjectInAllSpaces('type-2', 'action', typeActionMap, [
'space-1',
'space-2',
'space-3', // the user is not authorized for this type and action in space-3
]);
expect(type2Result).toBe(false);
const type3Result = isAuthorizedForObjectInAllSpaces('type-3', 'action', typeActionMap, [
'space-1', // the user is not authorized for this type and action in any space
]);
expect(type3Result).toBe(false);
const type4Result = isAuthorizedForObjectInAllSpaces('type-4', 'action', typeActionMap, [
'space-1', // the user is not authorized for this type and action in any space
]);
expect(type4Result).toBe(false);
});
});

View file

@ -135,6 +135,29 @@ export function getEnsureAuthorizedActionResult<T extends string>(
return record[action] ?? { authorizedSpaces: [] };
}
/**
* Helper function that, given an `EnsureAuthorizedResult`, ensures that the user is authorized to perform a given action for the given
* object type in the given spaces.
*
* @param {string} objectType the object type to check.
* @param {T} action the action to check.
* @param {EnsureAuthorizedResult<T>['typeActionMap']} typeActionMap the typeActionMap from an EnsureAuthorizedResult.
* @param {string[]} spacesToAuthorizeFor the spaces to check.
*/
export function isAuthorizedForObjectInAllSpaces<T extends string>(
objectType: string,
action: T,
typeActionMap: EnsureAuthorizedResult<T>['typeActionMap'],
spacesToAuthorizeFor: string[]
) {
const actionResult = getEnsureAuthorizedActionResult(objectType, action, typeActionMap);
const { authorizedSpaces, isGloballyAuthorized } = actionResult;
const authorizedSpacesSet = new Set(authorizedSpaces);
return (
isGloballyAuthorized || spacesToAuthorizeFor.every((space) => authorizedSpacesSet.has(space))
);
}
async function checkPrivileges(
deps: EnsureAuthorizedDependencies,
actions: string | string[],

View file

@ -24,6 +24,19 @@ interface SetupSavedObjectsParams {
getSpacesService(): SpacesService | undefined;
}
export type {
EnsureAuthorizedDependencies,
EnsureAuthorizedOptions,
EnsureAuthorizedResult,
EnsureAuthorizedActionResult,
} from './ensure_authorized';
export {
ensureAuthorized,
getEnsureAuthorizedActionResult,
isAuthorizedForObjectInAllSpaces,
} from './ensure_authorized';
export function setupSavedObjects({
legacyAuditLogger,
audit,

View file

@ -41,7 +41,11 @@ import type {
EnsureAuthorizedOptions,
EnsureAuthorizedResult,
} from './ensure_authorized';
import { ensureAuthorized, getEnsureAuthorizedActionResult } from './ensure_authorized';
import {
ensureAuthorized,
getEnsureAuthorizedActionResult,
isAuthorizedForObjectInAllSpaces,
} from './ensure_authorized';
interface SecureSavedObjectsClientWrapperOptions {
actions: Actions;
@ -1071,20 +1075,6 @@ function namespaceComparator(a: string, b: string) {
return A > B ? 1 : A < B ? -1 : 0;
}
function isAuthorizedForObjectInAllSpaces<T extends string>(
objectType: string,
action: T,
typeActionMap: EnsureAuthorizedResult<T>['typeActionMap'],
spacesToAuthorizeFor: string[]
) {
const actionResult = getEnsureAuthorizedActionResult(objectType, action, typeActionMap);
const { authorizedSpaces, isGloballyAuthorized } = actionResult;
const authorizedSpacesSet = new Set(authorizedSpaces);
return (
isGloballyAuthorized || spacesToAuthorizeFor.every((space) => authorizedSpacesSet.has(space))
);
}
function getRedactedSpaces<T extends string>(
objectType: string,
action: T,

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ensureAuthorized } from '../saved_objects';
export const mockEnsureAuthorized = jest.fn() as jest.MockedFunction<typeof ensureAuthorized>;
jest.mock('../saved_objects', () => {
return {
...jest.requireActual('../saved_objects'),
ensureAuthorized: mockEnsureAuthorized,
};
});

View file

@ -5,15 +5,17 @@
* 2.0.
*/
import { mockEnsureAuthorized } from './secure_spaces_client_wrapper.test.mocks';
import { deepFreeze } from '@kbn/std';
import type { EcsEventOutcome } from 'src/core/server';
import type { EcsEventOutcome, SavedObjectsClientContract } from 'src/core/server';
import { SavedObjectsErrorHelpers } from 'src/core/server';
import { httpServerMock } from 'src/core/server/mocks';
import type { GetAllSpacesPurpose, Space } from '../../../spaces/server';
import type { GetAllSpacesPurpose, LegacyUrlAliasTarget, Space } from '../../../spaces/server';
import { spacesClientMock } from '../../../spaces/server/mocks';
import type { AuditEvent, AuditLogger } from '../audit';
import { SpaceAuditAction } from '../audit';
import { SavedObjectAction, SpaceAuditAction } from '../audit';
import { auditServiceMock } from '../audit/index.mock';
import type {
AuthorizationServiceSetup,
@ -22,7 +24,11 @@ import type {
import { authorizationMock } from '../authorization/index.mock';
import type { CheckPrivilegesResponse } from '../authorization/types';
import type { LegacySpacesAuditLogger } from './legacy_audit_logger';
import { SecureSpacesClientWrapper } from './secure_spaces_client_wrapper';
import {
getAliasId,
LEGACY_URL_ALIAS_TYPE,
SecureSpacesClientWrapper,
} from './secure_spaces_client_wrapper';
interface Opts {
securityEnabled?: boolean;
@ -71,12 +77,20 @@ const setup = ({ securityEnabled = false }: Opts = {}) => {
const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const request = httpServerMock.createKibanaRequest();
const forbiddenError = new Error('Mock ForbiddenError');
const errors = ({
decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError),
// other errors exist but are not needed for these test cases
} as unknown) as jest.Mocked<SavedObjectsClientContract['errors']>;
const wrapper = new SecureSpacesClientWrapper(
baseClient,
request,
authorization,
auditLogger,
legacyAuditLogger
legacyAuditLogger,
errors
);
return {
authorization,
@ -85,6 +99,7 @@ const setup = ({ securityEnabled = false }: Opts = {}) => {
baseClient,
auditLogger,
legacyAuditLogger,
forbiddenError,
};
};
@ -160,6 +175,10 @@ const expectAuditEvent = (
);
};
beforeEach(() => {
mockEnsureAuthorized.mockReset();
});
describe('SecureSpacesClientWrapper', () => {
describe('#getAll', () => {
const savedObjects = [
@ -747,4 +766,99 @@ describe('SecureSpacesClientWrapper', () => {
});
});
});
describe('#disableLegacyUrlAliases', () => {
const alias1 = { targetSpace: 'space-1', targetType: 'type-1', sourceId: 'id' };
const alias2 = { targetSpace: 'space-2', targetType: 'type-2', sourceId: 'id' };
function expectAuditEvents(
auditLogger: AuditLogger,
aliases: LegacyUrlAliasTarget[],
action: EcsEventOutcome
) {
aliases.forEach((alias) => {
expectAuditEvent(auditLogger, SavedObjectAction.UPDATE, action, {
type: LEGACY_URL_ALIAS_TYPE,
id: getAliasId(alias),
});
});
}
function expectAuthorizationCheck(targetTypes: string[], targetSpaces: string[]) {
expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1);
expect(mockEnsureAuthorized).toHaveBeenCalledWith(
expect.any(Object), // dependencies
targetTypes, // unique types of the alias targets
['bulk_update'], // actions
targetSpaces, // unique spaces of the alias targets
{ requireFullAuthorization: false }
);
}
describe('when security is not enabled', () => {
const securityEnabled = false;
it('delegates to base client without checking authorization', async () => {
const { wrapper, baseClient, auditLogger } = setup({ securityEnabled });
const aliases = [alias1];
await wrapper.disableLegacyUrlAliases(aliases);
expect(mockEnsureAuthorized).not.toHaveBeenCalled();
expectAuditEvents(auditLogger, aliases, 'unknown');
expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledTimes(1);
expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledWith(aliases);
});
});
describe('when security is enabled', () => {
const securityEnabled = true;
it('re-throws the error if the authorization check fails', async () => {
const error = new Error('Oh no!');
mockEnsureAuthorized.mockRejectedValue(error);
const { wrapper, baseClient, auditLogger } = setup({ securityEnabled });
const aliases = [alias1, alias2];
await expect(() => wrapper.disableLegacyUrlAliases(aliases)).rejects.toThrow(error);
expectAuthorizationCheck(['type-1', 'type-2'], ['space-1', 'space-2']);
expectAuditEvents(auditLogger, aliases, 'failure');
expect(baseClient.disableLegacyUrlAliases).not.toHaveBeenCalled();
});
it('throws a forbidden error when unauthorized', async () => {
mockEnsureAuthorized.mockResolvedValue({
status: 'partially_authorized',
typeActionMap: new Map()
.set('type-1', { bulk_update: { authorizedSpaces: ['space-1'] } })
.set('type-2', { bulk_update: { authorizedSpaces: ['space-1'] } }), // the user is not authorized to bulkUpdate type-2 in space-2, so this will throw a forbidden error
});
const { wrapper, baseClient, auditLogger, forbiddenError } = setup({ securityEnabled });
const aliases = [alias1, alias2];
await expect(() => wrapper.disableLegacyUrlAliases(aliases)).rejects.toThrow(
forbiddenError
);
expectAuthorizationCheck(['type-1', 'type-2'], ['space-1', 'space-2']);
expectAuditEvents(auditLogger, aliases, 'failure');
expect(baseClient.disableLegacyUrlAliases).not.toHaveBeenCalled();
});
it('updates the legacy URL aliases when authorized', async () => {
mockEnsureAuthorized.mockResolvedValue({
status: 'partially_authorized',
typeActionMap: new Map()
.set('type-1', { bulk_update: { authorizedSpaces: ['space-1'] } })
.set('type-2', { bulk_update: { authorizedSpaces: ['space-2'] } }),
});
const { wrapper, baseClient, auditLogger } = setup({ securityEnabled });
const aliases = [alias1, alias2];
await wrapper.disableLegacyUrlAliases(aliases);
expectAuthorizationCheck(['type-1', 'type-2'], ['space-1', 'space-2']);
expectAuditEvents(auditLogger, aliases, 'unknown');
expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledTimes(1);
expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledWith(aliases);
});
});
});
});

View file

@ -7,19 +7,22 @@
import Boom from '@hapi/boom';
import type { KibanaRequest } from 'src/core/server';
import type { KibanaRequest, SavedObjectsClientContract } from 'src/core/server';
import type {
GetAllSpacesOptions,
GetAllSpacesPurpose,
GetSpaceResult,
ISpacesClient,
LegacyUrlAliasTarget,
Space,
} from '../../../spaces/server';
import type { AuditLogger } from '../audit';
import { SpaceAuditAction, spaceAuditEvent } from '../audit';
import { SavedObjectAction, savedObjectEvent, SpaceAuditAction, spaceAuditEvent } from '../audit';
import type { AuthorizationServiceSetup } from '../authorization';
import type { SecurityPluginSetup } from '../plugin';
import type { EnsureAuthorizedDependencies, EnsureAuthorizedOptions } from '../saved_objects';
import { ensureAuthorized, isAuthorizedForObjectInAllSpaces } from '../saved_objects';
import type { LegacySpacesAuditLogger } from './legacy_audit_logger';
const PURPOSE_PRIVILEGE_MAP: Record<
@ -38,6 +41,9 @@ const PURPOSE_PRIVILEGE_MAP: Record<
],
};
/** @internal */
export const LEGACY_URL_ALIAS_TYPE = 'legacy-url-alias';
export class SecureSpacesClientWrapper implements ISpacesClient {
private readonly useRbac = this.authorization.mode.useRbacForRequest(this.request);
@ -46,7 +52,8 @@ export class SecureSpacesClientWrapper implements ISpacesClient {
private readonly request: KibanaRequest,
private readonly authorization: AuthorizationServiceSetup,
private readonly auditLogger: AuditLogger,
private readonly legacyAuditLogger: LegacySpacesAuditLogger
private readonly legacyAuditLogger: LegacySpacesAuditLogger,
private readonly errors: SavedObjectsClientContract['errors']
) {}
public async getAll({
@ -277,6 +284,85 @@ export class SecureSpacesClientWrapper implements ISpacesClient {
return this.spacesClient.delete(id);
}
public async disableLegacyUrlAliases(aliases: LegacyUrlAliasTarget[]) {
if (this.useRbac) {
try {
const [uniqueSpaces, uniqueTypes, typesAndSpacesMap] = aliases.reduce(
([spaces, types, typesAndSpaces], { targetSpace, targetType }) => {
const spacesForType = typesAndSpaces.get(targetType) ?? new Set();
return [
spaces.add(targetSpace),
types.add(targetType),
typesAndSpaces.set(targetType, spacesForType.add(targetSpace)),
];
},
[new Set<string>(), new Set<string>(), new Map<string, Set<string>>()]
);
const action = 'bulk_update';
const { typeActionMap } = await this.ensureAuthorizedForSavedObjects(
Array.from(uniqueTypes),
[action],
Array.from(uniqueSpaces),
{ requireFullAuthorization: false }
);
const unauthorizedTypes = new Set<string>();
for (const type of uniqueTypes) {
const spaces = Array.from(typesAndSpacesMap.get(type)!);
if (!isAuthorizedForObjectInAllSpaces(type, action, typeActionMap, spaces)) {
unauthorizedTypes.add(type);
}
}
if (unauthorizedTypes.size > 0) {
const targetTypes = Array.from(unauthorizedTypes).sort().join(',');
const msg = `Unable to disable aliases for ${targetTypes}`;
throw this.errors.decorateForbiddenError(new Error(msg));
}
} catch (error) {
aliases.forEach((alias) => {
const id = getAliasId(alias);
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.UPDATE,
savedObject: { type: LEGACY_URL_ALIAS_TYPE, id },
error,
})
);
});
throw error;
}
}
aliases.forEach((alias) => {
const id = getAliasId(alias);
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.UPDATE,
outcome: 'unknown',
savedObject: { type: LEGACY_URL_ALIAS_TYPE, id },
})
);
});
return this.spacesClient.disableLegacyUrlAliases(aliases);
}
private async ensureAuthorizedForSavedObjects<T extends string>(
types: string[],
actions: T[],
namespaces: string[],
options?: EnsureAuthorizedOptions
) {
const ensureAuthorizedDependencies: EnsureAuthorizedDependencies = {
actions: this.authorization.actions,
errors: this.errors,
checkSavedObjectsPrivilegesAsCurrentUser: this.authorization.checkSavedObjectsPrivilegesWithRequest(
this.request
),
};
return ensureAuthorized(ensureAuthorizedDependencies, types, actions, namespaces, options);
}
private async ensureAuthorizedGlobally(action: string, method: string, forbiddenMessage: string) {
const checkPrivileges = this.authorization.checkPrivilegesWithRequest(this.request);
const { username, hasAllRequested } = await checkPrivileges.globally({ kibana: action });
@ -312,3 +398,8 @@ export class SecureSpacesClientWrapper implements ISpacesClient {
return value !== null;
}
}
/** @internal This is only exported for testing purposes. */
export function getAliasId({ targetSpace, targetType, sourceId }: LegacyUrlAliasTarget) {
return `${targetSpace}:${targetType}:${sourceId}`;
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { SavedObjectsClient } from '../../../../../src/core/server';
import type { SpacesPluginSetup } from '../../../spaces/server';
import type { AuditServiceSetup } from '../audit';
import type { AuthorizationServiceSetup } from '../authorization';
@ -39,7 +40,8 @@ export const setupSpacesClient = ({ audit, authz, spaces }: Deps) => {
request,
authz,
audit.asScoped(request),
spacesAuditLogger
spacesAuditLogger,
SavedObjectsClient.errors
)
);
};

View file

@ -8,4 +8,9 @@
export { isReservedSpace } from './is_reserved_space';
export { MAX_SPACE_INITIALS, SPACE_SEARCH_COUNT_THRESHOLD, ENTER_SPACE_PATH } from './constants';
export { addSpaceIdToPath, getSpaceIdFromPath } from './lib/spaces_url_parser';
export type { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult } from './types';
export type {
GetAllSpacesOptions,
GetAllSpacesPurpose,
GetSpaceResult,
LegacyUrlAliasTarget,
} from './types';

View file

@ -50,3 +50,21 @@ export interface GetSpaceResult extends Space {
*/
authorizedPurposes?: Record<GetAllSpacesPurpose, boolean>;
}
/**
* Client interface for interacting with legacy URL aliases.
*/
export interface LegacyUrlAliasTarget {
/**
* The namespace that the object existed in when it was converted.
*/
targetSpace: string;
/**
* The type of the object when it was converted.
*/
targetType: string;
/**
* The original ID of the object, before it was converted.
*/
sourceId: string;
}

View file

@ -14,6 +14,7 @@ import React, { lazy, Suspense } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import type { Space } from 'src/plugins/spaces_oss/common';
import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../common';
import { getSpaceAvatarComponent } from '../../space_avatar';
// No need to wrap LazySpaceAvatar in an error boundary, because it is one of the first chunks loaded when opening Kibana.
@ -83,7 +84,7 @@ export const SelectableSpacesControl = (props: Props) => {
className: 'spcCopyToSpace__spacesList',
'data-test-subj': 'cts-form-space-selector',
}}
searchable={options.length > 6}
searchable={options.length > SPACE_SEARCH_COUNT_THRESHOLD}
>
{(list, search) => {
return (

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { EuiTableComputedColumnType, Pagination } from '@elastic/eui';
import {
EuiCallOut,
EuiFlexItem,
EuiInMemoryTable,
EuiLoadingSpinner,
EuiSpacer,
} from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React, { lazy, Suspense, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { getSpaceAvatarComponent } from '../../space_avatar';
import type { ShareToSpaceTarget } from '../../types';
import type { InternalLegacyUrlAliasTarget } from './types';
// No need to wrap LazySpaceAvatar in an error boundary, because it is one of the first chunks loaded when opening Kibana.
const LazySpaceAvatar = lazy(() =>
getSpaceAvatarComponent().then((component) => ({ default: component }))
);
interface Props {
spaces: ShareToSpaceTarget[];
aliasesToDisable: InternalLegacyUrlAliasTarget[];
}
export const AliasTable: FunctionComponent<Props> = ({ spaces, aliasesToDisable }) => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const spacesMap = useMemo(
() =>
spaces.reduce(
(acc, space) => acc.set(space.id, space),
new Map<string, ShareToSpaceTarget>()
),
[spaces]
);
const filteredAliasesToDisable = useMemo(
() => aliasesToDisable.filter(({ spaceExists }) => spaceExists),
[aliasesToDisable]
);
const aliasesToDisableCount = filteredAliasesToDisable.length;
const pagination: Pagination = {
pageIndex,
pageSize,
totalItemCount: aliasesToDisableCount,
pageSizeOptions: [5, 10, 15, 20],
};
return (
<>
<EuiCallOut
size="s"
title={
<FormattedMessage
id="xpack.spaces.shareToSpace.aliasTableCalloutTitle"
defaultMessage="Legacy URL conflict"
/>
}
color="warning"
>
<FormattedMessage
id="xpack.spaces.shareToSpace.aliasTableCalloutBody"
defaultMessage="{aliasesToDisableCount, plural, one {# legacy URL} other {# legacy URLs}} will be disabled."
values={{ aliasesToDisableCount }}
/>
</EuiCallOut>
<EuiSpacer size="m" />
<EuiFlexItem>
<Suspense fallback={<EuiLoadingSpinner />}>
<EuiInMemoryTable
items={filteredAliasesToDisable}
columns={[
{ name: 'Type', field: 'targetType', sortable: true },
{ name: 'ID', field: 'sourceId', sortable: true, truncateText: true },
{
name: 'Space',
render: ({ targetSpace }) => {
const space = spacesMap.get(targetSpace)!; // it's safe to use ! here because we filtered only for aliases that are in spaces that exist
return <LazySpaceAvatar space={space} size={'s'} />; // the whole table is wrapped in a Suspense
},
sortable: ({ targetSpace }) => targetSpace,
} as EuiTableComputedColumnType<InternalLegacyUrlAliasTarget>,
]}
sorting={true}
pagination={pagination}
onTableChange={({ page: { index, size } }) => {
setPageIndex(index);
setPageSize(size);
}}
tableLayout="auto"
/>
</Suspense>
</EuiFlexItem>
</>
);
};

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiHorizontalRule, EuiText } from '@elastic/eui';
import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import type { SavedObjectReferenceWithContext } from 'src/core/public';
import type { ShareToSpaceSavedObjectTarget } from 'src/plugins/spaces_oss/public';
interface Props {
savedObjectTarget: ShareToSpaceSavedObjectTarget;
referenceGraph: SavedObjectReferenceWithContext[];
isDisabled: boolean;
}
export const RelativesFooter = (props: Props) => {
const { savedObjectTarget, referenceGraph, isDisabled } = props;
const relativesCount = useMemo(() => {
const { type, id } = savedObjectTarget;
return referenceGraph.filter(
(x) => (x.type !== type || x.id !== id) && x.spaces.length > 0 && !x.isMissing
).length;
}, [savedObjectTarget, referenceGraph]);
if (relativesCount > 0) {
return (
<>
<EuiText size="s" color={isDisabled ? 'subdued' : undefined}>
<FormattedMessage
id="xpack.spaces.shareToSpace.relativesControl.description"
defaultMessage="{relativesCount} related {relativesCount, plural, one {object} other {objects}} will also change."
values={{ relativesCount }}
/>
</EuiText>
<EuiHorizontalRule margin="s" />
</>
);
}
return null;
};

View file

@ -17,7 +17,6 @@ import {
EuiLink,
EuiLoadingSpinner,
EuiSelectable,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import React, { lazy, Suspense } from 'react';
@ -25,6 +24,7 @@ import React, { lazy, Suspense } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../common';
import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants';
import { DocumentationLinksService } from '../../lib';
import { getSpaceAvatarComponent } from '../../space_avatar';
@ -106,10 +106,14 @@ export const SelectableSpacesControl = (props: Props) => {
.sort(createSpacesComparator(activeSpaceId))
.map<SpaceOption>((space) => {
const checked = selectedSpaceIds.includes(space.id);
const additionalProps = getAdditionalProps(space, activeSpaceId, checked);
const { isAvatarDisabled, ...additionalProps } = getAdditionalProps(
space,
activeSpaceId,
checked
);
return {
label: space.name,
prepend: <LazySpaceAvatar space={space} size={'s'} />, // wrapped in a Suspense below
prepend: <LazySpaceAvatar space={space} isDisabled={isAvatarDisabled} size={'s'} />, // wrapped in a Suspense below
checked: checked ? 'on' : undefined,
['data-space-id']: space.id,
['data-test-subj']: `sts-space-selector-row-${space.id}`,
@ -140,8 +144,7 @@ export const SelectableSpacesControl = (props: Props) => {
docLinks!
).getKibanaPrivilegesDocUrl();
return (
<>
<EuiSpacer size="xs" />
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
<FormattedMessage
id="xpack.spaces.shareToSpace.unknownSpacesLabel.text"
@ -158,12 +161,16 @@ export const SelectableSpacesControl = (props: Props) => {
}}
/>
</EuiText>
</>
</EuiFlexItem>
);
};
const getNoSpacesAvailable = () => {
if (enableCreateNewSpaceLink && spaces.length < 2) {
return <NoSpacesAvailable application={application!} />;
return (
<EuiFlexItem grow={false}>
<NoSpacesAvailable application={application!} />
</EuiFlexItem>
);
}
return null;
};
@ -188,46 +195,52 @@ export const SelectableSpacesControl = (props: Props) => {
);
const hiddenSpaces = hiddenCount ? <EuiText size="xs">{hiddenSpacesLabel}</EuiText> : null;
return (
<EuiFormRow
label={selectSpacesLabel}
labelAppend={
<EuiFlexGroup direction="column" gutterSize="none" alignItems="flexEnd">
<EuiFlexItem grow={false}>
<EuiText size="xs">{selectedSpacesLabel}</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>{hiddenSpaces}</EuiFlexItem>
</EuiFlexGroup>
}
fullWidth
>
<>
<Suspense fallback={<EuiLoadingSpinner />}>
<EuiSelectable
options={options}
onChange={(newOptions) => updateSelectedSpaces(newOptions as SpaceOption[])}
listProps={{
bordered: true,
rowHeight: ROW_HEIGHT,
className: 'spcShareToSpace__spacesList',
'data-test-subj': 'sts-form-space-selector',
}}
height={ROW_HEIGHT * 3.5}
searchable={options.length > 6}
>
{(list, search) => {
return (
<>
{search}
{list}
</>
);
}}
</EuiSelectable>
</Suspense>
<>
<EuiFormRow
label={selectSpacesLabel}
labelAppend={
<EuiFlexGroup direction="column" gutterSize="none" alignItems="flexEnd">
<EuiFlexItem grow={false}>
<EuiText size="xs">{selectedSpacesLabel}</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>{hiddenSpaces}</EuiFlexItem>
</EuiFlexGroup>
}
fullWidth
>
<></>
</EuiFormRow>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<Suspense fallback={<EuiLoadingSpinner />}>
<EuiSelectable
options={options}
onChange={(newOptions) => updateSelectedSpaces(newOptions as SpaceOption[])}
listProps={{
bordered: true,
rowHeight: ROW_HEIGHT,
className: 'spcShareToSpace__spacesList',
'data-test-subj': 'sts-form-space-selector',
}}
height="full"
searchable={options.length > SPACE_SEARCH_COUNT_THRESHOLD}
>
{(list, search) => {
return (
<>
{search}
{list}
</>
);
}}
</EuiSelectable>
</Suspense>
</EuiFlexItem>
{getUnknownSpacesLabel()}
{getNoSpacesAvailable()}
</>
</EuiFormRow>
</EuiFlexGroup>
</>
);
};
@ -260,8 +273,10 @@ function getAdditionalProps(
if (space.isFeatureDisabled) {
return {
append: APPEND_FEATURE_IS_DISABLED,
isAvatarDisabled: true,
};
}
return {};
}
/**

View file

@ -1,3 +0,0 @@
.euiCheckableCard__children {
width: 100%; // required to expand the contents of EuiCheckableCard to the full width
}

View file

@ -5,11 +5,9 @@
* 2.0.
*/
import './share_mode_control.scss';
import {
EuiButtonGroup,
EuiCallOut,
EuiCheckableCard,
EuiFlexGroup,
EuiFlexItem,
EuiIconTip,
@ -40,36 +38,27 @@ interface Props {
enableSpaceAgnosticBehavior: boolean;
}
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>
</>
);
}
const buttonGroupLegend = i18n.translate(
'xpack.spaces.shareToSpace.shareModeControl.buttonGroupLegend',
{ defaultMessage: 'Choose how this is shared' }
);
const shareToAllSpacesId = 'shareToAllSpacesId';
const shareToAllSpacesButtonLabel = i18n.translate(
'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.buttonLabel',
{ defaultMessage: 'All spaces' }
);
const shareToExplicitSpacesId = 'shareToExplicitSpacesId';
const shareToExplicitSpacesButtonLabel = i18n.translate(
'xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.buttonLabel',
{ defaultMessage: 'Select spaces' }
);
const cannotChangeTooltip = i18n.translate(
'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotChangeTooltip',
{ defaultMessage: 'You need additional privileges to change this option.' }
);
export const ShareModeControl = (props: Props) => {
const {
@ -90,50 +79,9 @@ export const ShareModeControl = (props: Props) => {
const { selectedSpaceIds } = shareOptions;
const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID);
const shareToAllSpaces = {
id: 'shareToAllSpaces',
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.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip',
{ defaultMessage: 'You need additional privileges to change this option.' }
)
: i18n.translate(
'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip',
{ defaultMessage: 'You need additional privileges to use this option.' }
),
}),
disabled: !canShareToAllSpaces,
};
const shareToExplicitSpaces = {
id: 'shareToExplicitSpaces',
title: i18n.translate(
'xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.title',
{ defaultMessage: 'Select spaces' }
),
text: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text', {
defaultMessage: 'Make {objectNoun} available in selected spaces only.',
values: { objectNoun },
}),
disabled: !canShareToAllSpaces && isGlobalControlChecked,
};
const toggleShareOption = (allSpaces: boolean) => {
const updatedSpaceIds = allSpaces
? [ALL_SPACES_ID, ...selectedSpaceIds]
: selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID);
onChange(updatedSpaceIds);
};
const getPrivilegeWarning = () => {
if (!shareToExplicitSpaces.disabled) {
if (canShareToAllSpaces || !isGlobalControlChecked) {
return null;
}
@ -180,13 +128,66 @@ export const ShareModeControl = (props: Props) => {
<>
{getPrivilegeWarning()}
<EuiCheckableCard
id={shareToExplicitSpaces.id}
label={createLabel(shareToExplicitSpaces)}
checked={!isGlobalControlChecked}
onChange={() => toggleShareOption(false)}
disabled={shareToExplicitSpaces.disabled}
>
<EuiButtonGroup
type="single"
idSelected={isGlobalControlChecked ? shareToAllSpacesId : shareToExplicitSpacesId}
options={[
{ id: shareToExplicitSpacesId, label: shareToExplicitSpacesButtonLabel },
{ id: shareToAllSpacesId, label: shareToAllSpacesButtonLabel },
]}
onChange={(optionId: string) => {
const updatedSpaceIds =
optionId === shareToAllSpacesId
? [ALL_SPACES_ID, ...selectedSpaceIds]
: selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID);
onChange(updatedSpaceIds);
}}
legend={buttonGroupLegend}
color="secondary"
isFullWidth={true}
isDisabled={!canShareToAllSpaces}
/>
<EuiSpacer size="s" />
<EuiFlexItem grow={false}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiText
color="subdued"
textAlign="center"
size="s"
data-test-subj="share-mode-control-description"
>
{isGlobalControlChecked
? i18n.translate(
'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.text',
{
defaultMessage:
'Make {objectNoun} available in all current and future spaces.',
values: { objectNoun },
}
)
: i18n.translate(
'xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text',
{
defaultMessage: 'Make {objectNoun} available in selected spaces only.',
values: { objectNoun },
}
)}
</EuiText>
</EuiFlexItem>
{!canShareToAllSpaces && (
<EuiFlexItem grow={false}>
<EuiIconTip content={cannotChangeTooltip} position="left" type="iInCircle" />
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiSpacer size="m" />
<EuiFlexGroup direction="column" gutterSize="none">
<SelectableSpacesControl
spaces={spaces}
shareOptions={shareOptions}
@ -194,15 +195,7 @@ export const ShareModeControl = (props: Props) => {
enableCreateNewSpaceLink={enableCreateNewSpaceLink}
enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
/>
</EuiCheckableCard>
<EuiSpacer size="s" />
<EuiCheckableCard
id={shareToAllSpaces.id}
label={createLabel(shareToAllSpaces)}
checked={isGlobalControlChecked}
onChange={() => toggleShareOption(true)}
disabled={shareToAllSpaces.disabled}
/>
</EuiFlexGroup>
</>
);
};

View file

@ -0,0 +1,3 @@
.spcShareToSpace__flyoutBodyWrapper {
padding: $euiSizeL;
}

View file

@ -5,20 +5,14 @@
* 2.0.
*/
import type { EuiCheckableCardProps } from '@elastic/eui';
import {
EuiCallOut,
EuiCheckableCard,
EuiIconTip,
EuiLoadingSpinner,
EuiSelectable,
} from '@elastic/eui';
import { EuiCallOut, EuiIconTip, EuiLoadingSpinner, EuiSelectable } from '@elastic/eui';
import Boom from '@hapi/boom';
import { act } from '@testing-library/react';
import type { ReactWrapper } from 'enzyme';
import React from 'react';
import { findTestSubject, mountWithIntl, nextTick } from '@kbn/test/jest';
import type { SavedObjectReferenceWithContext } from 'src/core/public';
import { coreMock } from 'src/core/public/mocks';
import type { Space } from 'src/plugins/spaces_oss/common';
@ -26,7 +20,9 @@ import { ALL_SPACES_ID } from '../../../common/constants';
import { CopyToSpaceFlyoutInternal } from '../../copy_saved_objects_to_space/components/copy_to_space_flyout_internal';
import { getSpacesContextProviderWrapper } from '../../spaces_context';
import { spacesManagerMock } from '../../spaces_manager/mocks';
import { AliasTable } from './alias_table';
import { NoSpacesAvailable } from './no_spaces_available';
import { RelativesFooter } from './relatives_footer';
import { SelectableSpacesControl } from './selectable_spaces_control';
import { ShareModeControl } from './share_mode_control';
import { getShareToSpaceFlyoutComponent } from './share_to_space_flyout';
@ -41,6 +37,7 @@ interface SetupOpts {
enableCreateNewSpaceLink?: boolean;
behaviorContext?: 'within-space' | 'outside-space';
mockFeatureId?: string; // optional feature ID to use for the SpacesContext
additionalShareableReferences?: SavedObjectReferenceWithContext[];
}
const setup = async (opts: SetupOpts = {}) => {
@ -94,6 +91,19 @@ const setup = async (opts: SetupOpts = {}) => {
title: 'foo',
};
mockSpacesManager.getShareableReferences.mockResolvedValue({
objects: [
{
// this is the result for the saved object target; by default, it has no references
type: savedObjectToShare.type,
id: savedObjectToShare.id,
spaces: savedObjectToShare.namespaces,
inboundReferences: [],
},
...(opts.additionalShareableReferences ?? []),
],
});
const { getStartServices } = coreMock.createSetup();
const startServices = coreMock.createStart();
startServices.application.capabilities = {
@ -138,6 +148,25 @@ const setup = async (opts: SetupOpts = {}) => {
return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare };
};
function changeSpaceSelection(wrapper: ReactWrapper, selectedSpaces: string[]) {
// 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(selectedSpaces);
});
wrapper.update();
}
async function clickButton(wrapper: ReactWrapper, button: 'continue' | 'save' | 'copy') {
const buttonNode = findTestSubject(wrapper, `sts-${button}-button`);
await act(async () => {
buttonNode.simulate('click');
await nextTick();
wrapper.update();
});
}
describe('ShareToSpaceFlyout', () => {
it('waits for spaces to load', async () => {
const { wrapper } = await setup({ returnBeforeSpacesLoad: true });
@ -212,12 +241,7 @@ describe('ShareToSpaceFlyout', () => {
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();
});
await clickButton(wrapper, 'copy'); // this link is only present in the warning callout
wrapper.update();
expect(wrapper.find(CopyToSpaceFlyoutInternal)).toHaveLength(1);
@ -288,20 +312,8 @@ describe('ShareToSpaceFlyout', () => {
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();
});
changeSpaceSelection(wrapper, ['space-2', 'space-3']);
await clickButton(wrapper, 'save');
expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalled();
expect(mockToastNotifications.addError).toHaveBeenCalled();
@ -320,21 +332,8 @@ describe('ShareToSpaceFlyout', () => {
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();
});
changeSpaceSelection(wrapper, ['space-1', 'space-2', 'space-3']);
await clickButton(wrapper, 'save');
const { type, id } = savedObjectToShare;
expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalledWith(
@ -361,21 +360,8 @@ describe('ShareToSpaceFlyout', () => {
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();
});
changeSpaceSelection(wrapper, []);
await clickButton(wrapper, 'save');
const { type, id } = savedObjectToShare;
expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalledWith(
@ -402,21 +388,8 @@ describe('ShareToSpaceFlyout', () => {
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();
});
changeSpaceSelection(wrapper, ['space-2', 'space-3']);
await clickButton(wrapper, 'save');
const { type, id } = savedObjectToShare;
expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalledWith(
@ -430,25 +403,13 @@ describe('ShareToSpaceFlyout', () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
describe('correctly renders checkable cards', () => {
function getCheckableCardProps(
wrapper: ReactWrapper<React.PropsWithChildren<EuiCheckableCardProps>>
) {
const iconTip = wrapper.find(EuiIconTip);
describe('correctly renders share mode control', () => {
function getDescriptionAndWarning(wrapper: ReactWrapper) {
const descriptionNode = findTestSubject(wrapper, 'share-mode-control-description');
const iconTipNode = wrapper.find(ShareModeControl).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)
),
description: descriptionNode.text(),
isPrivilegeTooltipDisplayed: iconTipNode.length > 0,
};
}
@ -458,27 +419,23 @@ describe('ShareToSpaceFlyout', () => {
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);
const { description, isPrivilegeTooltipDisplayed } = getDescriptionAndWarning(wrapper);
expect(checkableCards).toEqual({
explicitSpacesCard: { checked: true, disabled: false },
allSpacesCard: { checked: false, disabled: false },
});
expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout
expect(description).toMatchInlineSnapshot(
`"Make object available in selected spaces only."`
);
expect(isPrivilegeTooltipDisplayed).toBe(false);
});
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);
const { description, isPrivilegeTooltipDisplayed } = getDescriptionAndWarning(wrapper);
expect(checkableCards).toEqual({
explicitSpacesCard: { checked: false, disabled: false },
allSpacesCard: { checked: true, disabled: false },
});
expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout
expect(description).toMatchInlineSnapshot(
`"Make object available in all current and future spaces."`
);
expect(isPrivilegeTooltipDisplayed).toBe(false);
});
});
@ -488,35 +445,23 @@ describe('ShareToSpaceFlyout', () => {
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);
const { description, isPrivilegeTooltipDisplayed } = getDescriptionAndWarning(wrapper);
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
expect(description).toMatchInlineSnapshot(
`"Make object available in selected spaces only."`
);
expect(isPrivilegeTooltipDisplayed).toBe(true);
});
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);
const { description, isPrivilegeTooltipDisplayed } = getDescriptionAndWarning(wrapper);
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
expect(description).toMatchInlineSnapshot(
`"Make object available in all current and future spaces."`
);
expect(isPrivilegeTooltipDisplayed).toBe(true);
});
});
});
@ -714,4 +659,152 @@ describe('ShareToSpaceFlyout', () => {
});
});
});
describe('alias list', () => {
it('shows only aliases for spaces that exist', async () => {
const namespaces = ['my-active-space']; // the saved object's current namespaces
const { wrapper } = await setup({
namespaces,
additionalShareableReferences: [
// it doesn't matter if aliases are for the saved object target or for references; this is easier to mock
{
type: 'foo',
id: '1',
spaces: namespaces,
inboundReferences: [],
spacesWithMatchingAliases: ['space-1', 'some-space-that-does-not-exist'], // space-1 exists, it is mocked at the top
},
],
});
changeSpaceSelection(wrapper, ['*']);
await clickButton(wrapper, 'continue');
const aliasTable = wrapper.find(AliasTable);
expect(aliasTable.prop('aliasesToDisable')).toEqual([
{ targetType: 'foo', sourceId: '1', targetSpace: 'space-1', spaceExists: true },
{
// this alias is present, and it will be disabled, but it is not displayed in the table below due to the 'spaceExists' field
targetType: 'foo',
sourceId: '1',
targetSpace: 'some-space-that-does-not-exist',
spaceExists: false,
},
]);
expect(aliasTable.find(EuiCallOut).text()).toMatchInlineSnapshot(
`"Legacy URL conflict1 legacy URL will be disabled."`
);
});
it('shows only aliases for selected spaces', async () => {
const namespaces = ['my-active-space']; // the saved object's current namespaces
const { wrapper } = await setup({
namespaces,
additionalShareableReferences: [
// it doesn't matter if aliases are for the saved object target or for references; this is easier to mock
{
type: 'foo',
id: '1',
spaces: namespaces,
inboundReferences: [],
spacesWithMatchingAliases: ['space-1', 'space-2'], // space-1 and space-2 both exist, they are mocked at the top
},
],
});
changeSpaceSelection(wrapper, ['space-1']);
await clickButton(wrapper, 'continue');
const aliasTable = wrapper.find(AliasTable);
expect(aliasTable.prop('aliasesToDisable')).toEqual([
{ targetType: 'foo', sourceId: '1', targetSpace: 'space-1', spaceExists: true },
// even though an alias exists for space-2, it will not be disabled, because we aren't sharing to that space
]);
expect(aliasTable.find(EuiCallOut).text()).toMatchInlineSnapshot(
`"Legacy URL conflict1 legacy URL will be disabled."`
);
});
});
describe('footer', () => {
it('does not show a description of relatives (references) if there are none', async () => {
const namespaces = ['my-active-space']; // the saved object's current namespaces
const { wrapper } = await setup({ namespaces });
const relativesControl = wrapper.find(RelativesFooter);
expect(relativesControl.isEmptyRender()).toBe(true);
});
it('shows a description of filtered relatives (references)', async () => {
const namespaces = ['my-active-space']; // the saved object's current namespaces
const { wrapper } = await setup({
namespaces,
additionalShareableReferences: [
// the saved object target is already included in the mock results by default; it will not be counted
{ type: 'foo', id: '1', spaces: [], inboundReferences: [] }, // this will not be counted because spaces is empty (it may not be a shareable type)
{ type: 'foo', id: '2', spaces: namespaces, inboundReferences: [], isMissing: true }, // this will not be counted because isMissing === true
{ type: 'foo', id: '3', spaces: namespaces, inboundReferences: [] }, // this will be counted
],
});
const relativesControl = wrapper.find(RelativesFooter);
expect(relativesControl.isEmptyRender()).toBe(false);
expect(relativesControl.text()).toMatchInlineSnapshot(`"1 related object will also change."`);
});
function expectButton(wrapper: ReactWrapper, button: 'save' | 'continue') {
const saveButton = findTestSubject(wrapper, 'sts-save-button');
const continueButton = findTestSubject(wrapper, 'sts-continue-button');
expect(saveButton).toHaveLength(button === 'save' ? 1 : 0);
expect(continueButton).toHaveLength(button === 'continue' ? 1 : 0);
}
it('shows a save button if there are no legacy URL aliases to disable', async () => {
const namespaces = ['my-active-space']; // the saved object's current namespaces
const { wrapper } = await setup({ namespaces });
changeSpaceSelection(wrapper, ['*']);
expectButton(wrapper, 'save');
});
it('shows a save button if there are legacy URL aliases to disable, but none for existing spaces', async () => {
const namespaces = ['my-active-space']; // the saved object's current namespaces
const { wrapper } = await setup({
namespaces,
additionalShareableReferences: [
// it doesn't matter if aliases are for the saved object target or for references; this is easier to mock
{
type: 'foo',
id: '1',
spaces: namespaces,
inboundReferences: [],
spacesWithMatchingAliases: ['some-space-that-does-not-exist'],
},
],
});
changeSpaceSelection(wrapper, ['*']);
expectButton(wrapper, 'save');
});
it('shows a continue button if there are legacy URL aliases to disable for existing spaces', async () => {
const namespaces = ['my-active-space']; // the saved object's current namespaces
const { wrapper } = await setup({
namespaces,
additionalShareableReferences: [
// it doesn't matter if aliases are for the saved object target or for references; this is easier to mock
{
type: 'foo',
id: '1',
spaces: namespaces,
inboundReferences: [],
spacesWithMatchingAliases: ['space-1', 'some-space-that-does-not-exist'], // space-1 exists, it is mocked at the top
},
],
});
changeSpaceSelection(wrapper, ['*']);
expectButton(wrapper, 'continue');
});
});
});

View file

@ -5,18 +5,19 @@
* 2.0.
*/
import './share_to_space_flyout_internal.scss';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiHorizontalRule,
EuiIcon,
EuiLoadingSpinner,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
@ -24,7 +25,7 @@ import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import type { ToastsStart } from 'src/core/public';
import type { SavedObjectReferenceWithContext, ToastsStart } from 'src/core/public';
import type {
ShareToSpaceFlyoutProps,
ShareToSpaceSavedObjectTarget,
@ -36,8 +37,11 @@ import { useSpaces } from '../../spaces_context';
import type { SpacesManager } from '../../spaces_manager';
import type { ShareToSpaceTarget } from '../../types';
import type { ShareOptions } from '../types';
import { AliasTable } from './alias_table';
import { DEFAULT_OBJECT_NOUN } from './constants';
import { RelativesFooter } from './relatives_footer';
import { ShareToSpaceForm } from './share_to_space_form';
import type { InternalLegacyUrlAliasTarget } from './types';
// No need to wrap LazyCopyToSpaceFlyout in an error boundary, because the ShareToSpaceFlyoutInternal component itself is only ever used in
// a lazy-loaded fashion with an error boundary.
@ -66,45 +70,53 @@ function createDefaultChangeSpacesHandler(
spacesManager: SpacesManager,
toastNotifications: ToastsStart
) {
return async (spacesToAdd: string[], spacesToRemove: string[]) => {
const { type, id, title } = object;
const objects = [{ type, id }];
return async (
objects: Array<{ type: string; id: string }>,
spacesToAdd: string[],
spacesToRemove: string[]
) => {
const { title } = object;
const objectsToUpdate = objects.map(({ type, id }) => ({ type, id })); // only use 'type' and 'id' fields
const relativesCount = objects.length - 1;
const toastTitle = i18n.translate('xpack.spaces.shareToSpace.shareSuccessTitle', {
values: { objectNoun: object.noun },
defaultMessage: 'Updated {objectNoun}',
description: `Object noun can be plural or singular, examples: "Updated objects", "Updated job"`,
});
await spacesManager.updateSavedObjectsSpaces(objects, spacesToAdd, spacesToRemove);
await spacesManager.updateSavedObjectsSpaces(objectsToUpdate, spacesToAdd, spacesToRemove);
const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID);
let toastText: string;
if (spacesToAdd.length > 0 && spacesToRemove.length > 0 && !isSharedToAllSpaces) {
toastText = i18n.translate('xpack.spaces.shareToSpace.shareSuccessAddRemoveText', {
defaultMessage: `'{object}' was added to {spacesTargetAdd} and removed from {spacesTargetRemove}.`, // TODO: update to include # of references and/or # of tags
defaultMessage: `'{object}' {relativesCount, plural, =0 {was} =1 {and {relativesCount} related object were} other {and {relativesCount} related objects were}} added to {spacesTargetAdd} and removed from {spacesTargetRemove}.`,
values: {
object: title,
relativesCount,
spacesTargetAdd: getSpacesTargetString(spacesToAdd),
spacesTargetRemove: getSpacesTargetString(spacesToRemove),
},
description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget...' inputs. Example strings: "'Finance dashboard' was added to 1 space and removed from 2 spaces.", "'Finance dashboard' was added to 3 spaces and removed from all spaces."`,
description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget...' inputs. Example strings: "'Finance dashboard' was added to 1 space and removed from 2 spaces.", "'Finance dashboard' and 2 related objects were added to 3 spaces and removed from all spaces."`,
});
} else if (spacesToAdd.length > 0) {
toastText = i18n.translate('xpack.spaces.shareToSpace.shareSuccessAddText', {
defaultMessage: `'{object}' was added to {spacesTarget}.`, // TODO: update to include # of references and/or # of tags
defaultMessage: `'{object}' {relativesCount, plural, =0 {was} =1 {and {relativesCount} related object were} other {and {relativesCount} related objects were}} added to {spacesTarget}.`,
values: {
object: title,
relativesCount,
spacesTarget: getSpacesTargetString(spacesToAdd),
},
description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget' input. Example strings: "'Finance dashboard' was added to 1 space.", "'Finance dashboard' was added to all spaces."`,
description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget' input. Example strings: "'Finance dashboard' was added to 1 space.", "'Finance dashboard' and 2 related objects were added to all spaces."`,
});
} else {
toastText = i18n.translate('xpack.spaces.shareToSpace.shareSuccessRemoveText', {
defaultMessage: `'{object}' was removed from {spacesTarget}.`, // TODO: update to include # of references and/or # of tags
defaultMessage: `'{object}' {relativesCount, plural, =0 {was} =1 {and {relativesCount} related object were} other {and {relativesCount} related objects were}} removed from {spacesTarget}.`,
values: {
object: title,
relativesCount,
spacesTarget: getSpacesTargetString(spacesToRemove),
},
description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget' input. Example strings: "'Finance dashboard' was removed from 1 space.", "'Finance dashboard' was removed from all spaces."`,
description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget' input. Example strings: "'Finance dashboard' was removed from 1 space.", "'Finance dashboard' and 2 related objects were removed from all spaces."`,
});
}
toastNotifications.addSuccess({ title: toastTitle, text: toastText });
@ -131,7 +143,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
const {
flyoutIcon,
flyoutTitle = i18n.translate('xpack.spaces.shareToSpace.flyoutTitle', {
defaultMessage: 'Edit spaces for {objectNoun}',
defaultMessage: 'Assign {objectNoun} to spaces',
values: { objectNoun: savedObjectTarget.noun },
}),
enableCreateCopyCallout = false,
@ -154,13 +166,15 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
const [canShareToAllSpaces, setCanShareToAllSpaces] = useState<boolean>(false);
const [showMakeCopy, setShowMakeCopy] = useState<boolean>(false);
const [{ isLoading, spaces }, setSpacesState] = useState<{
const [{ isLoading, spaces, referenceGraph, aliasTargets }, setSpacesState] = useState<{
isLoading: boolean;
spaces: ShareToSpaceTarget[];
}>({ isLoading: true, spaces: [] });
referenceGraph: SavedObjectReferenceWithContext[];
aliasTargets: InternalLegacyUrlAliasTarget[];
}>({ isLoading: true, spaces: [], referenceGraph: [], aliasTargets: [] });
useEffect(() => {
const { type, id } = savedObjectTarget;
const getShareableReferences = spacesManager.getShareableReferences([{ type, id }]); // NOTE: not used yet, this is just included so you can see the request/response in Dev Tools
const getShareableReferences = spacesManager.getShareableReferences([{ type, id }]);
const getPermissions = spacesManager.getShareSavedObjectPermissions(type);
Promise.all([shareToSpacesDataPromise, getShareableReferences, getPermissions])
.then(([shareToSpacesData, shareableReferences, permissions]) => {
@ -176,6 +190,20 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
setSpacesState({
isLoading: false,
spaces: [...shareToSpacesData.spacesMap].map(([, spaceTarget]) => spaceTarget),
referenceGraph: shareableReferences.objects,
aliasTargets: shareableReferences.objects.reduce<InternalLegacyUrlAliasTarget[]>(
(acc, x) => {
for (const space of x.spacesWithMatchingAliases ?? []) {
if (space !== '?') {
const spaceExists = shareToSpacesData.spacesMap.has(space);
// If the user does not have privileges to view all spaces, they will be redacted; we cannot attempt to disable aliases for redacted spaces.
acc.push({ targetSpace: space, targetType: x.type, sourceId: x.id, spaceExists });
}
}
return acc;
},
[]
),
});
})
.catch((e) => {
@ -195,7 +223,12 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
const getSelectionChanges = () => {
if (!spaces.length) {
return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] };
return {
isSelectionChanged: false,
spacesToAdd: [],
spacesToRemove: [],
aliasesToDisable: [],
};
}
const activeSpaceId =
!enableSpaceAgnosticBehavior && spaces.find((space) => space.isActiveSpace)!.id;
@ -231,21 +264,36 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
: isUnsharedFromAllSpaces
? [...activeSpaceArray, ...selectedSpacesToAdd]
: selectedSpacesToAdd;
const spacesToAddSet = new Set(spacesToAdd);
const spacesToRemove =
isUnsharedFromAllSpaces || !isSharedToAllSpaces
? selectedSpacesToRemove
: [...activeSpaceArray, ...initialSelection];
return { isSelectionChanged, spacesToAdd, spacesToRemove };
const aliasesToDisable = isSharedToAllSpaces
? aliasTargets
: aliasTargets.filter(({ targetSpace }) => spacesToAddSet.has(targetSpace));
return { isSelectionChanged, spacesToAdd, spacesToRemove, aliasesToDisable };
};
const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges();
const {
isSelectionChanged,
spacesToAdd,
spacesToRemove,
aliasesToDisable,
} = getSelectionChanges();
const [showAliasesToDisable, setShowAliasesToDisable] = useState(false);
const [shareInProgress, setShareInProgress] = useState(false);
async function startShare() {
setShareInProgress(true);
try {
await changeSpacesHandler(spacesToAdd, spacesToRemove);
onUpdate();
if (aliasesToDisable.length) {
const aliases = aliasesToDisable.map(({ spaceExists, ...alias }) => alias); // only use 'targetSpace', 'targetType', and 'sourceId' fields
await spacesManager.disableLegacyUrlAliases(aliases);
}
await changeSpacesHandler(referenceGraph, spacesToAdd, spacesToRemove);
const updatedObjects = referenceGraph.map(({ type, id }) => ({ type, id })); // only use 'type' and 'id' fields
onUpdate(updatedObjects);
onClose();
} catch (e) {
setShareInProgress(false);
@ -264,27 +312,86 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
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.
if (!showAliasesToDisable) {
// 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}
/>
);
}
return <AliasTable spaces={spaces} aliasesToDisable={aliasesToDisable} />;
};
const getFlyoutFooter = () => {
const filteredAliasesToDisable = aliasesToDisable.filter(({ spaceExists }) => spaceExists);
const showContinueButton = filteredAliasesToDisable.length && !showAliasesToDisable;
return (
<ShareToSpaceForm
spaces={spaces}
objectNoun={savedObjectTarget.noun}
shareOptions={shareOptions}
onUpdate={setShareOptions}
showCreateCopyCallout={showCreateCopyCallout}
canShareToAllSpaces={canShareToAllSpaces}
makeCopy={() => setShowMakeCopy(true)}
enableCreateNewSpaceLink={enableCreateNewSpaceLink}
enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
/>
<>
<RelativesFooter
savedObjectTarget={savedObjectTarget}
referenceGraph={referenceGraph}
isDisabled={isStartShareButtonDisabled}
/>
<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}>
{showContinueButton ? (
<EuiButton
fill
onClick={() => setShowAliasesToDisable(true)}
data-test-subj="sts-continue-button"
disabled={isStartShareButtonDisabled}
>
<FormattedMessage
id="xpack.spaces.shareToSpace.continueButton"
defaultMessage="Continue"
/>
</EuiButton>
) : (
<EuiButton
fill
onClick={() => startShare()}
data-test-subj="sts-save-button"
disabled={isStartShareButtonDisabled}
>
<FormattedMessage
id="xpack.spaces.shareToSpace.saveButton"
defaultMessage="Save &amp; close"
/>
</EuiButton>
)}
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};
@ -317,54 +424,33 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
</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" />
<EuiFlexGroup
direction="column"
gutterSize="none"
className="spcShareToSpace__flyoutBodyWrapper"
>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="m">
{savedObjectTarget.icon && (
<EuiFlexItem grow={false}>
<EuiIcon type={savedObjectTarget.icon} />
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiText>
<p>{savedObjectTarget.title}</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiSpacer size="m" />
{getFlyoutBody()}
</EuiFlyoutBody>
</EuiFlexGroup>
<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>
<EuiFlyoutFooter>{getFlyoutFooter()}</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -61,7 +61,7 @@ export const ShareToSpaceForm = (props: Props) => {
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()}>
<EuiLink data-test-subj="sts-copy-button" onClick={() => makeCopy()}>
<FormattedMessage
id="xpack.spaces.shareToSpace.shareWarningLink"
defaultMessage="Make a copy"
@ -77,7 +77,7 @@ export const ShareToSpaceForm = (props: Props) => {
) : null;
return (
<div data-test-subj="share-to-space-form">
<>
{createCopyCallout}
<ShareModeControl
@ -89,6 +89,6 @@ export const ShareToSpaceForm = (props: Props) => {
enableCreateNewSpaceLink={enableCreateNewSpaceLink}
enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
/>
</div>
</>
);
};

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 type { LegacyUrlAliasTarget } from '../../../common';
export interface InternalLegacyUrlAliasTarget extends LegacyUrlAliasTarget {
/**
* We could potentially have an alias for a space that does not exist; in that case, we may need disable it, but we don't want to show it
* in the UI.
*/
spaceExists: boolean;
}

View file

@ -44,13 +44,13 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage
return namespaceType === 'multiple' && !hiddenType && hasCapability;
},
onClick: (object: SavedObjectsManagementRecord) => {
this.isDataChanged = false;
this.objectsToRefresh = [];
this.start(object);
},
};
public refreshOnFinish = () => this.isDataChanged;
public refreshOnFinish = () => this.objectsToRefresh;
private isDataChanged: boolean = false;
private objectsToRefresh: Array<{ type: string; id: string }> = [];
constructor(private readonly spacesApiUi: SpacesApiUi) {
super();
@ -70,7 +70,8 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage
icon: this.record.meta.icon,
},
flyoutIcon: 'share',
onUpdate: () => (this.isDataChanged = true),
onUpdate: (updatedObjects: Array<{ type: string; id: string }>) =>
(this.objectsToRefresh = [...updatedObjects]),
onClose: this.onClose,
enableCreateCopyCallout: true,
enableCreateNewSpaceLink: true,

View file

@ -20,6 +20,12 @@ interface Props {
size?: 's' | 'm' | 'l' | 'xl';
className?: string;
announceSpaceName?: boolean;
/**
* This property is passed to the underlying `EuiAvatar` component. If enabled, the SpaceAvatar will have a grayed out appearance. For
* example, this can be useful when rendering a list of spaces for a specific feature, if the feature is disabled in one of those spaces.
* Default: false.
*/
isDisabled?: boolean;
}
export const SpaceAvatarInternal: FC<Props> = (props: Props) => {

View file

@ -142,11 +142,10 @@ export const SpaceListInternal = ({
<Suspense fallback={<EuiLoadingSpinner />}>
<EuiFlexGroup wrap responsive={false} gutterSize="xs">
{displayedSpaces.map((space) => {
// color may be undefined, which is intentional; SpacesAvatar calls the getSpaceColor function before rendering
const color = space.isFeatureDisabled ? 'hollow' : space.color;
const isDisabled = space.isFeatureDisabled;
return (
<EuiFlexItem grow={false} key={space.id}>
<LazySpaceAvatar space={{ ...space, color }} size={'s'} />
<LazySpaceAvatar space={space} isDisabled={isDisabled} size={'s'} />
</EuiFlexItem>
);
})}

View file

@ -21,6 +21,7 @@ function createSpacesManagerMock() {
createSpace: jest.fn().mockResolvedValue(undefined),
updateSpace: jest.fn().mockResolvedValue(undefined),
deleteSpace: jest.fn().mockResolvedValue(undefined),
disableLegacyUrlAliases: jest.fn().mockResolvedValue(undefined),
copySavedObjects: jest.fn().mockResolvedValue(undefined),
getShareableReferences: jest.fn().mockResolvedValue(undefined),
updateSavedObjectsSpaces: jest.fn().mockResolvedValue(undefined),

View file

@ -154,4 +154,33 @@ describe('SpacesManager', () => {
);
});
});
describe('#getShareableReferences', () => {
it('retrieves the shareable references, filters out references that are tags, and returns the result', async () => {
const obj1 = { type: 'not-a-tag', id: '1' }; // requested object
const obj2 = { type: 'tag', id: '2' }; // requested object
const obj3 = { type: 'tag', id: '3' }; // referenced object
const obj4 = { type: 'not-a-tag', id: '4' }; // referenced object
const coreStart = coreMock.createStart();
coreStart.http.post.mockResolvedValue({ objects: [obj1, obj2, obj3, obj4] }); // A realistic response would include additional fields besides 'type' and 'id', but they are not needed for this test case
const spacesManager = new SpacesManager(coreStart.http);
const requestObjects = [obj1, obj2];
const result = await spacesManager.getShareableReferences(requestObjects);
expect(coreStart.http.post).toHaveBeenCalledTimes(1);
expect(coreStart.http.post).toHaveBeenLastCalledWith(
'/api/spaces/_get_shareable_references',
{ body: JSON.stringify({ objects: requestObjects }) }
);
expect(result).toEqual({
objects: [
obj1, // obj1 is not a tag
obj2, // obj2 is a tag, but it was included in the request, so it is not excluded from the response
// obj3 is a tag, but it was not included in the request, so it is excluded from the response
obj4, // obj4 is not a tag
],
});
});
});
});

View file

@ -15,7 +15,7 @@ import type {
} from 'src/core/public';
import type { Space } from 'src/plugins/spaces_oss/common';
import type { GetAllSpacesOptions, GetSpaceResult } from '../../common';
import type { GetAllSpacesOptions, GetSpaceResult, LegacyUrlAliasTarget } from '../../common';
import type { CopySavedObjectsToSpaceResponse } from '../copy_saved_objects_to_space/types';
interface SavedObjectTarget {
@ -23,6 +23,8 @@ interface SavedObjectTarget {
id: string;
}
const TAG_TYPE = 'tag';
export class SpacesManager {
private activeSpace$: BehaviorSubject<Space | null> = new BehaviorSubject<Space | null>(null);
@ -90,6 +92,12 @@ export class SpacesManager {
await this.http.delete(`/api/spaces/space/${encodeURIComponent(space.id)}`);
}
public async disableLegacyUrlAliases(aliases: LegacyUrlAliasTarget[]) {
await this.http.post('/api/spaces/_disable_legacy_url_aliases', {
body: JSON.stringify({ aliases }),
});
}
public async copySavedObjects(
objects: SavedObjectTarget[],
spaces: string[],
@ -142,9 +150,21 @@ export class SpacesManager {
public async getShareableReferences(
objects: SavedObjectTarget[]
): Promise<SavedObjectsCollectMultiNamespaceReferencesResponse> {
return this.http.post(`/api/spaces/_get_shareable_references`, {
body: JSON.stringify({ objects }),
});
const response = await this.http.post<SavedObjectsCollectMultiNamespaceReferencesResponse>(
`/api/spaces/_get_shareable_references`,
{ body: JSON.stringify({ objects }) }
);
// We should exclude any child-reference tags because we don't yet support reconciling/merging duplicate tags. In other words: tags can
// be shared directly, but if a tag is only included as a reference of a requested object, it should not be shared.
const requestedObjectsSet = objects.reduce(
(acc, { type, id }) => acc.add(`${type}:${id}`),
new Set<string>()
);
const filteredObjects = response.objects.filter(
({ type, id }) => type !== TAG_TYPE || requestedObjectsSet.has(`${type}:${id}`)
);
return { objects: filteredObjects };
}
public async updateSavedObjectsSpaces(

View file

@ -14,6 +14,12 @@ import type { NotificationsStart } from 'src/core/public';
interface Props {
notifications: NotificationsStart;
/**
* Whether or not to show a loading spinner while waiting for the child components to load.
*
* Default is true.
*/
showLoadingSpinner?: boolean;
}
interface State {
@ -44,11 +50,12 @@ export class SuspenseErrorBoundary extends Component<PropsWithChildren<Props>, S
}
render() {
const { children, notifications } = this.props;
const { children, notifications, showLoadingSpinner = true } = this.props;
const { error } = this.state;
if (!notifications || error) {
return null;
}
return <Suspense fallback={<EuiLoadingSpinner />}>{children}</Suspense>;
const fallback = showLoadingSpinner ? <EuiLoadingSpinner /> : null;
return <Suspense fallback={fallback}>{children}</Suspense>;
}
}

View file

@ -34,9 +34,15 @@ export const getComponents = ({
/**
* Returns a function that creates a lazy-loading version of a component.
*/
function wrapLazy<T>(fn: () => Promise<FC<T>>) {
function wrapLazy<T>(fn: () => Promise<FC<T>>, options: { showLoadingSpinner?: boolean } = {}) {
const { showLoadingSpinner } = options;
return (props: JSX.IntrinsicAttributes & PropsWithRef<PropsWithChildren<T>>) => (
<LazyWrapper fn={fn} getStartServices={getStartServices} props={props} />
<LazyWrapper
fn={fn}
getStartServices={getStartServices}
props={props}
showLoadingSpinner={showLoadingSpinner}
/>
);
}
@ -44,7 +50,7 @@ export const getComponents = ({
getSpacesContextProvider: wrapLazy(() =>
getSpacesContextProviderWrapper({ spacesManager, getStartServices })
),
getShareToSpaceFlyout: wrapLazy(getShareToSpaceFlyoutComponent),
getShareToSpaceFlyout: wrapLazy(getShareToSpaceFlyoutComponent, { showLoadingSpinner: false }),
getSpaceList: wrapLazy(getSpaceListComponent),
getLegacyUrlConflict: wrapLazy(() => getLegacyUrlConflict({ getStartServices })),
getSpaceAvatar: wrapLazy(getSpaceAvatarComponent),

View file

@ -17,12 +17,14 @@ import { SuspenseErrorBoundary } from '../suspense_error_boundary';
interface InternalProps<T> {
fn: () => Promise<FC<T>>;
getStartServices: StartServicesAccessor<PluginsStart>;
showLoadingSpinner?: boolean;
props: JSX.IntrinsicAttributes & PropsWithRef<PropsWithChildren<T>>;
}
export const LazyWrapper: <T>(props: InternalProps<T>) => ReactElement | null = ({
fn,
getStartServices,
showLoadingSpinner,
props,
}) => {
const { value: startServices = [{ notifications: undefined }] } = useAsync(getStartServices);
@ -35,7 +37,7 @@ export const LazyWrapper: <T>(props: InternalProps<T>) => ReactElement | null =
}
return (
<SuspenseErrorBoundary notifications={notifications}>
<SuspenseErrorBoundary notifications={notifications} showLoadingSpinner={showLoadingSpinner}>
<LazyComponent {...props} />
</SuspenseErrorBoundary>
);

View file

@ -23,7 +23,12 @@ export { SpacesPluginSetup, SpacesPluginStart } from './plugin';
export { SpacesServiceSetup, SpacesServiceStart } from './spaces_service';
export { ISpacesClient, SpacesClientRepositoryFactory, SpacesClientWrapper } from './spaces_client';
export { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult } from '../common';
export {
GetAllSpacesOptions,
GetAllSpacesPurpose,
GetSpaceResult,
LegacyUrlAliasTarget,
} from '../common';
// re-export types from oss definition
export type { Space } from 'src/plugins/spaces_oss/common';

View file

@ -35,6 +35,7 @@ export const createMockSavedObjectsRepository = (spaces: any[] = []) => {
}
return {};
}),
bulkUpdate: jest.fn(),
delete: jest.fn((type: string, id: string) => {
return {};
}),

View file

@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as Rx from 'rxjs';
import type { RouteValidatorConfig } from 'src/core/server';
import { kibanaResponseFactory } from 'src/core/server';
import {
coreMock,
httpServerMock,
httpServiceMock,
loggingSystemMock,
} from 'src/core/server/mocks';
import { spacesConfig } from '../../../lib/__fixtures__';
import { SpacesClientService } from '../../../spaces_client';
import { SpacesService } from '../../../spaces_service';
import { usageStatsClientMock } from '../../../usage_stats/usage_stats_client.mock';
import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock';
import {
createMockSavedObjectsRepository,
createSpaces,
mockRouteContext,
mockRouteContextWithInvalidLicense,
} from '../__fixtures__';
import { initDisableLegacyUrlAliasesApi } from './disable_legacy_url_aliases';
describe('_disable_legacy_url_aliases', () => {
const spacesSavedObjects = createSpaces();
const setup = async () => {
const httpService = httpServiceMock.createSetupContract();
const router = httpService.createRouter();
const coreStart = coreMock.createStart();
const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects);
const log = loggingSystemMock.create().get('spaces');
const clientService = new SpacesClientService(jest.fn());
clientService
.setup({ config$: Rx.of(spacesConfig) })
.setClientRepositoryFactory(() => savedObjectsRepositoryMock);
const service = new SpacesService();
service.setup({
basePath: httpService.basePath,
});
const usageStatsClient = usageStatsClientMock.create();
const usageStatsServicePromise = Promise.resolve(
usageStatsServiceMock.createSetupContract(usageStatsClient)
);
const clientServiceStart = clientService.start(coreStart);
const spacesServiceStart = service.start({
basePath: coreStart.http.basePath,
spacesClientService: clientServiceStart,
});
initDisableLegacyUrlAliasesApi({
externalRouter: router,
getStartServices: async () => [coreStart, {}, {}],
log,
getSpacesService: () => spacesServiceStart,
usageStatsServicePromise,
});
const [routeDefinition, routeHandler] = router.post.mock.calls[0];
return {
routeValidation: routeDefinition.validate as RouteValidatorConfig<{}, {}, {}>,
routeHandler,
savedObjectsRepositoryMock,
usageStatsClient,
};
};
it('records usageStats data', async () => {
const payload = {
aliases: [{ targetSpace: 'space-1', targetType: 'type-1', sourceId: 'id-1' }],
};
const { routeHandler, usageStatsClient } = await setup();
const request = httpServerMock.createKibanaRequest({
body: payload,
method: 'post',
});
await routeHandler(mockRouteContext, request, kibanaResponseFactory);
expect(usageStatsClient.incrementDisableLegacyUrlAliases).toHaveBeenCalled();
});
it('should disable the provided aliases', async () => {
const payload = {
aliases: [{ targetSpace: 'space-1', targetType: 'type-1', sourceId: 'id-1' }],
};
const { routeHandler, savedObjectsRepositoryMock } = await setup();
const request = httpServerMock.createKibanaRequest({
body: payload,
method: 'post',
});
const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory);
const { status } = response;
expect(status).toEqual(204);
expect(savedObjectsRepositoryMock.bulkUpdate).toHaveBeenCalledTimes(1);
expect(savedObjectsRepositoryMock.bulkUpdate).toHaveBeenCalledWith([
{ type: 'legacy-url-alias', id: 'space-1:type-1:id-1', attributes: { disabled: true } },
]);
});
it(`returns http/403 when the license is invalid`, async () => {
const { routeHandler } = await setup();
const request = httpServerMock.createKibanaRequest({
method: 'post',
});
const response = await routeHandler(
mockRouteContextWithInvalidLicense,
request,
kibanaResponseFactory
);
expect(response.status).toEqual(403);
expect(response.payload).toEqual({
message: 'License is invalid for spaces',
});
});
});

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import { wrapError } from '../../../lib/errors';
import { createLicensedRouteHandler } from '../../lib';
import type { ExternalRouteDeps } from './';
export function initDisableLegacyUrlAliasesApi(deps: ExternalRouteDeps) {
const { externalRouter, getSpacesService, usageStatsServicePromise } = deps;
const usageStatsClientPromise = usageStatsServicePromise.then(({ getClient }) => getClient());
externalRouter.post(
{
path: '/api/spaces/_disable_legacy_url_aliases',
validate: {
body: schema.object({
aliases: schema.arrayOf(
schema.object({
targetSpace: schema.string(),
targetType: schema.string(),
sourceId: schema.string(),
})
),
}),
},
},
createLicensedRouteHandler(async (_context, request, response) => {
const spacesClient = getSpacesService().createSpacesClient(request);
const { aliases } = request.body;
usageStatsClientPromise.then((usageStatsClient) =>
usageStatsClient.incrementDisableLegacyUrlAliases()
);
try {
await spacesClient.disableLegacyUrlAliases(aliases);
return response.noContent();
} catch (error) {
return response.customError(wrapError(error));
}
})
);
}

Some files were not shown because too many files have changed in this diff Show more