mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com>
This commit is contained in:
parent
1e066f2615
commit
06313f3b94
118 changed files with 2792 additions and 640 deletions
|
@ -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. |
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) > [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'];
|
||||
```
|
|
@ -0,0 +1,22 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [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<T></code> | The saved object that was found. |
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) > [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'];
|
||||
```
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) > [savedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.savedobject.md)
|
||||
|
||||
## ResolvedSimpleSavedObject.savedObject property
|
||||
|
||||
The saved object that was found.
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
savedObject: SimpleSavedObject<T>;
|
||||
```
|
|
@ -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. |
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 | undefined) => ReturnType<SavedObjectsApi['delete']></code> | Deletes an object |
|
||||
| [find](./kibana-plugin-core-public.savedobjectsclient.find.md) | | <code><T = unknown, A = unknown>(options: SavedObjectsFindOptions) => Promise<SavedObjectsFindResponsePublic<T, unknown>></code> | Search for objects |
|
||||
| [get](./kibana-plugin-core-public.savedobjectsclient.get.md) | | <code><T = unknown>(type: string, id: string) => Promise<SimpleSavedObject<T>></code> | Fetches a single object |
|
||||
| [resolve](./kibana-plugin-core-public.savedobjectsclient.resolve.md) | | <code><T = unknown>(type: string, id: string) => Promise<ResolvedSimpleSavedObject<T>></code> | Resolves a single object |
|
||||
|
||||
## Methods
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) > [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>>;
|
||||
```
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) > [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;
|
||||
```
|
|
@ -0,0 +1,21 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [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' | 'aliasMatch' | 'conflict'</code> | The outcome for a successful <code>resolve</code> call is one of the following values:<!-- -->\* <code>'exactMatch'</code> -- One document exactly matched the given ID. \* <code>'aliasMatch'</code> -- One document with a legacy URL alias matched the given ID; in this case the <code>saved_object.id</code> field is different than the given ID. \* <code>'conflict'</code> -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the <code>saved_object</code> object is the exact match, and the <code>saved_object.id</code> field is the same as the given ID. |
|
||||
| [saved\_object](./kibana-plugin-core-public.savedobjectsresolveresponse.saved_object.md) | <code>SavedObject<T></code> | The saved object that was found. |
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) > [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';
|
||||
```
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) > [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>;
|
||||
```
|
|
@ -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<T></code> | |
|
||||
| { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, } | <code>SavedObjectType<T></code> | |
|
||||
|
||||
|
|
|
@ -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<T>['error']</code> | |
|
||||
| [id](./kibana-plugin-core-public.simplesavedobject.id.md) | | <code>SavedObjectType<T>['id']</code> | |
|
||||
| [migrationVersion](./kibana-plugin-core-public.simplesavedobject.migrationversion.md) | | <code>SavedObjectType<T>['migrationVersion']</code> | |
|
||||
| [namespaces](./kibana-plugin-core-public.simplesavedobject.namespaces.md) | | <code>SavedObjectType<T>['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<T>['references']</code> | |
|
||||
| [type](./kibana-plugin-core-public.simplesavedobject.type.md) | | <code>SavedObjectType<T>['type']</code> | |
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SimpleSavedObject](./kibana-plugin-core-public.simplesavedobject.md) > [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'];
|
||||
```
|
|
@ -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. |
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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' | 'aliasMatch' | 'conflict'</code> | The outcome for a successful <code>resolve</code> call is one of the following values:<!-- -->\* <code>'exactMatch'</code> -- One document exactly matched the given ID. \* <code>'aliasMatch'</code> -- One document with a legacy URL alias matched the given ID; in this case the <code>saved_object.id</code> field is different than the given ID. \* <code>'conflict'</code> -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the <code>saved_object</code> object is the exact match, and the <code>saved_object.id</code> field is the same as the given ID. |
|
||||
| [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | <code>SavedObject<T></code> | |
|
||||
| [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | <code>SavedObject<T></code> | The saved object that was found. |
|
||||
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
## SavedObjectsResolveResponse.saved\_object property
|
||||
|
||||
The saved object that was found.
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({});
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -18,6 +18,7 @@ const createStartContractMock = () => {
|
|||
bulkGet: jest.fn(),
|
||||
find: jest.fn(),
|
||||
get: jest.fn(),
|
||||
resolve: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
37
src/core/public/saved_objects/types.ts
Normal file
37
src/core/public/saved_objects/types.ts
Normal 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'];
|
||||
}
|
|
@ -139,6 +139,12 @@ const createStartContractMock = () => {
|
|||
storeSizeBytes: 1,
|
||||
},
|
||||
],
|
||||
legacyUrlAliases: {
|
||||
inactiveCount: 1,
|
||||
activeCount: 1,
|
||||
disabledCount: 1,
|
||||
totalCount: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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_\-]+)/;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) }
|
||||
);
|
||||
}
|
|
@ -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)}`
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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."`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
@ -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 });
|
||||
})
|
||||
);
|
||||
};
|
|
@ -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)
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
}));
|
||||
});
|
||||
}
|
|
@ -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));
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -94,6 +94,7 @@ export interface AuthorizationServiceSetup {
|
|||
actions: Actions;
|
||||
checkPrivilegesWithRequest: CheckPrivilegesWithRequest;
|
||||
checkPrivilegesDynamicallyWithRequest: CheckPrivilegesDynamicallyWithRequest;
|
||||
checkSavedObjectsPrivilegesWithRequest: CheckSavedObjectsPrivilegesWithRequest;
|
||||
mode: AuthorizationMode;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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[],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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 {};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
.euiCheckableCard__children {
|
||||
width: 100%; // required to expand the contents of EuiCheckableCard to the full width
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
.spcShareToSpace__flyoutBodyWrapper {
|
||||
padding: $euiSizeL;
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 & 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 & close"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
<EuiFlyoutFooter>{getFlyoutFooter()}</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -35,6 +35,7 @@ export const createMockSavedObjectsRepository = (spaces: any[] = []) => {
|
|||
}
|
||||
return {};
|
||||
}),
|
||||
bulkUpdate: jest.fn(),
|
||||
delete: jest.fn((type: string, id: string) => {
|
||||
return {};
|
||||
}),
|
||||
|
|
143
x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.test.ts
vendored
Normal file
143
x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.test.ts
vendored
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
50
x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.ts
vendored
Normal file
50
x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.ts
vendored
Normal 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
Loading…
Add table
Add a link
Reference in a new issue