diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c179dbadac533..915f0f799b210 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -196,12 +196,15 @@ # Platform /src/core/ @elastic/kibana-platform +/src/plugins/saved_objects_tagging_oss @elastic/kibana-platform /config/kibana.yml @elastic/kibana-platform /x-pack/plugins/features/ @elastic/kibana-platform /x-pack/plugins/licensing/ @elastic/kibana-platform /x-pack/plugins/global_search/ @elastic/kibana-platform /x-pack/plugins/cloud/ @elastic/kibana-platform +/x-pack/plugins/saved_objects_tagging/ @elastic/kibana-platform /x-pack/test/saved_objects_field_count/ @elastic/kibana-platform +/x-pack/test/saved_object_tagging/ @elastic/kibana-platform /packages/kbn-config-schema/ @elastic/kibana-platform /packages/kbn-std/ @elastic/kibana-platform /src/legacy/server/config/ @elastic/kibana-platform diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index b67a4c95fd0ad..9a32f3b3adb3c 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -164,6 +164,11 @@ Content is fetched from the remote (https://feeds.elastic.co and https://feeds-s |WARNING: Missing README. +|{kib-repo}blob/{branch}/src/plugins/saved_objects_tagging_oss/README.md[savedObjectsTaggingOss] +|Bridge plugin for consumption of the saved object tagging feature from +oss plugins. + + |{kib-repo}blob/{branch}/src/plugins/security_oss/README.md[securityOss] |securityOss is responsible for educating users about Elastic's free security features, so they can properly protect the data within their clusters. @@ -466,6 +471,10 @@ Elastic. |Welcome to the Kibana rollup plugin! This plugin provides Kibana support for Elasticsearch's rollup feature. Please refer to the Elasticsearch documentation to understand rollup indices and how to create rollup jobs. +|{kib-repo}blob/{branch}/x-pack/plugins/saved_objects_tagging/README.md[savedObjectsTagging] +|Add tagging capability to saved objects + + |{kib-repo}blob/{branch}/x-pack/plugins/searchprofiler/README.md[searchprofiler] |The search profiler consumes the Profile API by sending a search API with profile: true enabled in the request body. The response contains diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 85ef00d271415..b8b1bdcdee3be 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -98,6 +98,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsBulkUpdateOptions](./kibana-plugin-core-public.savedobjectsbulkupdateoptions.md) | | | [SavedObjectsCreateOptions](./kibana-plugin-core-public.savedobjectscreateoptions.md) | | | [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) | | +| [SavedObjectsFindOptionsReference](./kibana-plugin-core-public.savedobjectsfindoptionsreference.md) | | | [SavedObjectsFindResponsePublic](./kibana-plugin-core-public.savedobjectsfindresponsepublic.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | | [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md) | Represents a failure to import due to a conflict, which can be resolved in different ways with an overwrite. | | [SavedObjectsImportConflictError](./kibana-plugin-core-public.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.defaultsearchoperator.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.defaultsearchoperator.md index 8cf717365db39..f1c2fd08a21f1 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.defaultsearchoperator.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.defaultsearchoperator.md @@ -4,6 +4,8 @@ ## SavedObjectsFindOptions.defaultSearchOperator property +The search operator to use with the provided filter. Defaults to `OR` + Signature: ```typescript diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.hasreference.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.hasreference.md index 98f594b63f024..25ce8fa7b6018 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.hasreference.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.hasreference.md @@ -4,11 +4,10 @@ ## SavedObjectsFindOptions.hasReference property +Search for documents having a reference to the specified objects. Use `hasReferenceOperator` to specify the operator to use when searching for multiple references. + Signature: ```typescript -hasReference?: { - type: string; - id: string; - }; +hasReference?: SavedObjectsFindOptionsReference | SavedObjectsFindOptionsReference[]; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.hasreferenceoperator.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.hasreferenceoperator.md new file mode 100644 index 0000000000000..3681d1c9d34d9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.hasreferenceoperator.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [hasReferenceOperator](./kibana-plugin-core-public.savedobjectsfindoptions.hasreferenceoperator.md) + +## SavedObjectsFindOptions.hasReferenceOperator property + +The operator to use when searching by multiple references using the `hasReference` option. Defaults to `OR` + +Signature: + +```typescript +hasReferenceOperator?: 'AND' | 'OR'; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md index 470a41f30afbf..8bd87c2f6ea35 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md @@ -15,10 +15,11 @@ export interface SavedObjectsFindOptions | Property | Type | Description | | --- | --- | --- | -| [defaultSearchOperator](./kibana-plugin-core-public.savedobjectsfindoptions.defaultsearchoperator.md) | 'AND' | 'OR' | | +| [defaultSearchOperator](./kibana-plugin-core-public.savedobjectsfindoptions.defaultsearchoperator.md) | 'AND' | 'OR' | The search operator to use with the provided filter. Defaults to OR | | [fields](./kibana-plugin-core-public.savedobjectsfindoptions.fields.md) | string[] | An array of fields to include in the results | | [filter](./kibana-plugin-core-public.savedobjectsfindoptions.filter.md) | string | KueryNode | | -| [hasReference](./kibana-plugin-core-public.savedobjectsfindoptions.hasreference.md) | {
type: string;
id: string;
} | | +| [hasReference](./kibana-plugin-core-public.savedobjectsfindoptions.hasreference.md) | SavedObjectsFindOptionsReference | SavedObjectsFindOptionsReference[] | Search for documents having a reference to the specified objects. Use hasReferenceOperator to specify the operator to use when searching for multiple references. | +| [hasReferenceOperator](./kibana-plugin-core-public.savedobjectsfindoptions.hasreferenceoperator.md) | 'AND' | 'OR' | The operator to use when searching by multiple references using the hasReference option. Defaults to OR | | [namespaces](./kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md) | string[] | | | [page](./kibana-plugin-core-public.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-public.savedobjectsfindoptions.perpage.md) | number | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptionsreference.id.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptionsreference.id.md new file mode 100644 index 0000000000000..5e4c8dd982a0f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptionsreference.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptionsReference](./kibana-plugin-core-public.savedobjectsfindoptionsreference.md) > [id](./kibana-plugin-core-public.savedobjectsfindoptionsreference.id.md) + +## SavedObjectsFindOptionsReference.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptionsreference.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptionsreference.md new file mode 100644 index 0000000000000..cdfefd01e6f83 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptionsreference.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptionsReference](./kibana-plugin-core-public.savedobjectsfindoptionsreference.md) + +## SavedObjectsFindOptionsReference interface + + +Signature: + +```typescript +export interface SavedObjectsFindOptionsReference +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-public.savedobjectsfindoptionsreference.id.md) | string | | +| [type](./kibana-plugin-core-public.savedobjectsfindoptionsreference.type.md) | string | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptionsreference.type.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptionsreference.type.md new file mode 100644 index 0000000000000..3779bfd204a4b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptionsreference.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptionsReference](./kibana-plugin-core-public.savedobjectsfindoptionsreference.md) > [type](./kibana-plugin-core-public.savedobjectsfindoptionsreference.type.md) + +## SavedObjectsFindOptionsReference.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.exportsavedobjectstostream.md b/docs/development/core/server/kibana-plugin-core-server.exportsavedobjectstostream.md index b17984400c248..f8b5eb3b35393 100644 --- a/docs/development/core/server/kibana-plugin-core-server.exportsavedobjectstostream.md +++ b/docs/development/core/server/kibana-plugin-core-server.exportsavedobjectstostream.md @@ -9,14 +9,14 @@ Generates sorted saved object stream to be used for export. See the [options](./ Signature: ```typescript -export declare function exportSavedObjectsToStream({ types, objects, search, savedObjectsClient, exportSizeLimit, includeReferencesDeep, excludeExportDetails, namespace, }: SavedObjectsExportOptions): Promise; +export declare function exportSavedObjectsToStream({ types, hasReference, objects, search, savedObjectsClient, exportSizeLimit, includeReferencesDeep, excludeExportDetails, namespace, }: SavedObjectsExportOptions): Promise; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { types, objects, search, savedObjectsClient, exportSizeLimit, includeReferencesDeep, excludeExportDetails, namespace, } | SavedObjectsExportOptions | | +| { types, hasReference, objects, search, savedObjectsClient, exportSizeLimit, includeReferencesDeep, excludeExportDetails, namespace, } | SavedObjectsExportOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 29f5220794918..68f5e72915556 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -42,7 +42,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | Function | Description | | --- | --- | -| [exportSavedObjectsToStream({ types, objects, search, savedObjectsClient, exportSizeLimit, includeReferencesDeep, excludeExportDetails, namespace, })](./kibana-plugin-core-server.exportsavedobjectstostream.md) | Generates sorted saved object stream to be used for export. See the [options](./kibana-plugin-core-server.savedobjectsexportoptions.md) for more detailed information. | +| [exportSavedObjectsToStream({ types, hasReference, objects, search, savedObjectsClient, exportSizeLimit, includeReferencesDeep, excludeExportDetails, namespace, })](./kibana-plugin-core-server.exportsavedobjectstostream.md) | Generates sorted saved object stream to be used for export. See the [options](./kibana-plugin-core-server.savedobjectsexportoptions.md) for more detailed information. | | [importSavedObjectsFromStream({ readStream, objectLimit, overwrite, createNewCopies, savedObjectsClient, typeRegistry, namespace, })](./kibana-plugin-core-server.importsavedobjectsfromstream.md) | Import saved objects from given stream. See the [options](./kibana-plugin-core-server.savedobjectsimportoptions.md) for more detailed information. | | [resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, typeRegistry, namespace, createNewCopies, })](./kibana-plugin-core-server.resolvesavedobjectsimporterrors.md) | Resolve and return saved object import errors. See the [options](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) for more detailed informations. | @@ -163,6 +163,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsExportOptions](./kibana-plugin-core-server.savedobjectsexportoptions.md) | Options controlling the export operation. | | [SavedObjectsExportResultDetails](./kibana-plugin-core-server.savedobjectsexportresultdetails.md) | Structure of the export result details entry | | [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) | | +| [SavedObjectsFindOptionsReference](./kibana-plugin-core-server.savedobjectsfindoptionsreference.md) | | | [SavedObjectsFindResponse](./kibana-plugin-core-server.savedobjectsfindresponse.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | | [SavedObjectsFindResult](./kibana-plugin-core-server.savedobjectsfindresult.md) | | | [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md) | Represents a failure to import due to a conflict, which can be resolved in different ways with an overwrite. | @@ -180,6 +181,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsMigrationLogger](./kibana-plugin-core-server.savedobjectsmigrationlogger.md) | | | [SavedObjectsMigrationVersion](./kibana-plugin-core-server.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. | | [SavedObjectsRawDoc](./kibana-plugin-core-server.savedobjectsrawdoc.md) | A raw document as represented directly in the saved object index. | +| [SavedObjectsRemoveReferencesToOptions](./kibana-plugin-core-server.savedobjectsremovereferencestooptions.md) | | +| [SavedObjectsRemoveReferencesToResponse](./kibana-plugin-core-server.savedobjectsremovereferencestoresponse.md) | | | [SavedObjectsRepositoryFactory](./kibana-plugin-core-server.savedobjectsrepositoryfactory.md) | Factory provided when invoking a [client factory provider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) See [SavedObjectsServiceSetup.setClientFactoryProvider](./kibana-plugin-core-server.savedobjectsservicesetup.setclientfactoryprovider.md) | | [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) | Options to control the "resolve import" operation. | | [SavedObjectsServiceSetup](./kibana-plugin-core-server.savedobjectsservicesetup.md) | Saved Objects is Kibana's data persistence mechanism allowing plugins to use Elasticsearch for storing and querying state. The SavedObjectsServiceSetup API exposes methods for registering Saved Object types, creating and registering Saved Object client wrappers and factories. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md index 7c1273e63d24b..7fb34631c736e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md @@ -35,5 +35,6 @@ The constructor for this class is marked as internal. Third-party code should no | [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md) | | Removes namespaces from a SavedObject | | [find(options)](./kibana-plugin-core-server.savedobjectsclient.find.md) | | Find all SavedObjects matching the search query | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.get.md) | | Retrieves a single object | +| [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.update.md) | | Updates an SavedObject | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.removereferencesto.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.removereferencesto.md new file mode 100644 index 0000000000000..002992a17c313 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.removereferencesto.md @@ -0,0 +1,26 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [removeReferencesTo](./kibana-plugin-core-server.savedobjectsclient.removereferencesto.md) + +## SavedObjectsClient.removeReferencesTo() method + +Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. + +Signature: + +```typescript +removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | +| options | SavedObjectsRemoveReferencesToOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md index 8e04282ce0c71..97d33c3060bb0 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md @@ -7,7 +7,7 @@ Signature: ```typescript -static createConflictError(type: string, id: string): DecoratedError; +static createConflictError(type: string, id: string, reason?: string): DecoratedError; ``` ## Parameters @@ -16,6 +16,7 @@ static createConflictError(type: string, id: string): DecoratedError; | --- | --- | --- | | type | string | | | id | string | | +| reason | string | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md index a2eff4dd99ea5..9b69012ed5f12 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md @@ -16,7 +16,7 @@ export declare class SavedObjectsErrorHelpers | Method | Modifiers | Description | | --- | --- | --- | | [createBadRequestError(reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.createbadrequesterror.md) | static | | -| [createConflictError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md) | static | | +| [createConflictError(type, id, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md) | static | | | [createGenericNotFoundError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.creategenericnotfounderror.md) | static | | | [createInvalidVersionError(versionInput)](./kibana-plugin-core-server.savedobjectserrorhelpers.createinvalidversionerror.md) | static | | | [createTooManyRequestsError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.createtoomanyrequestserror.md) | static | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportoptions.hasreference.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportoptions.hasreference.md new file mode 100644 index 0000000000000..9ea9fb2e7fba2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportoptions.hasreference.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsExportOptions](./kibana-plugin-core-server.savedobjectsexportoptions.md) > [hasReference](./kibana-plugin-core-server.savedobjectsexportoptions.hasreference.md) + +## SavedObjectsExportOptions.hasReference property + +optional array of references to search object for when exporting by types + +Signature: + +```typescript +hasReference?: SavedObjectsFindOptionsReference[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportoptions.md index 5e93dca53847d..b1b51a123696c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportoptions.md @@ -18,6 +18,7 @@ export interface SavedObjectsExportOptions | --- | --- | --- | | [excludeExportDetails](./kibana-plugin-core-server.savedobjectsexportoptions.excludeexportdetails.md) | boolean | flag to not append [export details](./kibana-plugin-core-server.savedobjectsexportresultdetails.md) to the end of the export stream. | | [exportSizeLimit](./kibana-plugin-core-server.savedobjectsexportoptions.exportsizelimit.md) | number | the maximum number of objects to export. | +| [hasReference](./kibana-plugin-core-server.savedobjectsexportoptions.hasreference.md) | SavedObjectsFindOptionsReference[] | optional array of references to search object for when exporting by types | | [includeReferencesDeep](./kibana-plugin-core-server.savedobjectsexportoptions.includereferencesdeep.md) | boolean | flag to also include all related saved objects in the export stream. | | [namespace](./kibana-plugin-core-server.savedobjectsexportoptions.namespace.md) | string | optional namespace to override the namespace used by the savedObjectsClient. | | [objects](./kibana-plugin-core-server.savedobjectsexportoptions.objects.md) | Array<{
id: string;
type: string;
}> | optional array of objects to export. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.defaultsearchoperator.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.defaultsearchoperator.md index 030bf86e1c9c5..b716ed43948e4 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.defaultsearchoperator.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.defaultsearchoperator.md @@ -4,6 +4,8 @@ ## SavedObjectsFindOptions.defaultSearchOperator property +The search operator to use with the provided filter. Defaults to `OR` + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.hasreference.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.hasreference.md index 3b1fbd8901b68..dea3d55950789 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.hasreference.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.hasreference.md @@ -4,11 +4,10 @@ ## SavedObjectsFindOptions.hasReference property +Search for documents having a reference to the specified objects. Use `hasReferenceOperator` to specify the operator to use when searching for multiple references. + Signature: ```typescript -hasReference?: { - type: string; - id: string; - }; +hasReference?: SavedObjectsFindOptionsReference | SavedObjectsFindOptionsReference[]; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.hasreferenceoperator.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.hasreferenceoperator.md new file mode 100644 index 0000000000000..2c06f76d5c736 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.hasreferenceoperator.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [hasReferenceOperator](./kibana-plugin-core-server.savedobjectsfindoptions.hasreferenceoperator.md) + +## SavedObjectsFindOptions.hasReferenceOperator property + +The operator to use when searching by multiple references using the `hasReference` option. Defaults to `OR` + +Signature: + +```typescript +hasReferenceOperator?: 'AND' | 'OR'; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md index ce5c20e60ca11..d393d579dbdd2 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md @@ -15,10 +15,11 @@ export interface SavedObjectsFindOptions | Property | Type | Description | | --- | --- | --- | -| [defaultSearchOperator](./kibana-plugin-core-server.savedobjectsfindoptions.defaultsearchoperator.md) | 'AND' | 'OR' | | +| [defaultSearchOperator](./kibana-plugin-core-server.savedobjectsfindoptions.defaultsearchoperator.md) | 'AND' | 'OR' | The search operator to use with the provided filter. Defaults to OR | | [fields](./kibana-plugin-core-server.savedobjectsfindoptions.fields.md) | string[] | An array of fields to include in the results | | [filter](./kibana-plugin-core-server.savedobjectsfindoptions.filter.md) | string | KueryNode | | -| [hasReference](./kibana-plugin-core-server.savedobjectsfindoptions.hasreference.md) | {
type: string;
id: string;
} | | +| [hasReference](./kibana-plugin-core-server.savedobjectsfindoptions.hasreference.md) | SavedObjectsFindOptionsReference | SavedObjectsFindOptionsReference[] | Search for documents having a reference to the specified objects. Use hasReferenceOperator to specify the operator to use when searching for multiple references. | +| [hasReferenceOperator](./kibana-plugin-core-server.savedobjectsfindoptions.hasreferenceoperator.md) | 'AND' | 'OR' | The operator to use when searching by multiple references using the hasReference option. Defaults to OR | | [namespaces](./kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md) | string[] | | | [page](./kibana-plugin-core-server.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-server.savedobjectsfindoptions.perpage.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptionsreference.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptionsreference.id.md new file mode 100644 index 0000000000000..6d5b76d685680 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptionsreference.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptionsReference](./kibana-plugin-core-server.savedobjectsfindoptionsreference.md) > [id](./kibana-plugin-core-server.savedobjectsfindoptionsreference.id.md) + +## SavedObjectsFindOptionsReference.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptionsreference.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptionsreference.md new file mode 100644 index 0000000000000..db04ef7b162a0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptionsreference.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptionsReference](./kibana-plugin-core-server.savedobjectsfindoptionsreference.md) + +## SavedObjectsFindOptionsReference interface + + +Signature: + +```typescript +export interface SavedObjectsFindOptionsReference +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-server.savedobjectsfindoptionsreference.id.md) | string | | +| [type](./kibana-plugin-core-server.savedobjectsfindoptionsreference.type.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptionsreference.type.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptionsreference.type.md new file mode 100644 index 0000000000000..0d7db3d72a457 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptionsreference.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptionsReference](./kibana-plugin-core-server.savedobjectsfindoptionsreference.md) > [type](./kibana-plugin-core-server.savedobjectsfindoptionsreference.type.md) + +## SavedObjectsFindOptionsReference.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsremovereferencestooptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsremovereferencestooptions.md new file mode 100644 index 0000000000000..0874aa460e220 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsremovereferencestooptions.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRemoveReferencesToOptions](./kibana-plugin-core-server.savedobjectsremovereferencestooptions.md) + +## SavedObjectsRemoveReferencesToOptions interface + + +Signature: + +```typescript +export interface SavedObjectsRemoveReferencesToOptions extends SavedObjectsBaseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [refresh](./kibana-plugin-core-server.savedobjectsremovereferencestooptions.refresh.md) | boolean | The Elasticsearch Refresh setting for this operation. Defaults to true | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsremovereferencestooptions.refresh.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsremovereferencestooptions.refresh.md new file mode 100644 index 0000000000000..71e924a8af5e1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsremovereferencestooptions.refresh.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRemoveReferencesToOptions](./kibana-plugin-core-server.savedobjectsremovereferencestooptions.md) > [refresh](./kibana-plugin-core-server.savedobjectsremovereferencestooptions.refresh.md) + +## SavedObjectsRemoveReferencesToOptions.refresh property + +The Elasticsearch Refresh setting for this operation. Defaults to `true` + +Signature: + +```typescript +refresh?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsremovereferencestoresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsremovereferencestoresponse.md new file mode 100644 index 0000000000000..b5468a300d51d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsremovereferencestoresponse.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRemoveReferencesToResponse](./kibana-plugin-core-server.savedobjectsremovereferencestoresponse.md) + +## SavedObjectsRemoveReferencesToResponse interface + + +Signature: + +```typescript +export interface SavedObjectsRemoveReferencesToResponse extends SavedObjectsBaseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [updated](./kibana-plugin-core-server.savedobjectsremovereferencestoresponse.updated.md) | number | The number of objects that have been updated by this operation | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsremovereferencestoresponse.updated.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsremovereferencestoresponse.updated.md new file mode 100644 index 0000000000000..67c3721ccdc68 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsremovereferencestoresponse.updated.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRemoveReferencesToResponse](./kibana-plugin-core-server.savedobjectsremovereferencestoresponse.md) > [updated](./kibana-plugin-core-server.savedobjectsremovereferencestoresponse.updated.md) + +## SavedObjectsRemoveReferencesToResponse.updated property + +The number of objects that have been updated by this operation + +Signature: + +```typescript +updated: number; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index 1d11d5262a9c4..6a56f0bee718b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -27,5 +27,6 @@ export declare class SavedObjectsRepository | [find(options)](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object | | [incrementCounter(type, id, counterFieldName, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increases a counter field by one. Creates the document if one doesn't exist for the given id. | +| [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.removereferencesto.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.removereferencesto.md new file mode 100644 index 0000000000000..ff05926360938 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.removereferencesto.md @@ -0,0 +1,30 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [removeReferencesTo](./kibana-plugin-core-server.savedobjectsrepository.removereferencesto.md) + +## SavedObjectsRepository.removeReferencesTo() method + +Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. + +Signature: + +```typescript +removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | +| options | SavedObjectsRemoveReferencesToOptions | | + +Returns: + +`Promise` + +## Remarks + +Will throw a conflict error if the `update_by_query` operation returns any failure. In that case some references might have been removed, and some were not. It is the caller's responsibility to handle and fix this situation if it was to happen. + diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md index 215eac9829451..660644ae73255 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md @@ -12,7 +12,7 @@ start(core: CoreStart): { fieldFormatServiceFactory: (uiSettings: import("../../../core/server").IUiSettingsClient) => Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick) => Promise; }; search: ISearchStart>; }; @@ -31,7 +31,7 @@ start(core: CoreStart): { fieldFormatServiceFactory: (uiSettings: import("../../../core/server").IUiSettingsClient) => Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick) => Promise; }; search: ISearchStart>; }` diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index d4370c4d840c0..bacd93f585adc 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -104,6 +104,11 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is removing a saved object from other spaces. | `failure` | User is not authorized to remove a saved object from other spaces. +.2+| `saved_object_remove_references` +| `unknown` | User is removing references to a saved object. +| `failure` | User is not authorized to remove references to a saved object. + + 3+a| ====== Type: deletion diff --git a/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts index 79e758f09ccf0..3599911735b8d 100644 --- a/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts +++ b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts @@ -90,6 +90,8 @@ async function fetchKibanaIndices(client: Client) { return kibanaIndices.map((x: { index: string }) => x.index).filter(isKibanaIndex); } +const delay = (delayInMs: number) => new Promise((resolve) => setTimeout(resolve, delayInMs)); + export async function cleanKibanaIndices({ client, stats, @@ -132,6 +134,7 @@ export async function cleanKibanaIndices({ resp.deleted, resp.total ); + await delay(200); continue; } diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 770bb4f510301..59823803ec33b 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -64,7 +64,9 @@ pageLoadAssetSize: reporting: 183418 rollup: 97204 savedObjects: 108518 - savedObjectsManagement: 100503 + savedObjectsManagement: 101836 + savedObjectsTagging: 59482 + savedObjectsTaggingOss: 20590 searchprofiler: 67080 security: 189428 securityOss: 30806 diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 24d19e2d32074..1393e69d55e51 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -131,6 +131,7 @@ export { SavedObjectReference, SavedObjectsBaseOptions, SavedObjectsFindOptions, + SavedObjectsFindOptionsReference, SavedObjectsMigrationVersion, SavedObjectsClientContract, SavedObjectsClient, diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 8ed415c09806c..1263d160f6b78 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -97,7 +97,7 @@ function createCoreStartMock({ basePath = '' } = {}) { return mock; } -function pluginInitializerContextMock() { +function pluginInitializerContextMock(config: any = {}) { const mock: PluginInitializerContext = { opaqueId: Symbol(), env: { @@ -115,7 +115,7 @@ function pluginInitializerContextMock() { }, }, config: { - get: () => ({} as T), + get: () => config as T, }, }; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index da4a7f446add7..fd2d943cab9d2 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1056,18 +1056,14 @@ export interface SavedObjectsCreateOptions { // @public (undocumented) export interface SavedObjectsFindOptions { - // (undocumented) defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; // Warning: (ae-forgotten-export) The symbol "KueryNode" needs to be exported by the entry point index.d.ts // // (undocumented) filter?: string | KueryNode; - // (undocumented) - hasReference?: { - type: string; - id: string; - }; + hasReference?: SavedObjectsFindOptionsReference | SavedObjectsFindOptionsReference[]; + hasReferenceOperator?: 'AND' | 'OR'; // (undocumented) namespaces?: string[]; // (undocumented) @@ -1087,6 +1083,14 @@ export interface SavedObjectsFindOptions { typeToNamespacesMap?: Map; } +// @public (undocumented) +export interface SavedObjectsFindOptionsReference { + // (undocumented) + id: string; + // (undocumented) + type: string; +} + // @public export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse { // (undocumented) diff --git a/src/core/public/saved_objects/index.ts b/src/core/public/saved_objects/index.ts index ef7b23448ad6f..cc8fce0884ddf 100644 --- a/src/core/public/saved_objects/index.ts +++ b/src/core/public/saved_objects/index.ts @@ -34,6 +34,7 @@ export { SavedObjectsStart, SavedObjectsService } from './saved_objects_service' export { SavedObjectsBaseOptions, SavedObjectsFindOptions, + SavedObjectsFindOptionsReference, SavedObjectsMigrationVersion, SavedObjectsImportResponse, SavedObjectsImportSuccess, diff --git a/src/core/public/saved_objects/saved_objects_client.test.ts b/src/core/public/saved_objects/saved_objects_client.test.ts index fab651379ea6a..d5d97aded7bd0 100644 --- a/src/core/public/saved_objects/saved_objects_client.test.ts +++ b/src/core/public/saved_objects/saved_objects_client.test.ts @@ -407,10 +407,7 @@ describe('SavedObjectsClient', () => { "fields": Array [ "title", ], - "has_reference": Object { - "id": "1", - "type": "reference", - }, + "has_reference": "{\\"id\\":\\"1\\",\\"type\\":\\"reference\\"}", "page": 10, "per_page": 100, "search": "what is the meaning of life?|life", diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index beed3e6fe0a18..3169dad31e2a8 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -305,6 +305,7 @@ export class SavedObjectsClient { defaultSearchOperator: 'default_search_operator', fields: 'fields', hasReference: 'has_reference', + hasReferenceOperator: 'has_reference_operator', page: 'page', perPage: 'per_page', search: 'search', @@ -317,7 +318,16 @@ export class SavedObjectsClient { }; const renamedQuery = renameKeys(renameMap, options); - const query = pick.apply(null, [renamedQuery, ...Object.values(renameMap)]); + const query = pick.apply(null, [renamedQuery, ...Object.values(renameMap)]) as Record< + string, + any + >; + + // `has_references` is a structured object. we need to stringify it before sending it, as `fetch` + // is not doing it implicitly. + if (query.has_reference) { + query.has_reference = JSON.stringify(query.has_reference); + } const request: ReturnType = this.savedObjectsFetch(path, { method: 'GET', diff --git a/src/core/server/index.ts b/src/core/server/index.ts index efb196590ea97..0adda4770639d 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -284,6 +284,8 @@ export { SavedObjectsAddToNamespacesResponse, SavedObjectsDeleteFromNamespacesOptions, SavedObjectsDeleteFromNamespacesResponse, + SavedObjectsRemoveReferencesToOptions, + SavedObjectsRemoveReferencesToResponse, SavedObjectsServiceStart, SavedObjectsServiceSetup, SavedObjectStatusMeta, @@ -347,6 +349,7 @@ export { MutatingOperationRefreshSetting, SavedObjectsClientContract, SavedObjectsFindOptions, + SavedObjectsFindOptionsReference, SavedObjectsMigrationVersion, } from './types'; diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts index c084125f43127..c26467f4b931c 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts @@ -107,6 +107,8 @@ describe('getSortedObjectsForExport()', () => { "calls": Array [ Array [ Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, "namespaces": undefined, "perPage": 500, "search": undefined, @@ -197,6 +199,8 @@ describe('getSortedObjectsForExport()', () => { "calls": Array [ Array [ Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, "namespaces": undefined, "perPage": 500, "search": undefined, @@ -347,6 +351,8 @@ describe('getSortedObjectsForExport()', () => { "calls": Array [ Array [ Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, "namespaces": undefined, "perPage": 500, "search": "foo", @@ -367,6 +373,94 @@ describe('getSortedObjectsForExport()', () => { `); }); + test('exports selected types with references when present', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + saved_objects: [ + { + id: '2', + type: 'search', + attributes: {}, + score: 1, + references: [ + { + name: 'name', + type: 'index-pattern', + id: '1', + }, + ], + }, + ], + per_page: 1, + page: 0, + }); + const exportStream = await exportSavedObjectsToStream({ + savedObjectsClient, + exportSizeLimit: 500, + types: ['index-pattern', 'search'], + hasReference: [ + { + id: '1', + type: 'index-pattern', + }, + ], + }); + + const response = await readStreamToCompletion(exportStream); + + expect(response).toMatchInlineSnapshot(` + Array [ + Object { + "attributes": Object {}, + "id": "2", + "references": Array [ + Object { + "id": "1", + "name": "name", + "type": "index-pattern", + }, + ], + "type": "search", + }, + Object { + "exportedCount": 1, + "missingRefCount": 0, + "missingReferences": Array [], + }, + ] + `); + expect(savedObjectsClient.find).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": Array [ + Object { + "id": "1", + "type": "index-pattern", + }, + ], + "hasReferenceOperator": "OR", + "namespaces": undefined, + "perPage": 500, + "search": undefined, + "type": Array [ + "index-pattern", + "search", + ], + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); + }); + test('exports from the provided namespace when present', async () => { savedObjectsClient.find.mockResolvedValueOnce({ total: 2, @@ -436,6 +530,8 @@ describe('getSortedObjectsForExport()', () => { "calls": Array [ Array [ Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, "namespaces": Array [ "foo", ], @@ -843,4 +939,17 @@ describe('getSortedObjectsForExport()', () => { `"Can't specify both \\"search\\" and \\"objects\\" properties when exporting"` ); }); + + test('rejects when both objects and references are passed in', () => { + const exportOpts = { + exportSizeLimit: 1, + savedObjectsClient, + objects: [{ type: 'index-pattern', id: '1' }], + hasReference: [{ type: 'index-pattern', id: '1' }], + }; + + expect(exportSavedObjectsToStream(exportOpts)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Can't specify both \\"references\\" and \\"objects\\" properties when exporting"` + ); + }); }); diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index 9b13c9b24ffdc..7965b12eb874e 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -19,7 +19,11 @@ import Boom from '@hapi/boom'; import { createListStream } from '../../utils/streams'; -import { SavedObjectsClientContract, SavedObject } from '../types'; +import { + SavedObjectsClientContract, + SavedObject, + SavedObjectsFindOptionsReference, +} from '../types'; import { fetchNestedDependencies } from './inject_nested_depdendencies'; import { sortObjects } from './sort_objects'; @@ -30,6 +34,8 @@ import { sortObjects } from './sort_objects'; export interface SavedObjectsExportOptions { /** optional array of saved object types. */ types?: string[]; + /** optional array of references to search object for when exporting by types */ + hasReference?: SavedObjectsFindOptionsReference[]; /** optional array of objects to export. */ objects?: Array<{ /** the saved object id. */ @@ -51,6 +57,43 @@ export interface SavedObjectsExportOptions { namespace?: string; } +interface SavedObjectsFetchByTypeOptions { + /** array of saved object types. */ + types: string[]; + /** optional array of references to search object for when exporting by types */ + hasReference?: SavedObjectsFindOptionsReference[]; + /** optional query string to filter exported objects. */ + search?: string; + /** an instance of the SavedObjectsClient. */ + savedObjectsClient: SavedObjectsClientContract; + /** the maximum number of objects to export. */ + exportSizeLimit: number; + /** optional namespace to override the namespace used by the savedObjectsClient. */ + namespace?: string; +} + +interface SavedObjectsFetchByObjectOptions { + /** optional array of objects to export. */ + objects: Array<{ + /** the saved object id. */ + id: string; + /** the saved object type. */ + type: string; + }>; + /** an instance of the SavedObjectsClient. */ + savedObjectsClient: SavedObjectsClientContract; + /** the maximum number of objects to export. */ + exportSizeLimit: number; + /** optional namespace to override the namespace used by the savedObjectsClient. */ + namespace?: string; +} + +const isFetchByTypeOptions = ( + options: SavedObjectsFetchByTypeOptions | SavedObjectsFetchByObjectOptions +): options is SavedObjectsFetchByTypeOptions => { + return Boolean((options as SavedObjectsFetchByTypeOptions).types); +}; + /** * Structure of the export result details entry * @public @@ -69,21 +112,67 @@ export interface SavedObjectsExportResultDetails { }>; } -async function fetchObjectsToExport({ - objects, +async function fetchByType({ types, + namespace, + exportSizeLimit, + hasReference, search, + savedObjectsClient, +}: SavedObjectsFetchByTypeOptions) { + const findResponse = await savedObjectsClient.find({ + type: types, + hasReference, + hasReferenceOperator: hasReference ? 'OR' : undefined, + search, + perPage: exportSizeLimit, + namespaces: namespace ? [namespace] : undefined, + }); + if (findResponse.total > exportSizeLimit) { + throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`); + } + + // sorts server-side by _id, since it's only available in fielddata + return ( + findResponse.saved_objects + // exclude the find-specific `score` property from the exported objects + .map(({ score, ...obj }) => obj) + .sort((a: SavedObject, b: SavedObject) => (a.id > b.id ? 1 : -1)) + ); +} + +async function fetchByObjects({ + objects, exportSizeLimit, + namespace, savedObjectsClient, +}: SavedObjectsFetchByObjectOptions) { + if (objects.length > exportSizeLimit) { + throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`); + } + const bulkGetResult = await savedObjectsClient.bulkGet(objects, { namespace }); + const erroredObjects = bulkGetResult.saved_objects.filter((obj) => !!obj.error); + if (erroredObjects.length) { + const err = Boom.badRequest(); + err.output.payload.attributes = { + objects: erroredObjects, + }; + throw err; + } + return bulkGetResult.saved_objects; +} + +const validateOptions = ({ + objects, + search, + hasReference, + exportSizeLimit, namespace, -}: { - objects?: SavedObjectsExportOptions['objects']; - types?: string[]; - search?: string; - exportSizeLimit: number; - savedObjectsClient: SavedObjectsClientContract; - namespace?: string; -}) { + savedObjectsClient, + types, +}: SavedObjectsExportOptions): + | SavedObjectsFetchByTypeOptions + | SavedObjectsFetchByObjectOptions => { if ((types?.length ?? 0) > 0 && (objects?.length ?? 0) > 0) { throw Boom.badRequest(`Can't specify both "types" and "objects" properties when exporting`); } @@ -94,38 +183,30 @@ async function fetchObjectsToExport({ if (typeof search === 'string') { throw Boom.badRequest(`Can't specify both "search" and "objects" properties when exporting`); } - const bulkGetResult = await savedObjectsClient.bulkGet(objects, { namespace }); - const erroredObjects = bulkGetResult.saved_objects.filter((obj) => !!obj.error); - if (erroredObjects.length) { - const err = Boom.badRequest(); - err.output.payload.attributes = { - objects: erroredObjects, - }; - throw err; + if (hasReference && hasReference.length) { + throw Boom.badRequest( + `Can't specify both "references" and "objects" properties when exporting` + ); } - return bulkGetResult.saved_objects; + return { + objects, + exportSizeLimit, + savedObjectsClient, + namespace, + } as SavedObjectsFetchByObjectOptions; } else if (types && types.length > 0) { - const findResponse = await savedObjectsClient.find({ - type: types, + return { + types, + hasReference, search, - perPage: exportSizeLimit, - namespaces: namespace ? [namespace] : undefined, - }); - if (findResponse.total > exportSizeLimit) { - throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`); - } - - // sorts server-side by _id, since it's only available in fielddata - return ( - findResponse.saved_objects - // exclude the find-specific `score` property from the exported objects - .map(({ score, ...obj }) => obj) - .sort((a: SavedObject, b: SavedObject) => (a.id > b.id ? 1 : -1)) - ); + exportSizeLimit, + savedObjectsClient, + namespace, + } as SavedObjectsFetchByTypeOptions; } else { throw Boom.badRequest('Either `type` or `objects` are required.'); } -} +}; /** * Generates sorted saved object stream to be used for export. @@ -135,6 +216,7 @@ async function fetchObjectsToExport({ */ export async function exportSavedObjectsToStream({ types, + hasReference, objects, search, savedObjectsClient, @@ -143,14 +225,22 @@ export async function exportSavedObjectsToStream({ excludeExportDetails = false, namespace, }: SavedObjectsExportOptions) { - const rootObjects = await fetchObjectsToExport({ - types, - objects, - search, + const fetchOptions = validateOptions({ savedObjectsClient, - exportSizeLimit, namespace, + exportSizeLimit, + hasReference, + search, + objects, + excludeExportDetails, + includeReferencesDeep, + types, }); + + const rootObjects = isFetchByTypeOptions(fetchOptions) + ? await fetchByType(fetchOptions) + : await fetchByObjects(fetchOptions); + let exportedObjects: Array> = []; let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = []; diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index 35a65d8d9651f..5b4fd57e11256 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -28,12 +28,20 @@ import { validateTypes, validateObjects } from './utils'; export const registerExportRoute = (router: IRouter, config: SavedObjectConfig) => { const { maxImportExportSize } = config; + const referenceSchema = schema.object({ + type: schema.string(), + id: schema.string(), + }); + router.post( { path: '/_export', validate: { body: schema.object({ type: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + hasReference: schema.maybe( + schema.oneOf([referenceSchema, schema.arrayOf(referenceSchema)]) + ), objects: schema.maybe( schema.arrayOf( schema.object({ @@ -51,7 +59,14 @@ export const registerExportRoute = (router: IRouter, config: SavedObjectConfig) }, router.handleLegacyErrors(async (context, req, res) => { const savedObjectsClient = context.core.savedObjects.client; - const { type, objects, search, excludeExportDetails, includeReferencesDeep } = req.body; + const { + type, + hasReference, + objects, + search, + excludeExportDetails, + includeReferencesDeep, + } = req.body; const types = typeof type === 'string' ? [type] : type; // need to access the registry for type validation, can't use the schema for this @@ -82,6 +97,7 @@ export const registerExportRoute = (router: IRouter, config: SavedObjectConfig) const exportStream = await exportSavedObjectsToStream({ savedObjectsClient, types, + hasReference: hasReference && !Array.isArray(hasReference) ? [hasReference] : hasReference, search, objects, exportSizeLimit: maxImportExportSize, diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts index 6313a95b1fefa..915d0cccf7af9 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -21,6 +21,14 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; export const registerFindRoute = (router: IRouter) => { + const referenceSchema = schema.object({ + type: schema.string(), + id: schema.string(), + }); + const searchOperatorSchema = schema.oneOf([schema.literal('OR'), schema.literal('AND')], { + defaultValue: 'OR', + }); + router.get( { path: '/_find', @@ -30,19 +38,15 @@ export const registerFindRoute = (router: IRouter) => { page: schema.number({ min: 0, defaultValue: 1 }), type: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]), search: schema.maybe(schema.string()), - default_search_operator: schema.oneOf([schema.literal('OR'), schema.literal('AND')], { - defaultValue: 'OR', - }), + default_search_operator: searchOperatorSchema, search_fields: schema.maybe( schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) ), sort_field: schema.maybe(schema.string()), has_reference: schema.maybe( - schema.object({ - type: schema.string(), - id: schema.string(), - }) + schema.oneOf([referenceSchema, schema.arrayOf(referenceSchema)]) ), + has_reference_operator: searchOperatorSchema, fields: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), filter: schema.maybe(schema.string()), namespaces: schema.maybe( @@ -67,6 +71,7 @@ export const registerFindRoute = (router: IRouter) => { typeof query.search_fields === 'string' ? [query.search_fields] : query.search_fields, sortField: query.sort_field, hasReference: query.has_reference, + hasReferenceOperator: query.has_reference_operator, fields: typeof query.fields === 'string' ? [query.fields] : query.fields, filter: query.filter, namespaces, diff --git a/src/core/server/saved_objects/routes/integration_tests/find.test.ts b/src/core/server/saved_objects/routes/integration_tests/find.test.ts index 4fe9cbe415cd6..9a426ef48c7da 100644 --- a/src/core/server/saved_objects/routes/integration_tests/find.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/find.test.ts @@ -118,6 +118,7 @@ describe('GET /api/saved_objects/_find', () => { page: 1, type: ['foo', 'bar'], defaultSearchOperator: 'OR', + hasReferenceOperator: 'OR', }); }); @@ -129,7 +130,7 @@ describe('GET /api/saved_objects/_find', () => { expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); const options = savedObjectsClient.find.mock.calls[0][0]; - expect(options).toEqual({ perPage: 10, page: 50, type: ['foo'], defaultSearchOperator: 'OR' }); + expect(options).toEqual(expect.objectContaining({ perPage: 10, page: 50 })); }); it('accepts the optional query parameter has_reference', async () => { @@ -141,7 +142,7 @@ describe('GET /api/saved_objects/_find', () => { expect(options.hasReference).toBe(undefined); }); - it('accepts the query parameter has_reference', async () => { + it('accepts the query parameter has_reference as an object', async () => { const references = querystring.escape( JSON.stringify({ id: '1', @@ -161,6 +162,53 @@ describe('GET /api/saved_objects/_find', () => { }); }); + it('accepts the query parameter has_reference as an array', async () => { + const references = querystring.escape( + JSON.stringify([ + { + id: '1', + type: 'reference', + }, + { + id: '2', + type: 'reference', + }, + ]) + ); + await supertest(httpSetup.server.listener) + .get(`/api/saved_objects/_find?type=foo&has_reference=${references}`) + .expect(200); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + + const options = savedObjectsClient.find.mock.calls[0][0]; + expect(options.hasReference).toEqual([ + { + id: '1', + type: 'reference', + }, + { + id: '2', + type: 'reference', + }, + ]); + }); + + it('accepts the query parameter has_reference_operator', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=foo&has_reference_operator=AND') + .expect(200); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + + const options = savedObjectsClient.find.mock.calls[0][0]; + expect(options).toEqual( + expect.objectContaining({ + hasReferenceOperator: 'AND', + }) + ); + }); + it('accepts the query parameter search_fields', async () => { await supertest(httpSetup.server.listener) .get('/api/saved_objects/_find?type=foo&search_fields=title') @@ -169,13 +217,11 @@ describe('GET /api/saved_objects/_find', () => { expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); const options = savedObjectsClient.find.mock.calls[0][0]; - expect(options).toEqual({ - perPage: 20, - page: 1, - searchFields: ['title'], - type: ['foo'], - defaultSearchOperator: 'OR', - }); + expect(options).toEqual( + expect.objectContaining({ + searchFields: ['title'], + }) + ); }); it('accepts the query parameter fields as a string', async () => { @@ -186,13 +232,11 @@ describe('GET /api/saved_objects/_find', () => { expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); const options = savedObjectsClient.find.mock.calls[0][0]; - expect(options).toEqual({ - perPage: 20, - page: 1, - fields: ['title'], - type: ['foo'], - defaultSearchOperator: 'OR', - }); + expect(options).toEqual( + expect.objectContaining({ + fields: ['title'], + }) + ); }); it('accepts the query parameter fields as an array', async () => { @@ -203,13 +247,11 @@ describe('GET /api/saved_objects/_find', () => { expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); const options = savedObjectsClient.find.mock.calls[0][0]; - expect(options).toEqual({ - perPage: 20, - page: 1, - fields: ['title', 'description'], - type: ['foo'], - defaultSearchOperator: 'OR', - }); + expect(options).toEqual( + expect.objectContaining({ + fields: ['title', 'description'], + }) + ); }); it('accepts the query parameter type as a string', async () => { @@ -220,12 +262,11 @@ describe('GET /api/saved_objects/_find', () => { expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); const options = savedObjectsClient.find.mock.calls[0][0]; - expect(options).toEqual({ - perPage: 20, - page: 1, - type: ['index-pattern'], - defaultSearchOperator: 'OR', - }); + expect(options).toEqual( + expect.objectContaining({ + type: ['index-pattern'], + }) + ); }); it('accepts the query parameter type as an array', async () => { @@ -236,12 +277,11 @@ describe('GET /api/saved_objects/_find', () => { expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); const options = savedObjectsClient.find.mock.calls[0][0]; - expect(options).toEqual({ - perPage: 20, - page: 1, - type: ['index-pattern', 'visualization'], - defaultSearchOperator: 'OR', - }); + expect(options).toEqual( + expect.objectContaining({ + type: ['index-pattern', 'visualization'], + }) + ); }); it('accepts the query parameter namespaces as a string', async () => { @@ -252,13 +292,11 @@ describe('GET /api/saved_objects/_find', () => { expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); const options = savedObjectsClient.find.mock.calls[0][0]; - expect(options).toEqual({ - perPage: 20, - page: 1, - type: ['index-pattern'], - namespaces: ['foo'], - defaultSearchOperator: 'OR', - }); + expect(options).toEqual( + expect.objectContaining({ + namespaces: ['foo'], + }) + ); }); it('accepts the query parameter namespaces as an array', async () => { @@ -269,12 +307,10 @@ describe('GET /api/saved_objects/_find', () => { expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); const options = savedObjectsClient.find.mock.calls[0][0]; - expect(options).toEqual({ - perPage: 20, - page: 1, - type: ['index-pattern'], - namespaces: ['default', 'foo'], - defaultSearchOperator: 'OR', - }); + expect(options).toEqual( + expect.objectContaining({ + namespaces: ['default', 'foo'], + }) + ); }); }); diff --git a/src/core/server/saved_objects/service/lib/errors.ts b/src/core/server/saved_objects/service/lib/errors.ts index 78956ca8d6868..e8836dbd8f7a1 100644 --- a/src/core/server/saved_objects/service/lib/errors.ts +++ b/src/core/server/saved_objects/service/lib/errors.ts @@ -154,9 +154,10 @@ export class SavedObjectsErrorHelpers { return decorate(error, CODE_CONFLICT, 409, reason); } - public static createConflictError(type: string, id: string) { + public static createConflictError(type: string, id: string, reason?: string) { return SavedObjectsErrorHelpers.decorateConflictError( - Boom.conflict(`Saved object [${type}/${id}] conflict`) + Boom.conflict(`Saved object [${type}/${id}] conflict`), + reason ); } diff --git a/src/core/server/saved_objects/service/lib/repository.mock.ts b/src/core/server/saved_objects/service/lib/repository.mock.ts index c5fd260b78a9f..1b38a300debe6 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -33,6 +33,7 @@ const create = (): jest.Mocked => ({ deleteFromNamespaces: jest.fn(), deleteByNamespace: jest.fn(), incrementCounter: jest.fn(), + removeReferencesTo: jest.fn(), }); export const savedObjectsRepositoryMock = { create }; diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index e93bdb34ecc75..6f885f17fd82b 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -2446,6 +2446,161 @@ describe('SavedObjectsRepository', () => { }); }); + describe('#removeReferencesTo', () => { + const type = 'type'; + const id = 'id'; + const defaultOptions = {}; + + const updatedCount = 42; + + const removeReferencesToSuccess = async (options = defaultOptions) => { + client.updateByQuery.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + updated: updatedCount, + }) + ); + return await savedObjectsRepository.removeReferencesTo(type, id, options); + }; + + describe('client calls', () => { + it('should use the ES updateByQuery action', async () => { + await removeReferencesToSuccess(); + expect(client.updateByQuery).toHaveBeenCalledTimes(1); + }); + + it('uses the correct default `refresh` value', async () => { + await removeReferencesToSuccess(); + expect(client.updateByQuery).toHaveBeenCalledWith( + expect.objectContaining({ + refresh: true, + }), + expect.any(Object) + ); + }); + + it('merges output of getSearchDsl into es request body', async () => { + const query = { query: 1, aggregations: 2 }; + getSearchDslNS.getSearchDsl.mockReturnValue(query); + await removeReferencesToSuccess({ type }); + + expect(client.updateByQuery).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ ...query }), + }), + expect.anything() + ); + }); + + it('should set index to all known SO indices on the request', async () => { + await removeReferencesToSuccess(); + expect(client.updateByQuery).toHaveBeenCalledWith( + expect.objectContaining({ + index: ['.kibana-test', 'custom'], + }), + expect.anything() + ); + }); + + it('should use the `refresh` option in the request', async () => { + const refresh = Symbol(); + + await removeReferencesToSuccess({ refresh }); + expect(client.updateByQuery).toHaveBeenCalledWith( + expect.objectContaining({ + refresh, + }), + expect.anything() + ); + }); + + it('should pass the correct parameters to the update script', async () => { + await removeReferencesToSuccess(); + expect(client.updateByQuery).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + script: expect.objectContaining({ + params: { + type, + id, + }, + }), + }), + }), + expect.anything() + ); + }); + }); + + describe('search dsl', () => { + it(`passes mappings and registry to getSearchDsl`, async () => { + await removeReferencesToSuccess(); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith( + mappings, + registry, + expect.anything() + ); + }); + + it('passes namespace to getSearchDsl', async () => { + await removeReferencesToSuccess({ namespace: 'some-ns' }); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith( + mappings, + registry, + expect.objectContaining({ + namespaces: ['some-ns'], + }) + ); + }); + + it('passes hasReference to getSearchDsl', async () => { + await removeReferencesToSuccess(); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith( + mappings, + registry, + expect.objectContaining({ + hasReference: { + type, + id, + }, + }) + ); + }); + + it('passes all known types to getSearchDsl', async () => { + await removeReferencesToSuccess(); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith( + mappings, + registry, + expect.objectContaining({ + type: registry.getAllTypes().map((type) => type.name), + }) + ); + }); + }); + + describe('returns', () => { + it('returns the updated count from the ES response', async () => { + const response = await removeReferencesToSuccess(); + expect(response.updated).toBe(updatedCount); + }); + }); + + describe('errors', () => { + it(`throws when ES returns failures`, async () => { + client.updateByQuery.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + updated: 7, + failures: ['failure', 'another-failure'], + }) + ); + + await expect( + savedObjectsRepository.removeReferencesTo(type, id, defaultOptions) + ).rejects.toThrowError(createConflictError(type, id)); + }); + }); + }); + describe('#find', () => { const generateSearchResults = (namespace) => { return { @@ -2811,6 +2966,19 @@ describe('SavedObjectsRepository', () => { }); }); + it(`accepts hasReferenceOperator`, async () => { + const relevantOpts = { + ...commonOptions, + hasReferenceOperator: 'AND', + }; + + await findSuccess(relevantOpts, namespace); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { + ...relevantOpts, + hasReferenceOperator: 'AND', + }); + }); + it(`accepts KQL expression filter and passes KueryNode to getSearchDsl`, async () => { const findOpts = { namespaces: [namespace], diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 39aacd6b05b7b..d362c02de4915 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -57,6 +57,8 @@ import { SavedObjectsAddToNamespacesResponse, SavedObjectsDeleteFromNamespacesOptions, SavedObjectsDeleteFromNamespacesResponse, + SavedObjectsRemoveReferencesToOptions, + SavedObjectsRemoveReferencesToResponse, } from '../saved_objects_client'; import { SavedObject, @@ -708,6 +710,7 @@ export class SavedObjectsRepository { searchFields, rootSearchFields, hasReference, + hasReferenceOperator, page = FIND_DEFAULT_PAGE, perPage = FIND_DEFAULT_PER_PAGE, sortField, @@ -790,6 +793,7 @@ export class SavedObjectsRepository { namespaces, typeToNamespacesMap, hasReference, + hasReferenceOperator, kueryNode, }), }, @@ -1445,6 +1449,71 @@ export class SavedObjectsRepository { }; } + /** + * Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. + * + * @remarks Will throw a conflict error if the `update_by_query` operation returns any failure. In that case + * some references might have been removed, and some were not. It is the caller's responsibility + * to handle and fix this situation if it was to happen. + */ + async removeReferencesTo( + type: string, + id: string, + options: SavedObjectsRemoveReferencesToOptions = {} + ): Promise { + const { namespace, refresh = true } = options; + const allTypes = this._registry.getAllTypes().map((t) => t.name); + + // we need to target all SO indices as all types of objects may have references to the given SO. + const targetIndices = this.getIndicesForTypes(allTypes); + + const { body } = await this.client.updateByQuery( + { + index: targetIndices, + refresh, + body: { + script: { + source: ` + if (ctx._source.containsKey('references')) { + def items_to_remove = []; + for (item in ctx._source.references) { + if ( (item['type'] == params['type']) && (item['id'] == params['id']) ) { + items_to_remove.add(item); + } + } + ctx._source.references.removeAll(items_to_remove); + } + `, + params: { + type, + id, + }, + lang: 'painless', + }, + conflicts: 'proceed', + ...getSearchDsl(this._mappings, this._registry, { + namespaces: namespace ? [namespace] : undefined, + type: allTypes, + hasReference: { type, id }, + }), + }, + }, + { ignore: [404] } + ); + + if (body.failures?.length) { + throw SavedObjectsErrorHelpers.createConflictError( + type, + id, + `${body.failures.length} references could not be removed` + ); + } + + return { + updated: body.updated, + }; + } + /** * Increases a counter field by one. Creates the document if one doesn't exist for the given id. * diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index 330fa5066051f..333f5caf72525 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -23,7 +23,7 @@ type KueryNode = any; import { typeRegistryMock } from '../../../saved_objects_type_registry.mock'; import { ALL_NAMESPACES_STRING } from '../utils'; -import { getQueryParams } from './query_params'; +import { getQueryParams, getClauseForReference } from './query_params'; const registry = typeRegistryMock.create(); @@ -93,7 +93,7 @@ describe('#getQueryParams', () => { const mappings = MAPPINGS; type Result = ReturnType; - describe('kueryNode filter clause (query.bool.filter[...]', () => { + describe('kueryNode filter clause', () => { const expectResult = (result: Result, expected: any) => { expect(result.query.bool.filter).toEqual(expect.arrayContaining([expected])); }; @@ -150,12 +150,17 @@ describe('#getQueryParams', () => { }); }); - describe('reference filter clause (query.bool.filter[bool.must])', () => { + describe('reference filter clause', () => { describe('`hasReference` parameter', () => { - const expectResult = (result: Result, expected: any) => { - expect(result.query.bool.filter).toEqual( - expect.arrayContaining([{ bool: expect.objectContaining({ must: expected }) }]) - ); + const getReferencesFilter = (result: any) => { + const filters = result.query.bool.filter; + return filters.find((filter: any) => { + const clauses = filter.bool?.must ?? filter.bool?.should; + if (!clauses) { + return false; + } + return clauses[0].nested?.path === 'references' ?? false; + }); }; it('does not include the clause when `hasReference` is not specified', () => { @@ -164,36 +169,96 @@ describe('#getQueryParams', () => { registry, hasReference: undefined, }); - expectResult(result, undefined); + + expect(getReferencesFilter(result)).toBeUndefined(); }); - it('creates a clause with query for specified reference', () => { + it('creates a should clause for specified reference when operator is `OR`', () => { const hasReference = { id: 'foo', type: 'bar' }; const result = getQueryParams({ mappings, registry, hasReference, + hasReferenceOperator: 'OR', }); - expectResult(result, [ - { - nested: { - path: 'references', - query: { - bool: { - must: [ - { term: { 'references.id': hasReference.id } }, - { term: { 'references.type': hasReference.type } }, - ], - }, - }, - }, + expect(getReferencesFilter(result)).toEqual({ + bool: { + should: [getClauseForReference(hasReference)], + minimum_should_match: 1, + }, + }); + }); + + it('creates a must clause for specified reference when operator is `AND`', () => { + const hasReference = { id: 'foo', type: 'bar' }; + const result = getQueryParams({ + mappings, + registry, + hasReference, + hasReferenceOperator: 'AND', + }); + expect(getReferencesFilter(result)).toEqual({ + bool: { + must: [getClauseForReference(hasReference)], }, - ]); + }); + }); + + it('handles multiple references when operator is `OR`', () => { + const hasReference = [ + { id: 'foo', type: 'bar' }, + { id: 'hello', type: 'dolly' }, + ]; + const result = getQueryParams({ + mappings, + registry, + hasReference, + hasReferenceOperator: 'OR', + }); + expect(getReferencesFilter(result)).toEqual({ + bool: { + should: hasReference.map(getClauseForReference), + minimum_should_match: 1, + }, + }); + }); + + it('handles multiple references when operator is `AND`', () => { + const hasReference = [ + { id: 'foo', type: 'bar' }, + { id: 'hello', type: 'dolly' }, + ]; + const result = getQueryParams({ + mappings, + registry, + hasReference, + hasReferenceOperator: 'AND', + }); + expect(getReferencesFilter(result)).toEqual({ + bool: { + must: hasReference.map(getClauseForReference), + }, + }); + }); + + it('defaults to `OR` when operator is not specified', () => { + const hasReference = { id: 'foo', type: 'bar' }; + const result = getQueryParams({ + mappings, + registry, + hasReference, + }); + expect(getReferencesFilter(result)).toEqual({ + bool: { + should: [getClauseForReference(hasReference)], + minimum_should_match: 1, + }, + }); }); }); }); - describe('type filter clauses (query.bool.filter[bool.should])', () => { + describe('type filter clauses', () => { describe('`type` parameter', () => { const expectResult = (result: Result, ...types: string[]) => { expect(result.query.bool.filter).toEqual( diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 8bd9c7d8312ee..8d4fe13b9bede 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -122,11 +122,13 @@ function getClauseForType( }; } -interface HasReferenceQueryParams { +export interface HasReferenceQueryParams { type: string; id: string; } +export type SearchOperator = 'AND' | 'OR'; + interface QueryParams { mappings: IndexMapping; registry: ISavedObjectTypeRegistry; @@ -134,13 +136,58 @@ interface QueryParams { type?: string | string[]; typeToNamespacesMap?: Map; search?: string; + defaultSearchOperator?: SearchOperator; searchFields?: string[]; rootSearchFields?: string[]; - defaultSearchOperator?: string; - hasReference?: HasReferenceQueryParams; + hasReference?: HasReferenceQueryParams | HasReferenceQueryParams[]; + hasReferenceOperator?: SearchOperator; kueryNode?: KueryNode; } +function getReferencesFilter( + references: HasReferenceQueryParams[], + operator: SearchOperator = 'OR' +) { + if (operator === 'AND') { + return { + bool: { + must: references.map(getClauseForReference), + }, + }; + } else { + return { + bool: { + should: references.map(getClauseForReference), + minimum_should_match: 1, + }, + }; + } +} + +export function getClauseForReference(reference: HasReferenceQueryParams) { + return { + nested: { + path: 'references', + query: { + bool: { + must: [ + { + term: { + 'references.id': reference.id, + }, + }, + { + term: { + 'references.type': reference.type, + }, + }, + ], + }, + }, + }, + }; +} + /** * Get the "query" related keys for the search body */ @@ -155,6 +202,7 @@ export function getQueryParams({ rootSearchFields, defaultSearchOperator, hasReference, + hasReferenceOperator, kueryNode, }: QueryParams) { const types = getTypes( @@ -162,6 +210,10 @@ export function getQueryParams({ typeToNamespacesMap ? Array.from(typeToNamespacesMap.keys()) : type ); + if (hasReference && !Array.isArray(hasReference)) { + hasReference = [hasReference]; + } + // A de-duplicated set of namespaces makes for a more effecient query. // // Additonally, we treat the `*` namespace as the `default` namespace. @@ -181,33 +233,11 @@ export function getQueryParams({ const bool: any = { filter: [ ...(kueryNode != null ? [esKuery.toElasticsearchQuery(kueryNode)] : []), + ...(hasReference && hasReference.length + ? [getReferencesFilter(hasReference, hasReferenceOperator)] + : []), { bool: { - must: hasReference - ? [ - { - nested: { - path: 'references', - query: { - bool: { - must: [ - { - term: { - 'references.id': hasReference.id, - }, - }, - { - term: { - 'references.type': hasReference.type, - }, - }, - ], - }, - }, - }, - }, - ] - : undefined, should: types.map((shouldType) => { const normalizedNamespaces = normalizeNamespaces( typeToNamespacesMap ? typeToNamespacesMap.get(shouldType) : namespaces diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index 7276e505bce7d..a9f26f71a3f2b 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -57,7 +57,7 @@ describe('getSearchDsl', () => { }); describe('passes control', () => { - it('passes (mappings, schema, namespaces, type, typeToNamespacesMap, search, searchFields, rootSearchFields, hasReference) to getQueryParams', () => { + it('passes (mappings, schema, namespaces, type, typeToNamespacesMap, search, searchFields, rootSearchFields, hasReference, hasReferenceOperator) to getQueryParams', () => { const opts = { namespaces: ['foo-namespace'], type: 'foo', @@ -65,11 +65,12 @@ describe('getSearchDsl', () => { search: 'bar', searchFields: ['baz'], rootSearchFields: ['qux'], - defaultSearchOperator: 'AND', + defaultSearchOperator: 'AND' as queryParamsNS.SearchOperator, hasReference: { type: 'bar', id: '1', }, + hasReferenceOperator: 'AND' as queryParamsNS.SearchOperator, }; getSearchDsl(mappings, registry, opts); @@ -85,6 +86,7 @@ describe('getSearchDsl', () => { rootSearchFields: opts.rootSearchFields, defaultSearchOperator: opts.defaultSearchOperator, hasReference: opts.hasReference, + hasReferenceOperator: opts.hasReferenceOperator, }); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index fca361b8ffda0..d5da82e5617be 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -20,7 +20,7 @@ import Boom from '@hapi/boom'; import { IndexMapping } from '../../../mappings'; -import { getQueryParams } from './query_params'; +import { getQueryParams, HasReferenceQueryParams, SearchOperator } from './query_params'; import { getSortingParams } from './sorting_params'; import { ISavedObjectTypeRegistry } from '../../../saved_objects_type_registry'; @@ -29,17 +29,15 @@ type KueryNode = any; interface GetSearchDslOptions { type: string | string[]; search?: string; - defaultSearchOperator?: string; + defaultSearchOperator?: SearchOperator; searchFields?: string[]; rootSearchFields?: string[]; sortField?: string; sortOrder?: string; namespaces?: string[]; typeToNamespacesMap?: Map; - hasReference?: { - type: string; - id: string; - }; + hasReference?: HasReferenceQueryParams | HasReferenceQueryParams[]; + hasReferenceOperator?: SearchOperator; kueryNode?: KueryNode; } @@ -59,6 +57,7 @@ export function getSearchDsl( namespaces, typeToNamespacesMap, hasReference, + hasReferenceOperator, kueryNode, } = options; @@ -82,6 +81,7 @@ export function getSearchDsl( rootSearchFields, defaultSearchOperator, hasReference, + hasReferenceOperator, kueryNode, }), ...getSortingParams(mappings, type, sortField, sortOrder), diff --git a/src/core/server/saved_objects/service/saved_objects_client.mock.ts b/src/core/server/saved_objects/service/saved_objects_client.mock.ts index 3b0789970cc6b..7b300129f0b9a 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.mock.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.mock.ts @@ -34,6 +34,7 @@ const create = () => update: jest.fn(), addToNamespaces: jest.fn(), deleteFromNamespaces: jest.fn(), + removeReferencesTo: jest.fn(), } as unknown) as jest.Mocked); export const savedObjectsClientMock = { create }; diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index 47011414cbc7f..3298121f9571f 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -196,3 +196,19 @@ test(`#deleteFromNamespaces`, async () => { expect(mockRepository.deleteFromNamespaces).toHaveBeenCalledWith(type, id, namespaces, options); expect(result).toBe(returnValue); }); + +test(`#removeReferencesTo`, async () => { + const returnValue = Symbol(); + const mockRepository = { + removeReferencesTo: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const type = Symbol(); + const id = Symbol(); + const options = Symbol(); + const result = await client.removeReferencesTo(type, id, options); + + expect(mockRepository.removeReferencesTo).toHaveBeenCalledWith(type, id, options); + expect(result).toBe(returnValue); +}); diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 6782998d1bf1e..6cb9823c736e0 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -209,6 +209,24 @@ export interface SavedObjectsDeleteFromNamespacesResponse { namespaces: string[]; } +/** + * + * @public + */ +export interface SavedObjectsRemoveReferencesToOptions extends SavedObjectsBaseOptions { + /** The Elasticsearch Refresh setting for this operation. Defaults to `true` */ + refresh?: boolean; +} + +/** + * + * @public + */ +export interface SavedObjectsRemoveReferencesToResponse extends SavedObjectsBaseOptions { + /** The number of objects that have been updated by this operation */ + updated: number; +} + /** * * @public @@ -433,4 +451,15 @@ export class SavedObjectsClient { ): Promise> { return await this._repository.bulkUpdate(objects, options); } + + /** + * Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. + */ + async removeReferencesTo( + type: string, + id: string, + options?: SavedObjectsRemoveReferencesToOptions + ) { + return await this._repository.removeReferencesTo(type, id, options); + } } diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 01128e4f8cf51..b16eeb2aa03a6 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -60,6 +60,14 @@ export interface SavedObjectStatusMeta { }; } +/** + * @public + */ +export interface SavedObjectsFindOptionsReference { + type: string; + id: string; +} + /** * * @public @@ -85,7 +93,20 @@ export interface SavedObjectsFindOptions { * be modified. If used in conjunction with `searchFields`, both are concatenated together. */ rootSearchFields?: string[]; - hasReference?: { type: string; id: string }; + + /** + * Search for documents having a reference to the specified objects. + * Use `hasReferenceOperator` to specify the operator to use when searching for multiple references. + */ + hasReference?: SavedObjectsFindOptionsReference | SavedObjectsFindOptionsReference[]; + /** + * The operator to use when searching by multiple references using the `hasReference` option. Defaults to `OR` + */ + hasReferenceOperator?: 'AND' | 'OR'; + + /** + * The search operator to use with the provided filter. Defaults to `OR` + */ defaultSearchOperator?: 'AND' | 'OR'; filter?: string | KueryNode; namespaces?: string[]; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 4562b6d696c87..52500673f7f31 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -728,7 +728,7 @@ export interface Explanation { } // @public -export function exportSavedObjectsToStream({ types, objects, search, savedObjectsClient, exportSizeLimit, includeReferencesDeep, excludeExportDetails, namespace, }: SavedObjectsExportOptions): Promise; +export function exportSavedObjectsToStream({ types, hasReference, objects, search, savedObjectsClient, exportSizeLimit, includeReferencesDeep, excludeExportDetails, namespace, }: SavedObjectsExportOptions): Promise; // @public export interface FakeRequest { @@ -1987,6 +1987,7 @@ export class SavedObjectsClient { errors: typeof SavedObjectsErrorHelpers; find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; + removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; } @@ -2094,7 +2095,7 @@ export class SavedObjectsErrorHelpers { // (undocumented) static createBadRequestError(reason?: string): DecoratedError; // (undocumented) - static createConflictError(type: string, id: string): DecoratedError; + static createConflictError(type: string, id: string, reason?: string): DecoratedError; // (undocumented) static createGenericNotFoundError(type?: string | null, id?: string | null): DecoratedError; // (undocumented) @@ -2151,6 +2152,7 @@ export class SavedObjectsErrorHelpers { export interface SavedObjectsExportOptions { excludeExportDetails?: boolean; exportSizeLimit: number; + hasReference?: SavedObjectsFindOptionsReference[]; includeReferencesDeep?: boolean; namespace?: string; objects?: Array<{ @@ -2177,18 +2179,14 @@ export type SavedObjectsFieldMapping = SavedObjectsCoreFieldMapping | SavedObjec // @public (undocumented) export interface SavedObjectsFindOptions { - // (undocumented) defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; // Warning: (ae-forgotten-export) The symbol "KueryNode" needs to be exported by the entry point index.d.ts // // (undocumented) filter?: string | KueryNode; - // (undocumented) - hasReference?: { - type: string; - id: string; - }; + hasReference?: SavedObjectsFindOptionsReference | SavedObjectsFindOptionsReference[]; + hasReferenceOperator?: 'AND' | 'OR'; // (undocumented) namespaces?: string[]; // (undocumented) @@ -2208,6 +2206,14 @@ export interface SavedObjectsFindOptions { typeToNamespacesMap?: Map; } +// @public (undocumented) +export interface SavedObjectsFindOptionsReference { + // (undocumented) + id: string; + // (undocumented) + type: string; +} + // @public export interface SavedObjectsFindResponse { // (undocumented) @@ -2401,6 +2407,16 @@ export interface SavedObjectsRawDoc { _type?: string; } +// @public (undocumented) +export interface SavedObjectsRemoveReferencesToOptions extends SavedObjectsBaseOptions { + refresh?: boolean; +} + +// @public (undocumented) +export interface SavedObjectsRemoveReferencesToResponse extends SavedObjectsBaseOptions { + updated: number; +} + // @public (undocumented) export class SavedObjectsRepository { addToNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsAddToNamespacesOptions): Promise; @@ -2420,6 +2436,7 @@ export class SavedObjectsRepository { find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise; + removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; } diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index 531074f9fa60b..bd19a9f0d9cd3 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -11,7 +11,7 @@ "uiActions", "savedObjects" ], - "optionalPlugins": ["home", "share", "usageCollection"], + "optionalPlugins": ["home", "share", "usageCollection", "savedObjectsTaggingOss"], "server": true, "ui": true, "requiredBundles": ["kibanaUtils", "kibanaReact", "home"] diff --git a/src/plugins/dashboard/public/application/application.ts b/src/plugins/dashboard/public/application/application.ts index 6fe8f403f1a31..c06f4edc152ef 100644 --- a/src/plugins/dashboard/public/application/application.ts +++ b/src/plugins/dashboard/public/application/application.ts @@ -44,6 +44,7 @@ import { SharePluginStart } from '../../../share/public'; import { KibanaLegacyStart, configureAppAngularModule } from '../../../kibana_legacy/public'; import { UrlForwardingStart } from '../../../url_forwarding/public'; import { SavedObjectLoader, SavedObjectsStart } from '../../../saved_objects/public'; +import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; // required for i18nIdDirective import 'angular-sanitize'; @@ -76,6 +77,7 @@ export interface RenderDeps { scopedHistory: () => ScopedHistory; setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; savedObjects: SavedObjectsStart; + savedObjectsTagging?: SavedObjectsTaggingApi; restorePreviousUrl: () => void; } diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx index e5947b73b305b..35ee1d82d8ec4 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx @@ -37,7 +37,8 @@ import { distinctUntilChanged, } from 'rxjs/operators'; import { History } from 'history'; -import { SavedObjectSaveOpts } from 'src/plugins/saved_objects/public'; +import { SavedObjectSaveOpts, SavedObject } from 'src/plugins/saved_objects/public'; +import type { TagDecoratedSavedObject } from 'src/plugins/saved_objects_tagging_oss/public'; import { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; import { DashboardEmptyScreen, DashboardEmptyScreenProps } from './dashboard_empty_screen'; @@ -70,7 +71,7 @@ import { import { NavAction, SavedDashboardPanel } from '../types'; import { showOptionsPopover } from './top_nav/show_options_popover'; -import { DashboardSaveModal } from './top_nav/save_modal'; +import { DashboardSaveModal, SaveOptions } from './top_nav/save_modal'; import { showCloneModal } from './top_nav/show_clone_modal'; import { saveDashboard } from './lib'; import { DashboardStateManager } from './dashboard_state_manager'; @@ -155,6 +156,7 @@ export class DashboardAppController { kbnUrlStateStorage, usageCollection, navigation, + savedObjectsTagging, }: DashboardAppControllerDependencies) { const filterManager = queryService.filterManager; const timefilter = queryService.timefilter.timefilter; @@ -180,6 +182,11 @@ export class DashboardAppController { .getStateTransfer(scopedHistory()) .getIncomingEmbeddablePackage(); + // TS is picky with type guards, we can't just inline `() => false` + function defaultTaggingGuard(obj: SavedObject): obj is TagDecoratedSavedObject { + return false; + } + const dashboardStateManager = new DashboardStateManager({ savedDashboard: dash, hideWriteControls: dashboardConfig.getHideWriteControls(), @@ -187,6 +194,7 @@ export class DashboardAppController { kbnUrlStateStorage, history, usageCollection, + hasTaggingCapabilities: savedObjectsTagging?.ui.hasTagDecoration ?? defaultTaggingGuard, }); // sync initial app filters from state to filterManager @@ -882,6 +890,15 @@ export class DashboardAppController { const currentTitle = dashboardStateManager.getTitle(); const currentDescription = dashboardStateManager.getDescription(); const currentTimeRestore = dashboardStateManager.getTimeRestore(); + + let currentTags: string[] = []; + if (savedObjectsTagging) { + const dashboard = dashboardStateManager.savedDashboard; + if (savedObjectsTagging.ui.hasTagDecoration(dashboard)) { + currentTags = dashboard.getTags(); + } + } + const onSave = ({ newTitle, newDescription, @@ -889,18 +906,16 @@ export class DashboardAppController { newTimeRestore, isTitleDuplicateConfirmed, onTitleDuplicate, - }: { - newTitle: string; - newDescription: string; - newCopyOnSave: boolean; - newTimeRestore: boolean; - isTitleDuplicateConfirmed: boolean; - onTitleDuplicate: () => void; - }) => { + newTags, + }: SaveOptions) => { dashboardStateManager.setTitle(newTitle); dashboardStateManager.setDescription(newDescription); dashboardStateManager.savedDashboard.copyOnSave = newCopyOnSave; dashboardStateManager.setTimeRestore(newTimeRestore); + if (savedObjectsTagging && newTags) { + dashboardStateManager.setTags(newTags); + } + const saveOptions = { confirmOverwrite: false, isTitleDuplicateConfirmed, @@ -912,6 +927,9 @@ export class DashboardAppController { dashboardStateManager.setTitle(currentTitle); dashboardStateManager.setDescription(currentDescription); dashboardStateManager.setTimeRestore(currentTimeRestore); + if (savedObjectsTagging) { + dashboardStateManager.setTags(currentTags); + } } return response; }); @@ -923,6 +941,8 @@ export class DashboardAppController { onClose={() => {}} title={currentTitle} description={currentDescription} + tags={currentTags} + savedObjectsTagging={savedObjectsTagging} timeRestore={currentTimeRestore} showCopyOnSave={dash.id ? true : false} /> diff --git a/src/plugins/dashboard/public/application/dashboard_state.test.ts b/src/plugins/dashboard/public/application/dashboard_state.test.ts index 42425ebf2cacf..14c12115fd8f5 100644 --- a/src/plugins/dashboard/public/application/dashboard_state.test.ts +++ b/src/plugins/dashboard/public/application/dashboard_state.test.ts @@ -41,6 +41,11 @@ describe('DashboardState', function () { }, } as TimefilterContract; + // TS is *very* picky with type guards / predicates. can't just use jest.fn() + function mockHasTaggingCapabilities(obj: any): obj is any { + return false; + } + function initDashboardState() { dashboardState = new DashboardStateManager({ savedDashboard, @@ -48,6 +53,7 @@ describe('DashboardState', function () { kibanaVersion: '7.0.0', kbnUrlStateStorage: createKbnUrlStateStorage(), history: createBrowserHistory(), + hasTaggingCapabilities: mockHasTaggingCapabilities, }); } diff --git a/src/plugins/dashboard/public/application/dashboard_state_manager.ts b/src/plugins/dashboard/public/application/dashboard_state_manager.ts index 93a63b0535259..38479b1384477 100644 --- a/src/plugins/dashboard/public/application/dashboard_state_manager.ts +++ b/src/plugins/dashboard/public/application/dashboard_state_manager.ts @@ -25,6 +25,7 @@ import { History } from 'history'; import { Filter, Query, TimefilterContract as Timefilter } from 'src/plugins/data/public'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; +import type { SavedObjectTagDecoratorTypeGuard } from 'src/plugins/saved_objects_tagging_oss/public'; import { migrateLegacyQuery } from './lib/migrate_legacy_query'; import { ViewMode } from '../embeddable_plugin'; @@ -86,6 +87,7 @@ export class DashboardStateManager { private readonly stateSyncRef: ISyncStateRef; private readonly history: History; private readonly usageCollection: UsageCollectionSetup | undefined; + public readonly hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard; /** * @@ -101,6 +103,7 @@ export class DashboardStateManager { kbnUrlStateStorage, history, usageCollection, + hasTaggingCapabilities, }: { savedDashboard: SavedObjectDashboard; hideWriteControls: boolean; @@ -108,16 +111,18 @@ export class DashboardStateManager { kbnUrlStateStorage: IKbnUrlStateStorage; history: History; usageCollection?: UsageCollectionSetup; + hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard; }) { this.history = history; this.kibanaVersion = kibanaVersion; this.savedDashboard = savedDashboard; this.hideWriteControls = hideWriteControls; this.usageCollection = usageCollection; + this.hasTaggingCapabilities = hasTaggingCapabilities; // get state defaults from saved dashboard, make sure it is migrated this.stateDefaults = migrateAppState( - getAppStateDefaults(this.savedDashboard, this.hideWriteControls), + getAppStateDefaults(this.savedDashboard, this.hideWriteControls, this.hasTaggingCapabilities), kibanaVersion, usageCollection ); @@ -313,7 +318,7 @@ export class DashboardStateManager { // clone, but given how much code uses the state object, I determined that to be too risky of a change for // now. TODO: revisit this! this.stateDefaults = migrateAppState( - getAppStateDefaults(this.savedDashboard, this.hideWriteControls), + getAppStateDefaults(this.savedDashboard, this.hideWriteControls, this.hasTaggingCapabilities), this.kibanaVersion, this.usageCollection ); @@ -355,6 +360,10 @@ export class DashboardStateManager { return this.appState.description; } + public getTags() { + return this.appState.tags; + } + public setDescription(description: string) { this.stateContainer.transitions.set('description', description); } @@ -364,6 +373,10 @@ export class DashboardStateManager { this.stateContainer.transitions.set('title', title); } + public setTags(tags: string[]) { + this.stateContainer.transitions.set('tags', tags); + } + public getAppState() { return this.stateContainer.get(); } diff --git a/src/plugins/dashboard/public/application/legacy_app.js b/src/plugins/dashboard/public/application/legacy_app.js index abe04fb8bd7e3..3867991d94295 100644 --- a/src/plugins/dashboard/public/application/legacy_app.js +++ b/src/plugins/dashboard/public/application/legacy_app.js @@ -51,6 +51,7 @@ export function initDashboardApp(app, deps) { ['hideWriteControls', { watchDepth: 'reference' }], ['initialFilter', { watchDepth: 'reference' }], ['initialPageSize', { watchDepth: 'reference' }], + ['taggingApi', { watchDepth: 'reference' }], ]); }); @@ -113,11 +114,26 @@ export function initDashboardApp(app, deps) { $scope.listingLimit = deps.savedObjects.settings.getListingLimit(); $scope.initialPageSize = deps.savedObjects.settings.getPerPage(); + $scope.taggingApi = deps.savedObjectsTagging; $scope.create = () => { history.push(DashboardConstants.CREATE_NEW_DASHBOARD_URL); }; - $scope.find = (search) => { - return service.find(search, $scope.listingLimit); + $scope.find = async (search) => { + let searchTerm = search; + let references = undefined; + + if (deps.savedObjectsTagging) { + const parsed = deps.savedObjectsTagging.ui.parseSearchQuery(search, { + useName: true, + }); + searchTerm = parsed.searchTerm; + references = parsed.tagReferences; + } + + return service.find(searchTerm, { + size: $scope.listingLimit, + hasReference: references, + }); }; $scope.editItem = ({ id }) => { history.push(`${createDashboardEditUrl(id)}?_a=(viewMode:edit)`); diff --git a/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts b/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts index f008c787cb95d..5599aafe688f0 100644 --- a/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts +++ b/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts @@ -17,18 +17,21 @@ * under the License. */ +import type { SavedObjectTagDecoratorTypeGuard } from 'src/plugins/saved_objects_tagging_oss/public'; import { ViewMode } from '../../embeddable_plugin'; import { SavedObjectDashboard } from '../../saved_dashboards'; import { DashboardAppStateDefaults } from '../../types'; export function getAppStateDefaults( savedDashboard: SavedObjectDashboard, - hideWriteControls: boolean + hideWriteControls: boolean, + hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard ): DashboardAppStateDefaults { return { fullScreenMode: false, title: savedDashboard.title, description: savedDashboard.description || '', + tags: hasTaggingCapabilities(savedDashboard) ? savedDashboard.getTags() : [], timeRestore: savedDashboard.timeRestore, panels: savedDashboard.panelsJSON ? JSON.parse(savedDashboard.panelsJSON) : [], options: savedDashboard.optionsJSON ? JSON.parse(savedDashboard.optionsJSON) : {}, diff --git a/src/plugins/dashboard/public/application/lib/save_dashboard.ts b/src/plugins/dashboard/public/application/lib/save_dashboard.ts index c948c25cb2ab5..9560b3d90892c 100644 --- a/src/plugins/dashboard/public/application/lib/save_dashboard.ts +++ b/src/plugins/dashboard/public/application/lib/save_dashboard.ts @@ -38,8 +38,9 @@ export function saveDashboard( ): Promise { const savedDashboard = dashboardStateManager.savedDashboard; const appState = dashboardStateManager.appState; + const hasTaggingCapabilities = dashboardStateManager.hasTaggingCapabilities; - updateSavedDashboard(savedDashboard, appState, timeFilter, toJson); + updateSavedDashboard(savedDashboard, appState, timeFilter, hasTaggingCapabilities, toJson); return savedDashboard.save(saveOptions).then((id: string) => { if (id) { diff --git a/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts b/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts index 72d3ffe6b2322..9a4fa0822d5af 100644 --- a/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts +++ b/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts @@ -19,6 +19,7 @@ import _ from 'lodash'; import { RefreshInterval, TimefilterContract } from 'src/plugins/data/public'; +import type { SavedObjectTagDecoratorTypeGuard } from 'src/plugins/saved_objects_tagging_oss/public'; import { FilterUtils } from './filter_utils'; import { SavedObjectDashboard } from '../../saved_dashboards'; import { DashboardAppState } from '../../types'; @@ -28,6 +29,7 @@ export function updateSavedDashboard( savedDashboard: SavedObjectDashboard, appState: DashboardAppState, timeFilter: TimefilterContract, + hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard, toJson: (object: T) => string ) { savedDashboard.title = appState.title; @@ -36,6 +38,10 @@ export function updateSavedDashboard( savedDashboard.panelsJSON = toJson(appState.panels); savedDashboard.optionsJSON = toJson(appState.options); + if (hasTaggingCapabilities(savedDashboard)) { + savedDashboard.setTags(appState.tags); + } + savedDashboard.timeFrom = savedDashboard.timeRestore ? FilterUtils.convertTimeToUTCString(timeFilter.getTime().from) : undefined; diff --git a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap index 82c90530c2b4c..94a9c646a403c 100644 --- a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap +++ b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap @@ -31,6 +31,7 @@ exports[`after fetch hideWriteControls 1`] = ` /> } + searchFilters={Array []} tableColumns={ Array [ Object { @@ -133,6 +134,7 @@ exports[`after fetch initialFilter 1`] = ` /> } + searchFilters={Array []} tableColumns={ Array [ Object { @@ -235,6 +237,7 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = ` /> } + searchFilters={Array []} tableColumns={ Array [ Object { @@ -337,6 +340,7 @@ exports[`after fetch renders table rows 1`] = ` /> } + searchFilters={Array []} tableColumns={ Array [ Object { @@ -439,6 +443,7 @@ exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` /> } + searchFilters={Array []} tableColumns={ Array [ Object { @@ -540,6 +545,7 @@ exports[`renders empty page in before initial fetch to avoid flickering 1`] = ` /> } + searchFilters={Array []} tableColumns={ Array [ Object { diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.js b/src/plugins/dashboard/public/application/listing/dashboard_listing.js index 1a7a6b1d75234..31e5bcf83150b 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.js +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.js @@ -63,6 +63,11 @@ export class DashboardListing extends React.Component { })} toastNotifications={this.props.core.notifications.toasts} uiSettings={this.props.core.uiSettings} + searchFilters={ + this.props.taggingApi + ? [this.props.taggingApi.ui.getSearchBarFilter({ useName: true })] + : [] + } /> ); @@ -150,6 +155,8 @@ export class DashboardListing extends React.Component { } getTableColumns() { + const { taggingApi } = this.props; + const tableColumns = [ { field: 'title', @@ -174,6 +181,7 @@ export class DashboardListing extends React.Component { dataType: 'string', sortable: true, }, + ...(taggingApi ? [taggingApi.ui.getTableColumnDefinition()] : []), ]; return tableColumns; } @@ -189,6 +197,7 @@ DashboardListing.propTypes = { hideWriteControls: PropTypes.bool.isRequired, initialFilter: PropTypes.string, initialPageSize: PropTypes.number.isRequired, + taggingApi: PropTypes.object, }; DashboardListing.defaultProps = { diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html b/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html index ba05c138a0cba..dd0a40f71beb8 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html @@ -9,4 +9,5 @@ hide-write-controls="hideWriteControls" initial-filter="initialFilter" initial-page-size="initialPageSize" + tagging-api="taggingApi" > diff --git a/src/plugins/dashboard/public/application/top_nav/save_modal.tsx b/src/plugins/dashboard/public/application/top_nav/save_modal.tsx index 609ed23472924..71c3623805462 100644 --- a/src/plugins/dashboard/public/application/top_nav/save_modal.tsx +++ b/src/plugins/dashboard/public/application/top_nav/save_modal.tsx @@ -21,11 +21,13 @@ import React, { Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFormRow, EuiTextArea, EuiSwitch } from '@elastic/eui'; +import type { SavedObjectsTaggingApi } from '../../../../saved_objects_tagging_oss/public'; import { SavedObjectSaveModal } from '../../../../saved_objects/public'; -interface SaveOptions { +export interface SaveOptions { newTitle: string; newDescription: string; + newTags?: string[]; newCopyOnSave: boolean; newTimeRestore: boolean; isTitleDuplicateConfirmed: boolean; @@ -33,23 +35,19 @@ interface SaveOptions { } interface Props { - onSave: ({ - newTitle, - newDescription, - newCopyOnSave, - newTimeRestore, - isTitleDuplicateConfirmed, - onTitleDuplicate, - }: SaveOptions) => void; + onSave: (options: SaveOptions) => void; onClose: () => void; title: string; description: string; + tags?: string[]; timeRestore: boolean; showCopyOnSave: boolean; + savedObjectsTagging?: SavedObjectsTaggingApi; } interface State { description: string; + tags: string[]; timeRestore: boolean; } @@ -57,6 +55,7 @@ export class DashboardSaveModal extends React.Component { state: State = { description: this.props.description, timeRestore: this.props.timeRestore, + tags: this.props.tags ?? [], }; constructor(props: Props) { @@ -81,6 +80,7 @@ export class DashboardSaveModal extends React.Component { newTimeRestore: this.state.timeRestore, isTitleDuplicateConfirmed, onTitleDuplicate, + newTags: this.state.tags, }); }; @@ -97,6 +97,18 @@ export class DashboardSaveModal extends React.Component { }; renderDashboardSaveOptions() { + const { savedObjectsTagging } = this.props; + const tagSelector = savedObjectsTagging ? ( + { + this.setState({ + tags, + }); + }} + /> + ) : undefined; + return ( { /> + {tagSelector} + this.currentHistory!, setHeaderActionMenu: params.setHeaderActionMenu, savedObjects, + savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(), restorePreviousUrl, }; // make sure the index pattern list is up to date diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index 2764f4b075579..1af739c34b76a 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -81,6 +81,7 @@ export interface DashboardAppState { fullScreenMode: boolean; title: string; description: string; + tags: string[]; timeRestore: boolean; options: { hidePanelTitles: boolean; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index a3edbbd3844b3..6aa121c6aef90 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -893,7 +893,7 @@ export class Plugin implements Plugin_2 Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick) => Promise; }; search: ISearchStart>; }; diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx index b6efa40c5e40b..5e6ac6df642fd 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx @@ -36,16 +36,13 @@ import { EuiConfirmModal, EuiCallOut, EuiBasicTableColumn, + EuiTableActionsColumnType, + SearchFilterConfig, } from '@elastic/eui'; + import { HttpFetchError, ToastsStart } from 'kibana/public'; import { toMountPoint } from '../util'; -interface Column { - name: string; - width?: string; - actions?: object[]; -} - interface Item { id?: string; } @@ -61,8 +58,7 @@ export interface TableListViewProps { initialFilter: string; initialPageSize: number; noItemsFragment: JSX.Element; - // update possible column types to something like (FieldDataColumn | ComputedColumn | ActionsColumn)[] when they have been added to EUI - tableColumns: Column[]; + tableColumns: Array>; tableListTitle: string; toastNotifications: ToastsStart; /** @@ -70,6 +66,7 @@ export interface TableListViewProps { * If the table is not empty, this component renders its own h1 element using the same id. */ headingId?: string; + searchFilters?: SearchFilterConfig[]; } export interface TableListViewState { @@ -402,6 +399,8 @@ class TableListView extends React.Component { @@ -414,7 +413,7 @@ class TableListView extends React.Component['actions'] = [ { name: i18n.translate('kibana-react.tableListView.listing.table.editActionName', { defaultMessage: 'Edit', @@ -439,6 +438,7 @@ class TableListView extends React.Component>} // EuiBasicTableColumn is stricter than Column + columns={columns} pagination={this.pagination} loading={this.state.isFetchingItems} message={noItemsMessage} diff --git a/src/plugins/saved_objects/public/index.ts b/src/plugins/saved_objects/public/index.ts index ecf6aa0569bf7..0a0d298600fd2 100644 --- a/src/plugins/saved_objects/public/index.ts +++ b/src/plugins/saved_objects/public/index.ts @@ -23,6 +23,7 @@ export { OnSaveProps, SavedObjectSaveModal, SavedObjectSaveModalOrigin, + OriginSaveModalProps, SaveModalState, SaveResult, showSaveModal, @@ -30,12 +31,16 @@ export { export { getSavedObjectFinder, SavedObjectFinderUi, SavedObjectMetaData } from './finder'; export { SavedObjectLoader, + SavedObjectLoaderFindOptions, checkForDuplicateTitle, saveWithConfirmation, isErrorNonFatal, + SavedObjectDecorator, + SavedObjectDecoratorFactory, + SavedObjectDecoratorConfig, } from './saved_object'; -export { SavedObjectSaveOpts, SavedObjectKibanaServices, SavedObject } from './types'; +export { SavedObjectSaveOpts, SavedObject, SavedObjectConfig } from './types'; export { PER_PAGE_SETTING, LISTING_LIMIT_SETTING } from '../common'; -export { SavedObjectsStart } from './plugin'; +export { SavedObjectsStart, SavedObjectSetup } from './plugin'; export const plugin = () => new SavedObjectsPublicPlugin(); diff --git a/src/plugins/saved_objects/public/mocks.ts b/src/plugins/saved_objects/public/mocks.ts index d34a6ded7c8de..ee9491a3abf4b 100644 --- a/src/plugins/saved_objects/public/mocks.ts +++ b/src/plugins/saved_objects/public/mocks.ts @@ -17,7 +17,7 @@ * under the License. */ -import { SavedObjectsStart } from './plugin'; +import { SavedObjectsStart, SavedObjectSetup } from './plugin'; const createStartContract = (): SavedObjectsStart => { return { @@ -29,6 +29,13 @@ const createStartContract = (): SavedObjectsStart => { }; }; +const createSetupContract = (): jest.Mocked => { + return { + registerDecorator: jest.fn(), + }; +}; + export const savedObjectsPluginMock = { createStartContract, + createSetupContract, }; diff --git a/src/plugins/saved_objects/public/plugin.ts b/src/plugins/saved_objects/public/plugin.ts index 0c50180e13d86..a1cef159984d1 100644 --- a/src/plugins/saved_objects/public/plugin.ts +++ b/src/plugins/saved_objects/public/plugin.ts @@ -20,11 +20,19 @@ import { CoreStart, Plugin } from 'src/core/public'; import './index.scss'; -import { createSavedObjectClass } from './saved_object'; +import { + createSavedObjectClass, + SavedObjectDecoratorRegistry, + SavedObjectDecoratorConfig, +} from './saved_object'; import { DataPublicPluginStart } from '../../data/public'; import { PER_PAGE_SETTING, LISTING_LIMIT_SETTING } from '../common'; import { SavedObject } from './types'; +export interface SavedObjectSetup { + registerDecorator: (config: SavedObjectDecoratorConfig) => void; +} + export interface SavedObjectsStart { SavedObjectClass: new (raw: Record) => SavedObject; settings: { @@ -38,17 +46,26 @@ export interface SavedObjectsStartDeps { } export class SavedObjectsPublicPlugin - implements Plugin { - public setup() {} + implements Plugin { + private decoratorRegistry = new SavedObjectDecoratorRegistry(); + + public setup(): SavedObjectSetup { + return { + registerDecorator: (config) => this.decoratorRegistry.register(config), + }; + } public start(core: CoreStart, { data }: SavedObjectsStartDeps) { return { - SavedObjectClass: createSavedObjectClass({ - indexPatterns: data.indexPatterns, - savedObjectsClient: core.savedObjects.client, - search: data.search, - chrome: core.chrome, - overlays: core.overlays, - }), + SavedObjectClass: createSavedObjectClass( + { + indexPatterns: data.indexPatterns, + savedObjectsClient: core.savedObjects.client, + search: data.search, + chrome: core.chrome, + overlays: core.overlays, + }, + this.decoratorRegistry + ), settings: { getPerPage: () => core.uiSettings.get(PER_PAGE_SETTING), getListingLimit: () => core.uiSettings.get(LISTING_LIMIT_SETTING), diff --git a/src/plugins/saved_objects/public/save_modal/index.ts b/src/plugins/saved_objects/public/save_modal/index.ts index 7c32337bb314a..e3c8fb992b6ea 100644 --- a/src/plugins/saved_objects/public/save_modal/index.ts +++ b/src/plugins/saved_objects/public/save_modal/index.ts @@ -18,5 +18,5 @@ */ export { SavedObjectSaveModal, OnSaveProps, SaveModalState } from './saved_object_save_modal'; -export { SavedObjectSaveModalOrigin } from './saved_object_save_modal_origin'; +export { SavedObjectSaveModalOrigin, OriginSaveModalProps } from './saved_object_save_modal_origin'; export { showSaveModal, SaveResult } from './show_saved_object_save_modal'; diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx index dfc0c4049774d..bfedb268839e1 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx @@ -30,7 +30,7 @@ interface SaveModalDocumentInfo { description?: string; } -interface OriginSaveModalProps { +export interface OriginSaveModalProps { originatingApp?: string; getAppNameFromId?: (appId: string) => string | undefined; originatingAppName?: string; @@ -38,6 +38,7 @@ interface OriginSaveModalProps { documentInfo: SaveModalDocumentInfo; objectType: string; onClose: () => void; + options?: React.ReactNode | ((state: SaveModalState) => React.ReactNode); onSave: (props: OnSaveProps & { returnToOrigin: boolean }) => void; } @@ -53,8 +54,11 @@ export function SavedObjectSaveModalOrigin(props: OriginSaveModalProps) { }); const getReturnToOriginSwitch = (state: SaveModalState) => { + const sourceOptions = + typeof props.options === 'function' ? props.options(state) : props.options; + if (!props.originatingApp) { - return; + return sourceOptions; } const origin = props.getAppNameFromId ? props.getAppNameFromId(props.originatingApp) || props.originatingApp @@ -67,6 +71,7 @@ export function SavedObjectSaveModalOrigin(props: OriginSaveModalProps) { const originVerb = !documentInfo.id || state.copyOnSave ? addLabel : returnLabel; return ( + {sourceOptions} { + const mock: jest.Mocked = { + register: jest.fn(), + getOrderedDecorators: jest.fn(), + }; + + mock.getOrderedDecorators.mockReturnValue([]); + + return mock; +}; + +export const savedObjectsDecoratorRegistryMock = { + create: createRegistryMock, +}; diff --git a/src/plugins/saved_objects/public/saved_object/decorators/registry.test.ts b/src/plugins/saved_objects/public/saved_object/decorators/registry.test.ts new file mode 100644 index 0000000000000..cfa363372c9bf --- /dev/null +++ b/src/plugins/saved_objects/public/saved_object/decorators/registry.test.ts @@ -0,0 +1,136 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectDecoratorRegistry } from './registry'; + +const mockDecorator = (id: string = 'foo') => { + return { + getId: () => id, + decorateConfig: () => undefined, + decorateObject: () => undefined, + }; +}; + +describe('SavedObjectDecoratorRegistry', () => { + let registry: SavedObjectDecoratorRegistry; + + beforeEach(() => { + registry = new SavedObjectDecoratorRegistry(); + }); + + describe('register', () => { + it('allow to register a decorator', () => { + expect(() => { + registry.register({ + id: 'foo', + priority: 9000, + factory: () => mockDecorator(), + }); + }).not.toThrow(); + }); + + it('throws when trying to register the same id twice', () => { + registry.register({ + id: 'foo', + priority: 9000, + factory: () => mockDecorator(), + }); + + expect(() => { + registry.register({ + id: 'foo', + priority: 42, + factory: () => mockDecorator(), + }); + }).toThrowErrorMatchingInlineSnapshot(`"A decorator is already registered for id foo"`); + }); + + it('throws when trying to register multiple decorators with the same priority', () => { + registry.register({ + id: 'foo', + priority: 100, + factory: () => mockDecorator(), + }); + + expect(() => { + registry.register({ + id: 'bar', + priority: 100, + factory: () => mockDecorator(), + }); + }).toThrowErrorMatchingInlineSnapshot(`"A decorator is already registered for priority 100"`); + }); + }); + + describe('getOrderedDecorators', () => { + it('returns the decorators in correct order', () => { + registry.register({ + id: 'A', + priority: 1000, + factory: () => mockDecorator('A'), + }); + registry.register({ + id: 'B', + priority: 100, + factory: () => mockDecorator('B'), + }); + registry.register({ + id: 'C', + priority: 2000, + factory: () => mockDecorator('C'), + }); + + const decorators = registry.getOrderedDecorators({} as any); + expect(decorators.map((d) => d.getId())).toEqual(['B', 'A', 'C']); + }); + + it('invoke the decorators factory with the provided services', () => { + const services = Symbol('services'); + + const decorator = { + id: 'foo', + priority: 9000, + factory: jest.fn(), + }; + registry.register(decorator); + registry.getOrderedDecorators(services as any); + + expect(decorator.factory).toHaveBeenCalledTimes(1); + expect(decorator.factory).toHaveBeenCalledWith(services); + }); + + it('invoke the factory each time the method is called', () => { + const services = Symbol('services'); + + const decorator = { + id: 'foo', + priority: 9000, + factory: jest.fn(), + }; + registry.register(decorator); + registry.getOrderedDecorators(services as any); + + expect(decorator.factory).toHaveBeenCalledTimes(1); + + registry.getOrderedDecorators(services as any); + + expect(decorator.factory).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/plugins/saved_objects/public/saved_object/decorators/registry.ts b/src/plugins/saved_objects/public/saved_object/decorators/registry.ts new file mode 100644 index 0000000000000..d2b3f44a3e65d --- /dev/null +++ b/src/plugins/saved_objects/public/saved_object/decorators/registry.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PublicMethodsOf } from '@kbn/utility-types'; +import { SavedObjectDecoratorFactory } from './types'; +import { SavedObjectKibanaServices, SavedObject } from '../../types'; + +export interface SavedObjectDecoratorConfig { + /** + * The id of the decorator + */ + id: string; + /** + * Highest priority will be called **last** + * (the decoration will be at the highest level) + */ + priority: number; + /** + * The factory to use to create the decorator + */ + factory: SavedObjectDecoratorFactory; +} + +export type ISavedObjectDecoratorRegistry = PublicMethodsOf; + +export class SavedObjectDecoratorRegistry { + private readonly registry = new Map>(); + + public register(config: SavedObjectDecoratorConfig) { + if (this.registry.has(config.id)) { + throw new Error(`A decorator is already registered for id ${config.id}`); + } + if ([...this.registry.values()].find(({ priority }) => priority === config.priority)) { + throw new Error(`A decorator is already registered for priority ${config.priority}`); + } + this.registry.set(config.id, config); + } + + public getOrderedDecorators(services: SavedObjectKibanaServices) { + return [...this.registry.values()] + .sort((a, b) => a.priority - b.priority) + .map(({ factory }) => factory(services)); + } +} diff --git a/src/plugins/saved_objects/public/saved_object/decorators/types.ts b/src/plugins/saved_objects/public/saved_object/decorators/types.ts new file mode 100644 index 0000000000000..4ed2a45aa9668 --- /dev/null +++ b/src/plugins/saved_objects/public/saved_object/decorators/types.ts @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObject, SavedObjectKibanaServices, SavedObjectConfig } from '../../types'; + +export interface SavedObjectDecorator { + /** + * Id of the decorator + */ + getId(): string; + + /** + * Decorate the saved object provided config. This can be used to enhance or alter the object's provided + * configuration. + */ + decorateConfig: (config: SavedObjectConfig) => void; + /** + * Decorate the saved object instance. Can be used to add additional methods to it. + * + * @remarks This will be called before the internal constructor of the object, meaning that + * wrapping existing methods is not possible (and is not a desired pattern). + */ + decorateObject: (object: T) => void; +} + +export type SavedObjectDecoratorFactory = ( + services: SavedObjectKibanaServices +) => SavedObjectDecorator; diff --git a/src/plugins/saved_objects/public/saved_object/helpers/apply_es_resp.ts b/src/plugins/saved_objects/public/saved_object/helpers/apply_es_resp.ts index 04fa3647de4c7..b1db5510fd531 100644 --- a/src/plugins/saved_objects/public/saved_object/helpers/apply_es_resp.ts +++ b/src/plugins/saved_objects/public/saved_object/helpers/apply_es_resp.ts @@ -36,12 +36,11 @@ export async function applyESResp( config: SavedObjectConfig, dependencies: SavedObjectKibanaServices ) { - const mapping = expandShorthand(config.mapping); - const esType = config.type || ''; + const mapping = expandShorthand(config.mapping ?? {}); + const savedObjectType = config.type || ''; savedObject._source = _.cloneDeep(resp._source); - const injectReferences = config.injectReferences; if (typeof resp.found === 'boolean' && !resp.found) { - throw new SavedObjectNotFound(esType, savedObject.id || ''); + throw new SavedObjectNotFound(savedObjectType, savedObject.id || ''); } const meta = resp._source.kibanaSavedObjectMeta || {}; @@ -101,6 +100,7 @@ export async function applyESResp( } } + const injectReferences = config.injectReferences; if (injectReferences && resp.references && resp.references.length > 0) { injectReferences(savedObject, resp.references); } diff --git a/src/plugins/saved_objects/public/saved_object/helpers/build_saved_object.ts b/src/plugins/saved_objects/public/saved_object/helpers/build_saved_object.ts index fdc8d79c9428a..59c1c4eae9d85 100644 --- a/src/plugins/saved_objects/public/saved_object/helpers/build_saved_object.ts +++ b/src/plugins/saved_objects/public/saved_object/helpers/build_saved_object.ts @@ -30,12 +30,27 @@ import { } from '../../types'; import { applyESResp } from './apply_es_resp'; import { saveSavedObject } from './save_saved_object'; +import { SavedObjectDecorator } from '../decorators'; + +const applyDecorators = ( + object: SavedObject, + config: SavedObjectConfig, + decorators: SavedObjectDecorator[] +) => { + decorators.forEach((decorator) => { + decorator.decorateConfig(config); + decorator.decorateObject(object); + }); +}; export function buildSavedObject( savedObject: SavedObject, - config: SavedObjectConfig = {}, - services: SavedObjectKibanaServices + config: SavedObjectConfig, + services: SavedObjectKibanaServices, + decorators: SavedObjectDecorator[] = [] ) { + applyDecorators(savedObject, config, decorators); + const { indexPatterns, savedObjectsClient } = services; // type name for this object, used as the ES-type const esType = config.type || ''; diff --git a/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts b/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts index a0ab527ce1743..a24c8fafde6e9 100644 --- a/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts +++ b/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts @@ -23,7 +23,7 @@ import { expandShorthand } from './field_mapping'; export function serializeSavedObject(savedObject: SavedObject, config: SavedObjectConfig) { // mapping definition for the fields that this object will expose - const mapping = expandShorthand(config.mapping); + const mapping = expandShorthand(config.mapping ?? {}); const attributes = {} as Record; const references = []; diff --git a/src/plugins/saved_objects/public/saved_object/index.ts b/src/plugins/saved_objects/public/saved_object/index.ts index 178ffaf88f4be..e4191ed384a67 100644 --- a/src/plugins/saved_objects/public/saved_object/index.ts +++ b/src/plugins/saved_objects/public/saved_object/index.ts @@ -18,7 +18,13 @@ */ export { createSavedObjectClass } from './saved_object'; -export { SavedObjectLoader } from './saved_object_loader'; +export { SavedObjectLoader, SavedObjectLoaderFindOptions } from './saved_object_loader'; export { checkForDuplicateTitle } from './helpers/check_for_duplicate_title'; export { saveWithConfirmation } from './helpers/save_with_confirmation'; export { isErrorNonFatal } from './helpers/save_saved_object'; +export { + SavedObjectDecoratorRegistry, + SavedObjectDecoratorFactory, + SavedObjectDecorator, + SavedObjectDecoratorConfig, +} from './decorators'; diff --git a/src/plugins/saved_objects/public/saved_object/saved_object.test.ts b/src/plugins/saved_objects/public/saved_object/saved_object.test.ts index 849e11dc3dd9f..e4f784e121058 100644 --- a/src/plugins/saved_objects/public/saved_object/saved_object.test.ts +++ b/src/plugins/saved_objects/public/saved_object/saved_object.test.ts @@ -25,12 +25,14 @@ import { SavedObjectKibanaServices, SavedObjectSaveOpts, } from '../types'; +import { SavedObjectDecorator } from './decorators'; import { coreMock } from '../../../../core/public/mocks'; import { dataPluginMock, createSearchSourceMock } from '../../../../plugins/data/public/mocks'; import { getStubIndexPattern, StubIndexPattern } from '../../../../plugins/data/public/test_utils'; import { SavedObjectAttributes, SimpleSavedObject } from 'kibana/public'; import { IIndexPattern } from '../../../data/common/index_patterns'; +import { savedObjectsDecoratorRegistryMock } from './decorators/registry.mock'; const getConfig = (cfg: any) => cfg; @@ -39,6 +41,7 @@ describe('Saved Object', () => { const dataStartMock = dataPluginMock.createStartContract(); const saveOptionsMock = {} as SavedObjectSaveOpts; const savedObjectsClientStub = startMock.savedObjects.client; + let decoratorRegistry: ReturnType; let SavedObjectClass: new (config: SavedObjectConfig) => SavedObject; @@ -94,26 +97,116 @@ describe('Saved Object', () => { * @returns {Promise} A promise that resolves with an instance of * SavedObject */ - function createInitializedSavedObject(config: SavedObjectConfig = {}) { + function createInitializedSavedObject(config: SavedObjectConfig = { type: 'dashboard' }) { const savedObject = new SavedObjectClass(config); savedObject.title = 'my saved object'; return savedObject.init!(); } + const initSavedObjectClass = () => { + SavedObjectClass = createSavedObjectClass( + ({ + savedObjectsClient: savedObjectsClientStub, + indexPatterns: dataStartMock.indexPatterns, + search: { + ...dataStartMock.search, + searchSource: { + ...dataStartMock.search.searchSource, + create: createSearchSourceMock, + createEmpty: createSearchSourceMock, + }, + }, + } as unknown) as SavedObjectKibanaServices, + decoratorRegistry + ); + }; + beforeEach(() => { - SavedObjectClass = createSavedObjectClass(({ - savedObjectsClient: savedObjectsClientStub, - indexPatterns: dataStartMock.indexPatterns, - search: { - ...dataStartMock.search, - searchSource: { - ...dataStartMock.search.searchSource, - create: createSearchSourceMock, - createEmpty: createSearchSourceMock, + decoratorRegistry = savedObjectsDecoratorRegistryMock.create(); + initSavedObjectClass(); + }); + + describe('decorators', () => { + it('calls the decorators during construct', () => { + const decorA = { + getId: () => 'A', + decorateConfig: jest.fn(), + decorateObject: jest.fn(), + }; + const decorB = { + getId: () => 'B', + decorateConfig: jest.fn(), + decorateObject: jest.fn(), + }; + + decoratorRegistry.getOrderedDecorators.mockReturnValue([decorA, decorB]); + + initSavedObjectClass(); + createInitializedSavedObject(); + + expect(decorA.decorateConfig).toHaveBeenCalledTimes(1); + expect(decorA.decorateObject).toHaveBeenCalledTimes(1); + }); + + it('calls the decorators in correct order', () => { + const decorA = { + getId: () => 'A', + decorateConfig: jest.fn(), + decorateObject: jest.fn(), + }; + const decorB = { + getId: () => 'B', + decorateConfig: jest.fn(), + decorateObject: jest.fn(), + }; + + decoratorRegistry.getOrderedDecorators.mockReturnValue([decorA, decorB]); + + initSavedObjectClass(); + createInitializedSavedObject(); + + expect(decorA.decorateConfig.mock.invocationCallOrder[0]).toBeLessThan( + decorB.decorateConfig.mock.invocationCallOrder[0] + ); + expect(decorA.decorateObject.mock.invocationCallOrder[0]).toBeLessThan( + decorB.decorateObject.mock.invocationCallOrder[0] + ); + }); + + it('passes the mutated config and object down the decorator chain', () => { + expect.assertions(2); + + const newMappingValue = 'string'; + const newObjectMethod = jest.fn(); + + const decorA: SavedObjectDecorator = { + getId: () => 'A', + decorateConfig: (config) => { + config.mapping = { + ...config.mapping, + addedFromA: newMappingValue, + }; + }, + decorateObject: (object) => { + (object as any).newMethod = newObjectMethod; + }, + }; + const decorB: SavedObjectDecorator = { + getId: () => 'B', + decorateConfig: (config) => { + expect(config.mapping!.addedFromA).toBe(newMappingValue); + }, + decorateObject: (object) => { + expect((object as any).newMethod).toBe(newObjectMethod); }, - }, - } as unknown) as SavedObjectKibanaServices); + }; + + decoratorRegistry.getOrderedDecorators.mockReturnValue([decorA, decorB]); + + initSavedObjectClass(); + createInitializedSavedObject(); + }); }); describe('save', () => { @@ -578,13 +671,16 @@ describe('Saved Object', () => { }); it('passes references to search source parsing function', async () => { - SavedObjectClass = createSavedObjectClass(({ - savedObjectsClient: savedObjectsClientStub, - indexPatterns: dataStartMock.indexPatterns, - search: { - ...dataStartMock.search, - }, - } as unknown) as SavedObjectKibanaServices); + SavedObjectClass = createSavedObjectClass( + ({ + savedObjectsClient: savedObjectsClientStub, + indexPatterns: dataStartMock.indexPatterns, + search: { + ...dataStartMock.search, + }, + } as unknown) as SavedObjectKibanaServices, + decoratorRegistry + ); const savedObject = new SavedObjectClass({ type: 'dashboard', searchSource: true }); return savedObject.init!().then(async () => { const searchSourceJSON = JSON.stringify({ diff --git a/src/plugins/saved_objects/public/saved_object/saved_object.ts b/src/plugins/saved_objects/public/saved_object/saved_object.ts index 45711cb6b3498..02b9169256eaf 100644 --- a/src/plugins/saved_objects/public/saved_object/saved_object.ts +++ b/src/plugins/saved_objects/public/saved_object/saved_object.ts @@ -28,9 +28,13 @@ * service and the saved object api. */ import { SavedObject, SavedObjectConfig, SavedObjectKibanaServices } from '../types'; +import { ISavedObjectDecoratorRegistry } from './decorators'; import { buildSavedObject } from './helpers/build_saved_object'; -export function createSavedObjectClass(services: SavedObjectKibanaServices) { +export function createSavedObjectClass( + services: SavedObjectKibanaServices, + decoratorRegistry: ISavedObjectDecoratorRegistry +) { /** * The SavedObject class is a base class for saved objects loaded from the server and * provides additional functionality besides loading/saving/deleting/etc. @@ -44,7 +48,7 @@ export function createSavedObjectClass(services: SavedObjectKibanaServices) { constructor(config: SavedObjectConfig = {}) { // @ts-ignore const self: SavedObject = this; - buildSavedObject(self, config, services); + buildSavedObject(self, config, services, decoratorRegistry.getOrderedDecorators(services)); } } diff --git a/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts b/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts index 6c791f5339def..a62d7526c45a0 100644 --- a/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts +++ b/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts @@ -16,10 +16,21 @@ * specific language governing permissions and limitations * under the License. */ -import { SavedObjectsClientContract, SavedObjectsFindOptions } from 'kibana/public'; +import { + SavedObjectsClientContract, + SavedObjectsFindOptions, + SavedObjectsFindOptionsReference, + SavedObjectReference, +} from 'kibana/public'; import { SavedObject } from '../types'; import { StringUtils } from './helpers/string_utils'; +export interface SavedObjectLoaderFindOptions { + size?: number; + fields?: string[]; + hasReference?: SavedObjectsFindOptionsReference[]; +} + /** * The SavedObjectLoader class provides some convenience functions * to load and save one kind of saved objects (specified in the constructor). @@ -80,15 +91,21 @@ export class SavedObjectLoader { } /** - * Updates source to contain an id and url field, and returns the updated + * Updates source to contain an id, url and references fields, and returns the updated * source object. * @param source * @param id + * @param references * @returns {source} The modified source object, with an id and url field. */ - mapHitSource(source: Record, id: string) { + mapHitSource( + source: Record, + id: string, + references: SavedObjectReference[] = [] + ) { source.id = id; source.url = this.urlFor(id); + source.references = references; return source; } @@ -98,8 +115,16 @@ export class SavedObjectLoader { * @param hit * @returns {hit.attributes} The modified hit.attributes object, with an id and url field. */ - mapSavedObjectApiHits(hit: { attributes: Record; id: string }) { - return this.mapHitSource(hit.attributes, hit.id); + mapSavedObjectApiHits({ + attributes, + id, + references = [], + }: { + attributes: Record; + id: string; + references?: SavedObjectReference[]; + }) { + return this.mapHitSource(attributes, id, references); } /** @@ -111,7 +136,10 @@ export class SavedObjectLoader { * @param fields * @returns {Promise} */ - findAll(search: string = '', size: number = 100, fields?: string[]) { + private findAll( + search: string = '', + { size = 100, fields, hasReference }: SavedObjectLoaderFindOptions + ) { return this.savedObjectsClient .find>({ type: this.lowercaseType, @@ -121,6 +149,7 @@ export class SavedObjectLoader { searchFields: ['title^3', 'description'], defaultSearchOperator: 'AND', fields, + hasReference, } as SavedObjectsFindOptions) .then((resp) => { return { @@ -130,8 +159,15 @@ export class SavedObjectLoader { }); } - find(search: string = '', size: number = 100) { - return this.findAll(search, size).then((resp) => { + find(search: string = '', sizeOrOptions: number | SavedObjectLoaderFindOptions = 100) { + const options: SavedObjectLoaderFindOptions = + typeof sizeOrOptions === 'number' + ? { + size: sizeOrOptions, + } + : sizeOrOptions; + + return this.findAll(search, options).then((resp) => { return { total: resp.total, hits: resp.hits.filter((savedObject) => !savedObject.error), diff --git a/src/plugins/saved_objects/public/types.ts b/src/plugins/saved_objects/public/types.ts index 6db72b396a86a..8734d171b9b18 100644 --- a/src/plugins/saved_objects/public/types.ts +++ b/src/plugins/saved_objects/public/types.ts @@ -79,22 +79,21 @@ export interface SavedObjectKibanaServices { overlays: OverlayStart; } +export interface SavedObjectAttributesAndRefs { + attributes: SavedObjectAttributes; + references: SavedObjectReference[]; +} + export interface SavedObjectConfig { // is only used by visualize afterESResp?: (savedObject: SavedObject) => Promise; defaults?: any; - extractReferences?: (opts: { - attributes: SavedObjectAttributes; - references: SavedObjectReference[]; - }) => { - attributes: SavedObjectAttributes; - references: SavedObjectReference[]; - }; + extractReferences?: (opts: SavedObjectAttributesAndRefs) => SavedObjectAttributesAndRefs; + injectReferences?: (object: T, references: SavedObjectReference[]) => void; id?: string; init?: () => void; indexPattern?: IIndexPattern; - injectReferences?: any; - mapping?: any; + mapping?: Record; migrationVersion?: Record; path?: string; searchSource?: ISearchSource | boolean; diff --git a/src/plugins/saved_objects_management/kibana.json b/src/plugins/saved_objects_management/kibana.json index a7a8f11034b7d..f062433605c53 100644 --- a/src/plugins/saved_objects_management/kibana.json +++ b/src/plugins/saved_objects_management/kibana.json @@ -4,7 +4,7 @@ "server": true, "ui": true, "requiredPlugins": ["management", "data"], - "optionalPlugins": ["dashboard", "visualizations", "discover", "home"], + "optionalPlugins": ["dashboard", "visualizations", "discover", "home", "savedObjectsTaggingOss"], "extraPublicDirs": ["public/lib"], "requiredBundles": ["kibanaReact", "home"] } diff --git a/src/plugins/saved_objects_management/public/lib/fetch_export_by_type_and_search.ts b/src/plugins/saved_objects_management/public/lib/fetch_export_by_type_and_search.ts index e0f005fab2a3b..2a41d329db99e 100644 --- a/src/plugins/saved_objects_management/public/lib/fetch_export_by_type_and_search.ts +++ b/src/plugins/saved_objects_management/public/lib/fetch_export_by_type_and_search.ts @@ -17,18 +17,26 @@ * under the License. */ -import { HttpStart } from 'src/core/public'; +import { HttpStart, SavedObjectsFindOptionsReference } from 'src/core/public'; -export async function fetchExportByTypeAndSearch( - http: HttpStart, - types: string[], - search: string | undefined, - includeReferencesDeep: boolean = false -): Promise { +export async function fetchExportByTypeAndSearch({ + http, + search, + types, + references, + includeReferencesDeep = false, +}: { + http: HttpStart; + types: string[]; + search?: string; + references?: SavedObjectsFindOptionsReference[]; + includeReferencesDeep?: boolean; +}): Promise { return http.post('/api/saved_objects/_export', { body: JSON.stringify({ type: types, search, + hasReference: references, includeReferencesDeep, }), }); diff --git a/src/plugins/saved_objects_management/public/lib/find_objects.ts b/src/plugins/saved_objects_management/public/lib/find_objects.ts index 530dcc4648d8c..0008763d96959 100644 --- a/src/plugins/saved_objects_management/public/lib/find_objects.ts +++ b/src/plugins/saved_objects_management/public/lib/find_objects.ts @@ -35,7 +35,12 @@ export async function findObjects( const response = await http.get>( '/api/kibana/management/saved_objects/_find', { - query: findOptions as Record, + query: { + ...findOptions, + hasReference: findOptions.hasReference + ? JSON.stringify(findOptions.hasReference) + : undefined, + } as Record, } ); diff --git a/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts b/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts index dcf59142e73e3..be0431f3201af 100644 --- a/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts +++ b/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts @@ -17,15 +17,21 @@ * under the License. */ -import { HttpStart } from 'src/core/public'; +import { HttpStart, SavedObjectsFindOptionsReference } from 'src/core/public'; -export async function getSavedObjectCounts( - http: HttpStart, - typesToInclude: string[], - searchString?: string -): Promise> { +export async function getSavedObjectCounts({ + http, + searchString, + typesToInclude, + references, +}: { + http: HttpStart; + typesToInclude: string[]; + searchString?: string; + references?: SavedObjectsFindOptionsReference[]; +}): Promise> { return await http.post>( `/api/kibana/management/saved_objects/scroll/counts`, - { body: JSON.stringify({ typesToInclude, searchString }) } + { body: JSON.stringify({ typesToInclude, searchString, references }) } ); } diff --git a/src/plugins/saved_objects_management/public/lib/get_tag_references.test.ts b/src/plugins/saved_objects_management/public/lib/get_tag_references.test.ts new file mode 100644 index 0000000000000..e4a70e101515c --- /dev/null +++ b/src/plugins/saved_objects_management/public/lib/get_tag_references.test.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { taggingApiMock } from '../../../saved_objects_tagging_oss/public/mocks'; +import { getTagFindReferences } from './get_tag_references'; + +const tagNameToRef = (name: string) => ({ + type: 'tag', + id: `id-of-${name}`, +}); + +describe('getTagFindReferences', () => { + let taggingApi: ReturnType; + const selectedTags = ['name-1', 'name-2']; + + beforeEach(() => { + taggingApi = taggingApiMock.create(); + taggingApi.ui.convertNameToReference.mockImplementation(tagNameToRef); + }); + + it('returns undefined if `taggingApi` is not provided', () => { + expect(getTagFindReferences({ selectedTags })).toBeUndefined(); + }); + it('returns undefined if `selectedTags` is not provided', () => { + expect(getTagFindReferences({ taggingApi })).toBeUndefined(); + }); + + it('returns the references for given names', () => { + expect(getTagFindReferences({ selectedTags, taggingApi })).toEqual( + selectedTags.map(tagNameToRef) + ); + }); + + it('ignores any unknown tag name', () => { + taggingApi.ui.convertNameToReference.mockImplementation((name) => { + if (name === 'name-1') { + return undefined; + } + return tagNameToRef(name); + }); + + expect(getTagFindReferences({ selectedTags, taggingApi })).toEqual([tagNameToRef('name-2')]); + }); +}); diff --git a/src/plugins/saved_objects_management/public/lib/get_tag_references.ts b/src/plugins/saved_objects_management/public/lib/get_tag_references.ts new file mode 100644 index 0000000000000..40a795abf901d --- /dev/null +++ b/src/plugins/saved_objects_management/public/lib/get_tag_references.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectsFindOptionsReference } from 'kibana/server'; +import { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; + +export const getTagFindReferences = ({ + selectedTags, + taggingApi, +}: { + selectedTags?: string[]; + taggingApi?: SavedObjectsTaggingApi; +}): SavedObjectsFindOptionsReference[] | undefined => { + if (taggingApi && selectedTags) { + const references: SavedObjectsFindOptionsReference[] = []; + selectedTags.forEach((tagName) => { + const ref = taggingApi.ui.convertNameToReference(tagName); + if (ref) { + references.push(ref); + } + }); + return references; + } + return undefined; +}; diff --git a/src/plugins/saved_objects_management/public/lib/index.ts b/src/plugins/saved_objects_management/public/lib/index.ts index 9ed5b1907cecb..aad5b77c3af95 100644 --- a/src/plugins/saved_objects_management/public/lib/index.ts +++ b/src/plugins/saved_objects_management/public/lib/index.ts @@ -45,3 +45,4 @@ export { findObjects, findObject } from './find_objects'; export { extractExportDetails, SavedObjectsExportResultDetails } from './extract_export_details'; export { createFieldList } from './create_field_list'; export { getAllowedTypes } from './get_allowed_types'; +export { getTagFindReferences } from './get_tag_references'; diff --git a/src/plugins/saved_objects_management/public/lib/parse_query.test.ts b/src/plugins/saved_objects_management/public/lib/parse_query.test.ts index f62234eaf4e94..d30ed5866d248 100644 --- a/src/plugins/saved_objects_management/public/lib/parse_query.test.ts +++ b/src/plugins/saved_objects_management/public/lib/parse_query.test.ts @@ -17,14 +17,51 @@ * under the License. */ +import { Query } from '@elastic/eui'; import { parseQuery } from './parse_query'; describe('getQueryText', () => { - it('should know how to get the text out of the AST', () => { - const ast = { - getTermClauses: () => [{ value: 'foo' }, { value: 'bar' }], - getFieldClauses: () => [{ value: 'lala' }, { value: 'lolo' }], - }; - expect(parseQuery({ ast } as any)).toEqual({ queryText: 'foo bar', visibleTypes: 'lala' }); + it('parses the query text', () => { + const query = Query.parse('some search'); + + expect(parseQuery(query)).toEqual({ + queryText: 'some search', + }); + }); + + it('parses the types', () => { + const query = Query.parse('type:(index-pattern or dashboard) kibana'); + + expect(parseQuery(query)).toEqual({ + queryText: 'kibana', + visibleTypes: ['index-pattern', 'dashboard'], + }); + }); + + it('parses the tags', () => { + const query = Query.parse('tag:(tag-1 or tag-2) kibana'); + + expect(parseQuery(query)).toEqual({ + queryText: 'kibana', + selectedTags: ['tag-1', 'tag-2'], + }); + }); + + it('parses all the fields', () => { + const query = Query.parse('tag:(tag-1 or tag-2) type:(index-pattern) kibana'); + + expect(parseQuery(query)).toEqual({ + queryText: 'kibana', + visibleTypes: ['index-pattern'], + selectedTags: ['tag-1', 'tag-2'], + }); + }); + + it('does not fail on unknown fields', () => { + const query = Query.parse('unknown:(hello or dolly) some search'); + + expect(parseQuery(query)).toEqual({ + queryText: 'some search', + }); }); }); diff --git a/src/plugins/saved_objects_management/public/lib/parse_query.ts b/src/plugins/saved_objects_management/public/lib/parse_query.ts index f5b7b69ea049c..6de46b1aa0262 100644 --- a/src/plugins/saved_objects_management/public/lib/parse_query.ts +++ b/src/plugins/saved_objects_management/public/lib/parse_query.ts @@ -22,11 +22,13 @@ import { Query } from '@elastic/eui'; interface ParsedQuery { queryText?: string; visibleTypes?: string[]; + selectedTags?: string[]; } export function parseQuery(query: Query): ParsedQuery { let queryText: string | undefined; let visibleTypes: string[] | undefined; + let selectedTags: string[] | undefined; if (query) { if (query.ast.getTermClauses().length) { @@ -38,10 +40,14 @@ export function parseQuery(query: Query): ParsedQuery { if (query.ast.getFieldClauses('type')) { visibleTypes = query.ast.getFieldClauses('type')[0].value as string[]; } + if (query.ast.getFieldClauses('tag')) { + selectedTags = query.ast.getFieldClauses('tag')[0].value as string[]; + } } return { queryText, visibleTypes, + selectedTags, }; } diff --git a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx index 09e9ac29d664b..bfac041056cc3 100644 --- a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx +++ b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx @@ -48,7 +48,7 @@ export const mountManagementSection = async ({ mountParams, serviceRegistry, }: MountParams) => { - const [coreStart, { data }, pluginStart] = await core.getStartServices(); + const [coreStart, { data, savedObjectsTaggingOss }, pluginStart] = await core.getStartServices(); const { element, history, setBreadcrumbs } = mountParams; if (allowedObjectTypes === undefined) { allowedObjectTypes = await getAllowedTypes(coreStart.http); @@ -89,6 +89,7 @@ export const mountManagementSection = async ({ }> void; canGoInApp: (obj: SavedObjectWithMetadata) => boolean; + initialQuery?: QueryType; } interface TableState { @@ -161,6 +165,7 @@ export class Table extends PureComponent { basePath, actionRegistry, columnRegistry, + taggingApi, } = this.props; const pagination = { @@ -180,14 +185,7 @@ export class Table extends PureComponent { multiSelect: 'or', options: filterOptions, }, - // Add this back in once we have tag support - // { - // type: 'field_value_selection', - // field: 'tag', - // name: 'Tags', - // multiSelect: 'or', - // options: [], - // }, + ...(taggingApi ? [taggingApi.ui.getSearchBarFilter({ useName: true })] : []), ]; const columns = [ @@ -240,6 +238,7 @@ export class Table extends PureComponent { ); }, } as EuiTableFieldDataColumnType>, + ...(taggingApi ? [taggingApi.ui.getTableColumnDefinition()] : []), ...columnRegistry.getAll().map((column) => { return { ...column.euiColumn, @@ -348,6 +347,7 @@ export class Table extends PureComponent { box={{ 'data-test-subj': 'savedObjectSearchBar' }} filters={filters as any} onChange={this.onChange} + defaultQuery={this.props.initialQuery} toolsRight={[ { await component.instance().onExportAll(); - expect(fetchExportByTypeAndSearchMock).toHaveBeenCalledWith( + expect(fetchExportByTypeAndSearchMock).toHaveBeenCalledWith({ http, - allowedTypes, - undefined, - true - ); + types: allowedTypes, + includeReferencesDeep: true, + }); expect(saveAsMock).toHaveBeenCalledWith(blob, 'export.ndjson'); expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ title: 'Your file is downloading in the background', @@ -372,12 +371,12 @@ describe('SavedObjectsTable', () => { await component.instance().onExportAll(); - expect(fetchExportByTypeAndSearchMock).toHaveBeenCalledWith( + expect(fetchExportByTypeAndSearchMock).toHaveBeenCalledWith({ http, - allowedTypes, - 'test*', - true - ); + types: allowedTypes, + search: 'test*', + includeReferencesDeep: true, + }); expect(saveAsMock).toHaveBeenCalledWith(blob, 'export.ndjson'); expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ title: 'Your file is downloading in the background', diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index 5011c0299abe8..d2db9ab711c57 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -56,6 +56,7 @@ import { ApplicationStart, } from 'src/core/public'; import { RedirectAppLinks } from '../../../../kibana_react/public'; +import { SavedObjectsTaggingApi } from '../../../../saved_objects_tagging_oss/public'; import { IndexPatternsContract } from '../../../../data/public'; import { parseQuery, @@ -68,6 +69,7 @@ import { findObject, extractExportDetails, SavedObjectsExportResultDetails, + getTagFindReferences, } from '../../lib'; import { SavedObjectWithMetadata } from '../../types'; import { @@ -90,6 +92,7 @@ export interface SavedObjectsTableProps { columnRegistry: SavedObjectsManagementColumnServiceStart; savedObjectsClient: SavedObjectsClientContract; indexPatterns: IndexPatternsContract; + taggingApi?: SavedObjectsTaggingApi; http: HttpStart; search: DataPublicPluginStart['search']; overlays: OverlayStart; @@ -98,6 +101,7 @@ export interface SavedObjectsTableProps { perPageConfig: number; goInspectObject: (obj: SavedObjectWithMetadata) => void; canGoInApp: (obj: SavedObjectWithMetadata) => boolean; + initialQuery?: Query; } export interface SavedObjectsTableState { @@ -136,7 +140,7 @@ export class SavedObjectsTable extends Component), - activeQuery: Query.parse(''), + activeQuery: props.initialQuery ?? Query.parse(''), selectedSavedObjects: [], isShowingImportFlyout: false, isSearching: false, @@ -164,37 +168,46 @@ export class SavedObjectsTable extends Component { - const { allowedTypes } = this.props; - const { queryText, visibleTypes } = parseQuery(this.state.activeQuery); + const { allowedTypes, taggingApi } = this.props; + const { queryText, visibleTypes, selectedTags } = parseQuery(this.state.activeQuery); - const filteredTypes = allowedTypes.filter( + const selectedTypes = allowedTypes.filter( (type) => !visibleTypes || visibleTypes.includes(type) ); - // These are the saved objects visible in the table. - const filteredSavedObjectCounts = await getSavedObjectCounts( - this.props.http, - filteredTypes, - queryText - ); + const references = getTagFindReferences({ selectedTags, taggingApi }); - const exportAllOptions: ExportAllOption[] = []; - const exportAllSelectedOptions: Record = {}; + // These are the saved objects visible in the table. + const filteredSavedObjectCounts = await getSavedObjectCounts({ + http: this.props.http, + typesToInclude: selectedTypes, + searchString: queryText, + references, + }); - Object.keys(filteredSavedObjectCounts).forEach((id) => { - // Add this type as a bulk-export option. - exportAllOptions.push({ + const exportAllOptions: ExportAllOption[] = Object.entries(filteredSavedObjectCounts).map( + ([id, count]) => ({ id, - label: `${id} (${filteredSavedObjectCounts[id] || 0})`, - }); - - // Select it by default. - exportAllSelectedOptions[id] = true; - }); + label: `${id} (${count || 0})`, + }) + ); + const exportAllSelectedOptions: Record = exportAllOptions.reduce( + (record, { id }) => { + return { + ...record, + [id]: true, + }; + }, + {} + ); // Fetch all the saved objects that exist so we can accurately populate the counts within // the table filter dropdown. - const savedObjectCounts = await getSavedObjectCounts(this.props.http, allowedTypes, queryText); + const savedObjectCounts = await getSavedObjectCounts({ + http: this.props.http, + typesToInclude: allowedTypes, + searchString: queryText, + }); this.setState((state) => ({ ...state, @@ -214,8 +227,8 @@ export class SavedObjectsTable extends Component { const { activeQuery: query, page, perPage } = this.state; - const { notifications, http, allowedTypes } = this.props; - const { queryText, visibleTypes } = parseQuery(query); + const { notifications, http, allowedTypes, taggingApi } = this.props; + const { queryText, visibleTypes, selectedTags } = parseQuery(query); // "searchFields" is missing from the "findOptions" but gets injected via the API. // The API extracts the fields from each uiExports.savedObjectsManagement "defaultSearchField" attribute const findOptions: SavedObjectsFindOptions = { @@ -229,6 +242,8 @@ export class SavedObjectsTable extends Component { const { exportAllSelectedOptions, isIncludeReferencesDeepChecked, activeQuery } = this.state; - const { notifications, http } = this.props; - const { queryText } = parseQuery(activeQuery); + const { notifications, http, taggingApi } = this.props; + const { queryText, selectedTags } = parseQuery(activeQuery); const exportTypes = Object.entries(exportAllSelectedOptions).reduce((accum, [id, selected]) => { if (selected) { accum.push(id); @@ -386,14 +401,17 @@ export class SavedObjectsTable extends Component { const capabilities = coreStart.application.capabilities; const itemsPerPage = coreStart.uiSettings.get('savedObjects:perPage', 50); + const { search } = useLocation(); + + const initialQuery = useMemo(() => { + const query = parse(search); + try { + return Query.parse((query.initialQuery as string) ?? ''); + } catch (e) { + return Query.parse(''); + } + }, [search]); useEffect(() => { setBreadcrumbs([ @@ -62,10 +78,12 @@ const SavedObjectsTablePage = ({ return ( ) => { + const referenceSchema = schema.object({ + type: schema.string(), + id: schema.string(), + }); + const searchOperatorSchema = schema.oneOf([schema.literal('OR'), schema.literal('AND')], { + defaultValue: 'OR', + }); + router.get( { path: '/api/kibana/management/saved_objects/_find', @@ -35,16 +43,12 @@ export const registerFindRoute = ( page: schema.number({ min: 0, defaultValue: 1 }), type: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]), search: schema.maybe(schema.string()), - defaultSearchOperator: schema.oneOf([schema.literal('OR'), schema.literal('AND')], { - defaultValue: 'OR', - }), + defaultSearchOperator: searchOperatorSchema, sortField: schema.maybe(schema.string()), hasReference: schema.maybe( - schema.object({ - type: schema.string(), - id: schema.string(), - }) + schema.oneOf([referenceSchema, schema.arrayOf(referenceSchema)]) ), + hasReferenceOperator: searchOperatorSchema, fields: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { defaultValue: [], }), diff --git a/src/plugins/saved_objects_management/server/routes/scroll_count.ts b/src/plugins/saved_objects_management/server/routes/scroll_count.ts index 58ba90d847791..b88bf6f15c9d0 100644 --- a/src/plugins/saved_objects_management/server/routes/scroll_count.ts +++ b/src/plugins/saved_objects_management/server/routes/scroll_count.ts @@ -29,6 +29,14 @@ export const registerScrollForCountRoute = (router: IRouter) => { body: schema.object({ typesToInclude: schema.arrayOf(schema.string()), searchString: schema.maybe(schema.string()), + references: schema.maybe( + schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }) + ) + ), }), }, }, @@ -43,6 +51,10 @@ export const registerScrollForCountRoute = (router: IRouter) => { findOptions.search = `${req.body.searchString}*`; findOptions.searchFields = ['title']; } + if (req.body.references) { + findOptions.hasReference = req.body.references; + findOptions.hasReferenceOperator = 'OR'; + } const objects = await findAll(client, findOptions); diff --git a/src/plugins/saved_objects_tagging_oss/README.md b/src/plugins/saved_objects_tagging_oss/README.md new file mode 100644 index 0000000000000..61ff7bd043ac5 --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/README.md @@ -0,0 +1,4 @@ +# SavedObjectsTaggingOss + +Bridge plugin for consumption of the saved object tagging feature from +oss plugins. diff --git a/src/plugins/saved_objects_tagging_oss/common/index.ts b/src/plugins/saved_objects_tagging_oss/common/index.ts new file mode 100644 index 0000000000000..788fc2b069ded --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/common/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { Tag, TagAttributes, ITagsClient } from './types'; diff --git a/src/plugins/saved_objects_tagging_oss/common/types.ts b/src/plugins/saved_objects_tagging_oss/common/types.ts new file mode 100644 index 0000000000000..8267e96a8fc87 --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/common/types.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export interface Tag { + id: string; + name: string; + description: string; + color: string; +} + +export interface TagAttributes { + name: string; + description: string; + color: string; +} + +export interface ITagsClient { + create(attributes: TagAttributes): Promise; + get(id: string): Promise; + getAll(): Promise; + delete(id: string): Promise; + update(id: string, attributes: TagAttributes): Promise; +} diff --git a/src/plugins/saved_objects_tagging_oss/kibana.json b/src/plugins/saved_objects_tagging_oss/kibana.json new file mode 100644 index 0000000000000..90a0380883f7a --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "savedObjectsTaggingOss", + "version": "kibana", + "server": false, + "ui": true, + "requiredPlugins": [], + "optionalPlugins": ["savedObjects"] +} diff --git a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts new file mode 100644 index 0000000000000..e29922c2481c4 --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts @@ -0,0 +1,86 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ITagsClient } from '../common'; +import { SavedObjectsTaggingApiUi, SavedObjectsTaggingApiUiComponent } from './api'; + +const createClientMock = (): jest.Mocked => { + const mock = { + create: jest.fn(), + get: jest.fn(), + getAll: jest.fn(), + delete: jest.fn(), + update: jest.fn(), + }; + + return mock; +}; + +interface SavedObjectsTaggingApiMock { + client: jest.Mocked; + ui: SavedObjectsTaggingApiUiMock; +} + +const createApiMock = (): SavedObjectsTaggingApiMock => { + const mock = { + client: createClientMock(), + ui: createApiUiMock(), + }; + + return mock; +}; + +type SavedObjectsTaggingApiUiMock = Omit, 'components'> & { + components: SavedObjectsTaggingApiUiComponentMock; +}; + +const createApiUiMock = (): SavedObjectsTaggingApiUiMock => { + const mock = { + components: createApiUiComponentsMock(), + // TS is very picky with type guards + hasTagDecoration: jest.fn() as any, + getSearchBarFilter: jest.fn(), + getTableColumnDefinition: jest.fn(), + convertNameToReference: jest.fn(), + parseSearchQuery: jest.fn(), + getTagIdsFromReferences: jest.fn(), + updateTagsReferences: jest.fn(), + }; + + return mock; +}; + +type SavedObjectsTaggingApiUiComponentMock = jest.Mocked; + +const createApiUiComponentsMock = (): SavedObjectsTaggingApiUiComponentMock => { + const mock = { + TagList: jest.fn(), + TagSelector: jest.fn(), + SavedObjectSaveModalTagSelector: jest.fn(), + }; + + return mock; +}; + +export const taggingApiMock = { + create: createApiMock, + createClient: createClientMock, + createUi: createApiUiMock, + createComponents: createApiUiComponentsMock, +}; diff --git a/src/plugins/saved_objects_tagging_oss/public/api.ts b/src/plugins/saved_objects_tagging_oss/public/api.ts new file mode 100644 index 0000000000000..71548cd5c7f51 --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/api.ts @@ -0,0 +1,270 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SearchFilterConfig, EuiTableFieldDataColumnType } from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import { SavedObject, SavedObjectReference } from '../../../core/types'; +import { SavedObjectsFindOptionsReference } from '../../../core/public'; +import { SavedObject as SavedObjectClass } from '../../saved_objects/public'; +import { TagDecoratedSavedObject } from './decorator'; +import { ITagsClient } from '../common'; + +/** + * @public + */ +export interface SavedObjectsTaggingApi { + client: ITagsClient; + ui: SavedObjectsTaggingApiUi; +} + +/** + * @public + */ +export type SavedObjectTagDecoratorTypeGuard = SavedObjectsTaggingApiUi['hasTagDecoration']; + +/** + * React components and utility methods to use the SO tagging feature + * + * @public + */ +export interface SavedObjectsTaggingApiUi { + /** + * Type-guard to safely manipulate tag-enhanced `SavedObject` from the `savedObject` plugin. + * + * @param object + */ + hasTagDecoration(object: SavedObjectClass): object is TagDecoratedSavedObject; + + /** + * Return a filter that can be used to filter by tag with `EuiSearchBar` or EUI tables using `EuiSearchBar`. + * + * @example + * ```ts + * // inside a react render + * const filters = taggingApi ? [taggingApi.ui.getSearchBarFilter({ useName: true })] : [] + * return ( + * + * ) + * ``` + */ + getSearchBarFilter(options?: GetSearchBarFilterOptions): SearchFilterConfig; + + /** + * Return the column definition to be used to display the tags in a EUI table. + * The table's items must be of the `SavedObject` type (or at least have their references available + * via the `references` field) + * + * @example + * ```ts + * // inside a react render + * const columns = [...myColumns, ...(taggingApi ? taggingApi.ui.getTableColumnDefinition() : [])]; + * return ( + * + * ) + * ``` + */ + getTableColumnDefinition(): EuiTableFieldDataColumnType; + + /** + * Convert given tag name to a {@link SavedObjectsFindOptionsReference | reference } + * to be used to search using the savedObjects `_find` API. Will return `undefined` + * is the given name does not match any existing tag. + */ + convertNameToReference(tagName: string): SavedObjectsFindOptionsReference | undefined; + + /** + * Parse given query using EUI's `Query` syntax, and return the search term and the tag references + * to be used when using the `_find` API to retrieve the filtered objects. + * + * @param query The query to parse + * @param options see {@link ParseSearchQueryOptions} + * + * @example + * ```typescript + * parseSearchQuery('(tag:(tag-1 or tag-2) some term', { useNames: true }) + * >> + * { + * searchTerm: 'some term', + * tagReferences: [{type: 'tag', id: 'tag-1-id'}, {type: 'tag', id: 'tag-2-id'}] + * } + * ``` + * + * @example + * ```typescript + * parseSearchQuery('(tagging:(some-tag-uuid or some-other-tag-uuid) some term', { tagClause: 'tagging' }) + * >> + * { + * searchTerm: 'some term', + * tagReferences: [{type: 'tag', id: 'some-tag-uuid'}, {type: 'tag', id: 'some-other-tag-uuid'}] + * } + * ``` + */ + parseSearchQuery(query: string, options?: ParseSearchQueryOptions): ParsedSearchQuery; + + /** + * Returns the object ids for the tag references from given references array + */ + getTagIdsFromReferences( + references: Array + ): string[]; + + /** + * Returns a new references array that replace the old tag references with references to the + * new given tag ids, while preserving all non-tag references. + */ + updateTagsReferences( + references: SavedObjectReference[], + newTagIds: string[] + ): SavedObjectReference[]; + + /** + * {@link SavedObjectsTaggingApiUiComponent | React components} to support the tagging feature. + */ + components: SavedObjectsTaggingApiUiComponent; +} + +/** + * React UI components to be used to display the tagging feature in any application. + * + * @public + */ +export interface SavedObjectsTaggingApiUiComponent { + /** + * Displays the tags for given saved object. + */ + TagList: FunctionComponent; + /** + * Widget to select tags. + */ + TagSelector: FunctionComponent; + /** + * Component to be used with the `options` property of the `SavedObjectSaveModal` or `SavedObjectSaveModalOrigin` + * modals from the `savedObjects` plugin. It displays the whole field row and handles the 'stateless' nature + * of props passed to inline components + */ + SavedObjectSaveModalTagSelector: FunctionComponent; +} + +/** + * Props type for the {@link SavedObjectsTaggingApiUiComponent.TagList | TagList component} + * + * @public + */ +export interface TagListComponentProps { + /** + * The object to display tags for. + */ + object: SavedObject; +} + +/** + * Props type for the {@link SavedObjectsTaggingApiUiComponent.TagSelector | TagSelector component} + * + * @public + */ +export interface TagSelectorComponentProps { + /** + * Ids of the currently selected tags + */ + selected: string[]; + /** + * tags selection callback + */ + onTagsSelected: (ids: string[]) => void; +} + +/** + * Props type for the {@link SavedObjectsTaggingApiUiComponent.SavedObjectSaveModalTagSelector | SavedObjectSaveModalTagSelector component} + * + * @public + */ +export interface SavedObjectSaveModalTagSelectorComponentProps { + /** + * Ids of the initially selected tags. + * Changing the value of this prop after initial mount will not rerender the component (see component description for more details) + */ + initialSelection: string[]; + /** + * tags selection callback + */ + onTagsSelected: (ids: string[]) => void; +} + +/** + * Options for the {@link SavedObjectsTaggingApiUi.getSearchBarFilter | getSearchBarFilter api} + * + * @public + */ +export interface GetSearchBarFilterOptions { + /** + * If set to true, will use the tag's `name` instead of `id` for the tag field clause, which is recommended + * for a better end-user experience. + * + * Defaults to true. + * + * @example + * ``` + * // query generated with { useName: true } + * `tag:(tag-1 OR tag-2) my search term` + * // query generated with { useName: false } + * `tag:(d97721fc-542b-4485-a329-65ed04c84a4c OR d97721fc-542b-4485-a329-65ed04c84a4c) my search term` + * ``` + * + * @remarks this must consistent with the {@link ParseSearchQueryOptions.useName} when parsing the query. + */ + useName?: boolean; + /** + * The tag clause field name to generate the query. Defaults to `tag`. + * + * @remarks It is very unlikely that this option is needed for external consumers. + */ + tagField?: string; +} + +/** + * @public + */ +export interface ParsedSearchQuery { + searchTerm: string; + tagReferences?: SavedObjectsFindOptionsReference[]; +} + +/** + * Options for the {@link SavedObjectsTaggingApiUi.parseSearchQuery | parseSearchQuery api} + * + * @public + */ +export interface ParseSearchQueryOptions { + /** + * If set to true, will assume the tag clause is using tag names instead of ids. + * In that case, will perform a reverse lookup from the client-side tag cache to resolve tag ids from names. + * + * Defaults to true. + * + * @remarks this must be set to to true if the filter is configured to use tag names instead of id in the query. + * see {@link GetSearchBarFilterOptions.useName} for more details. + */ + useName?: boolean; + /** + * The tag clause field name to extract the tags from. Defaults to `tag`. + * + * @remarks It is very unlikely that this option is needed for external consumers. + */ + tagField?: string; +} diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.test.mocks.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.test.mocks.ts new file mode 100644 index 0000000000000..9cb792c275c38 --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.test.mocks.ts @@ -0,0 +1,28 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const injectTagReferencesMock = jest.fn(); +jest.doMock('./inject_tag_references', () => ({ + injectTagReferences: injectTagReferencesMock, +})); + +export const extractTagReferencesMock = jest.fn(); +jest.doMock('./extract_tag_references', () => ({ + extractTagReferences: extractTagReferencesMock, +})); diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.test.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.test.ts new file mode 100644 index 0000000000000..dd5d5687c2e60 --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.test.ts @@ -0,0 +1,143 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { extractTagReferencesMock, injectTagReferencesMock } from './decorate_config.test.mocks'; + +import { SavedObjectConfig } from '../../../saved_objects/public'; +import { decorateConfig } from './decorate_config'; + +describe('decorateConfig', () => { + afterEach(() => { + extractTagReferencesMock.mockReset(); + injectTagReferencesMock.mockReset(); + }); + + describe('mapping', () => { + it('adds the `__tags` key to the config mapping', () => { + const config: SavedObjectConfig = { + mapping: { + someText: 'text', + someNum: 'number', + }, + }; + + decorateConfig(config); + + expect(config.mapping).toEqual({ + __tags: 'text', + someText: 'text', + someNum: 'number', + }); + }); + + it('adds mapping to the config if missing', () => { + const config: SavedObjectConfig = {}; + + decorateConfig(config); + + expect(config.mapping).toEqual({ + __tags: 'text', + }); + }); + }); + + describe('injectReferences', () => { + it('decorates to only call `injectTagReferences` when not present on the config', () => { + const config: SavedObjectConfig = {}; + + decorateConfig(config); + + const object: any = Symbol('object'); + const references: any = Symbol('referebces'); + + config.injectReferences!(object, references); + + expect(injectTagReferencesMock).toHaveBeenCalledTimes(1); + expect(injectTagReferencesMock).toHaveBeenCalledWith(object, references); + }); + + it('decorates to call both functions when present on the config', () => { + const initialInjectReferences = jest.fn(); + + const config: SavedObjectConfig = { + injectReferences: initialInjectReferences, + }; + + decorateConfig(config); + + const object: any = Symbol('object'); + const references: any = Symbol('references'); + + config.injectReferences!(object, references); + + expect(initialInjectReferences).toHaveBeenCalledTimes(1); + expect(initialInjectReferences).toHaveBeenCalledWith(object, references); + + expect(injectTagReferencesMock).toHaveBeenCalledTimes(1); + expect(injectTagReferencesMock).toHaveBeenCalledWith(object, references); + }); + }); + + describe('extractReferences', () => { + it('decorates to only call `extractTagReference` when not present on the config', () => { + const config: SavedObjectConfig = {}; + + decorateConfig(config); + + const params: any = Symbol('params'); + const expectedReturn = Symbol('return-from-extractTagReferences'); + + extractTagReferencesMock.mockReturnValue(expectedReturn); + + const result = config.extractReferences!(params); + + expect(result).toBe(expectedReturn); + + expect(extractTagReferencesMock).toHaveBeenCalledTimes(1); + expect(extractTagReferencesMock).toHaveBeenCalledWith(params); + }); + + it('decorates to call both functions in order when present on the config', () => { + const initialExtractReferences = jest.fn(); + + const config: SavedObjectConfig = { + extractReferences: initialExtractReferences, + }; + + decorateConfig(config); + + const params: any = Symbol('initial-params'); + const initialReturn = Symbol('return-from-initial-extractReferences'); + const tagReturn = Symbol('return-from-extractTagReferences'); + + initialExtractReferences.mockReturnValue(initialReturn); + extractTagReferencesMock.mockReturnValue(tagReturn); + + const result = config.extractReferences!(params); + + expect(initialExtractReferences).toHaveBeenCalledTimes(1); + expect(initialExtractReferences).toHaveBeenCalledWith(params); + + expect(extractTagReferencesMock).toHaveBeenCalledTimes(1); + expect(extractTagReferencesMock).toHaveBeenCalledWith(initialReturn); + + expect(result).toBe(tagReturn); + }); + }); +}); diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.ts new file mode 100644 index 0000000000000..5ee7d83ba949d --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.ts @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectConfig } from '../../../saved_objects/public'; +import { injectTagReferences } from './inject_tag_references'; +import { extractTagReferences } from './extract_tag_references'; + +export const decorateConfig = (config: SavedObjectConfig) => { + config.mapping = { + ...config.mapping, + __tags: 'text', + }; + + const initialExtractReferences = config.extractReferences; + const initialInjectReferences = config.injectReferences; + + config.injectReferences = (object, references) => { + if (initialInjectReferences) { + initialInjectReferences(object, references); + } + injectTagReferences(object, references); + }; + + config.extractReferences = (attrsAndRefs) => { + if (initialExtractReferences) { + attrsAndRefs = initialExtractReferences(attrsAndRefs); + } + return extractTagReferences(attrsAndRefs); + }; +}; diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_object.test.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_object.test.ts new file mode 100644 index 0000000000000..53895e019a2fd --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_object.test.ts @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { InternalTagDecoratedSavedObject } from './types'; +import { decorateObject } from './decorate_object'; + +const createObject = (): InternalTagDecoratedSavedObject => { + // we really just need TS not to complain here. + return {} as InternalTagDecoratedSavedObject; +}; + +describe('decorateObject', () => { + it('adds the `getTags` method', () => { + const object = createObject(); + object.__tags = ['foo', 'bar']; + + decorateObject(object); + + expect(object.getTags).toBeDefined(); + expect(object.getTags()).toEqual(['foo', 'bar']); + }); + + it('adds the `setTags` method', () => { + const object = createObject(); + object.__tags = ['foo', 'bar']; + + decorateObject(object); + + expect(object.setTags).toBeDefined(); + + object.setTags(['hello', 'dolly']); + + expect(object.getTags()).toEqual(['hello', 'dolly']); + }); +}); diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_object.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_object.ts new file mode 100644 index 0000000000000..2eed43e7b8bdb --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_object.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { InternalTagDecoratedSavedObject } from './types'; + +/** + * Enhance the object with tag accessors + */ +export const decorateObject = (object: InternalTagDecoratedSavedObject) => { + object.getTags = () => { + return object.__tags ?? []; + }; + object.setTags = (tagIds) => { + object.__tags = tagIds; + }; +}; diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/extract_tag_references.test.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/extract_tag_references.test.ts new file mode 100644 index 0000000000000..c4864b5de6ae7 --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/decorator/extract_tag_references.test.ts @@ -0,0 +1,95 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectReference } from '../../../../core/public'; +import { extractTagReferences } from './extract_tag_references'; + +const ref = (type: string, id: string, name = `ref-to-${type}-${id}`): SavedObjectReference => ({ + id, + type, + name, +}); + +const tagRef = (id: string): SavedObjectReference => ref('tag', id, `tag-${id}`); + +describe('extractTagReferences', () => { + it('generate tag references from the attributes', () => { + const attributes = { + __tags: ['tag-id-1', 'tag-id-2'], + }; + const references: SavedObjectReference[] = []; + + const { references: resultRefs } = extractTagReferences({ + attributes, + references, + }); + + expect(resultRefs).toEqual([tagRef('tag-id-1'), tagRef('tag-id-2')]); + }); + + it('removes the `__tag` property from the attributes', () => { + const attributes = { + someString: 'foo', + someNumber: 42, + __tags: ['tag-id-1', 'tag-id-2'], + }; + const references: SavedObjectReference[] = []; + + const { attributes: resultAttrs } = extractTagReferences({ + attributes, + references, + }); + + expect(resultAttrs).toEqual({ someString: 'foo', someNumber: 42 }); + }); + + it('preserves the other references', () => { + const attributes = { + __tags: ['tag-id-1'], + }; + + const refA = ref('dashboard', 'dash-1'); + const refB = ref('visualization', 'vis-1'); + + const { references: resultRefs } = extractTagReferences({ + attributes, + references: [refA, refB], + }); + + expect(resultRefs).toEqual([refA, refB, tagRef('tag-id-1')]); + }); + + it('does not fail if `attributes` does not contain `__tags`', () => { + const attributes = { + someString: 'foo', + someNumber: 42, + }; + + const refA = ref('dashboard', 'dash-1'); + const refB = ref('visualization', 'vis-1'); + + const { attributes: resultAttrs, references: resultRefs } = extractTagReferences({ + attributes, + references: [refA, refB], + }); + + expect(resultRefs).toEqual([refA, refB]); + expect(resultAttrs).toEqual({ someString: 'foo', someNumber: 42 }); + }); +}); diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/extract_tag_references.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/extract_tag_references.ts new file mode 100644 index 0000000000000..e2668ce2a2b84 --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/decorator/extract_tag_references.ts @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectConfig } from '../../../saved_objects/public'; + +/** + * Extract the tag references from the object's attribute + * + * (`extractReferences` is used when persisting the saved object to the backend) + */ +export const extractTagReferences: Required['extractReferences'] = ({ + attributes, + references, +}) => { + const { __tags, ...otherAttributes } = attributes; + const tags = (__tags as string[]) ?? []; + return { + attributes: otherAttributes, + references: [ + ...references, + ...tags.map((tagId) => ({ + id: tagId, + type: 'tag', + name: `tag-${tagId}`, + })), + ], + }; +}; diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/factory.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/factory.ts new file mode 100644 index 0000000000000..ab628647c5ef4 --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/decorator/factory.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectDecoratorFactory } from '../../../saved_objects/public'; +import { InternalTagDecoratedSavedObject } from './types'; +import { decorateConfig } from './decorate_config'; +import { decorateObject } from './decorate_object'; + +export const decoratorId = 'tag'; + +export const tagDecoratorFactory: SavedObjectDecoratorFactory = () => { + return { + getId: () => decoratorId, + decorateConfig, + decorateObject, + }; +}; diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/index.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/index.ts new file mode 100644 index 0000000000000..cc0dbb6626e5f --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/decorator/index.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectDecoratorConfig } from '../../../saved_objects/public'; +import { tagDecoratorFactory, decoratorId } from './factory'; +import { InternalTagDecoratedSavedObject } from './types'; + +export { TagDecoratedSavedObject } from './types'; + +export const tagDecoratorConfig: SavedObjectDecoratorConfig = { + id: decoratorId, + priority: 100, + factory: tagDecoratorFactory, +}; diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/inject_tag_references.test.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/inject_tag_references.test.ts new file mode 100644 index 0000000000000..a1f19b10054c3 --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/decorator/inject_tag_references.test.ts @@ -0,0 +1,70 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectReference } from '../../../../core/public'; +import { injectTagReferences } from './inject_tag_references'; +import { InternalTagDecoratedSavedObject } from './types'; + +const ref = (type: string, id: string): SavedObjectReference => ({ + id, + type, + name: `ref-to-${type}-${id}`, +}); + +const tagRef = (id: string) => ref('tag', id); + +const createObject = (): InternalTagDecoratedSavedObject => { + // we really just need TS not to complain here. + return {} as InternalTagDecoratedSavedObject; +}; + +describe('injectTagReferences', () => { + let object: InternalTagDecoratedSavedObject; + + beforeEach(() => { + object = createObject(); + }); + + it('injects the `tag` references to the `__tags` field', () => { + const references = [tagRef('tag-id-1'), tagRef('tag-id-2')]; + + injectTagReferences(object, references); + + expect(object.__tags).toEqual(['tag-id-1', 'tag-id-2']); + }); + + it('only process the tag references', () => { + const references = [ + tagRef('tag-id-1'), + ref('dashboard', 'foo'), + tagRef('tag-id-2'), + ref('dashboard', 'baz'), + ]; + + injectTagReferences(object, references); + + expect(object.__tags).toEqual(['tag-id-1', 'tag-id-2']); + }); + + it('injects an empty list when not tag references are present', () => { + injectTagReferences(object, [ref('dashboard', 'foo'), ref('dashboard', 'baz')]); + + expect(object.__tags).toEqual([]); + }); +}); diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/inject_tag_references.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/inject_tag_references.ts new file mode 100644 index 0000000000000..f39468de0a1c1 --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/decorator/inject_tag_references.ts @@ -0,0 +1,35 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectConfig } from '../../../saved_objects/public'; +import { InternalTagDecoratedSavedObject } from './types'; + +/** + * Inject the tags back into the object's references + * + * (`injectReferences`) is used when fetching the object from the backend + */ +export const injectTagReferences: Required['injectReferences'] = ( + object, + references = [] +) => { + ((object as unknown) as InternalTagDecoratedSavedObject).__tags = references + .filter(({ type }) => type === 'tag') + .map(({ id }) => id); +}; diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/types.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/types.ts new file mode 100644 index 0000000000000..b2b7afa169b62 --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/decorator/types.ts @@ -0,0 +1,35 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObject } from '../../../saved_objects/public'; + +/** + * @public + */ +export type TagDecoratedSavedObject = SavedObject & { + getTags(): string[]; + setTags(tags: string[]): void; +}; + +/** + * @internal + */ +export type InternalTagDecoratedSavedObject = TagDecoratedSavedObject & { + __tags: string[]; +}; diff --git a/src/plugins/saved_objects_tagging_oss/public/index.ts b/src/plugins/saved_objects_tagging_oss/public/index.ts new file mode 100644 index 0000000000000..bc824621830d2 --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/index.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializerContext } from '../../../../src/core/public'; +import { SavedObjectTaggingOssPlugin } from './plugin'; + +export { SavedObjectTaggingOssPluginSetup, SavedObjectTaggingOssPluginStart } from './types'; + +export { + SavedObjectsTaggingApi, + SavedObjectsTaggingApiUi, + SavedObjectsTaggingApiUiComponent, + TagListComponentProps, + TagSelectorComponentProps, + GetSearchBarFilterOptions, + ParsedSearchQuery, + ParseSearchQueryOptions, + SavedObjectSaveModalTagSelectorComponentProps, + SavedObjectTagDecoratorTypeGuard, +} from './api'; + +export { TagDecoratedSavedObject } from './decorator'; + +export const plugin = (initializerContext: PluginInitializerContext) => + new SavedObjectTaggingOssPlugin(initializerContext); diff --git a/src/plugins/saved_objects_tagging_oss/public/mocks.ts b/src/plugins/saved_objects_tagging_oss/public/mocks.ts new file mode 100644 index 0000000000000..b7cf60761b12a --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/mocks.ts @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectTaggingOssPluginSetup, SavedObjectTaggingOssPluginStart } from './types'; + +const createSetupMock = (): jest.Mocked => { + const mock = { + registerTaggingApi: jest.fn(), + }; + + return mock; +}; + +const createStartMock = (): jest.Mocked => { + const mock = { + isTaggingAvailable: jest.fn(), + getTaggingApi: jest.fn(), + }; + + mock.isTaggingAvailable.mockReturnValue(false); + + return mock; +}; + +export { taggingApiMock } from './api.mock'; + +export const savedObjectTaggingOssPluginMock = { + createSetup: createSetupMock, + createStart: createStartMock, +}; diff --git a/src/plugins/saved_objects_tagging_oss/public/plugin.test.ts b/src/plugins/saved_objects_tagging_oss/public/plugin.test.ts new file mode 100644 index 0000000000000..cdabc620fbb21 --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/plugin.test.ts @@ -0,0 +1,101 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { coreMock } from '../../../core/public/mocks'; +import { savedObjectsPluginMock } from '../../saved_objects/public/mocks'; +import { tagDecoratorConfig } from './decorator'; +import { taggingApiMock } from './api.mock'; +import { SavedObjectTaggingOssPlugin } from './plugin'; + +describe('SavedObjectTaggingOssPlugin', () => { + let plugin: SavedObjectTaggingOssPlugin; + let coreSetup: ReturnType; + + beforeEach(() => { + coreSetup = coreMock.createSetup(); + plugin = new SavedObjectTaggingOssPlugin(coreMock.createPluginInitializerContext()); + }); + + describe('#setup', () => { + it('registers the tag SO decorator if the `savedObjects` plugin is present', () => { + const savedObjects = savedObjectsPluginMock.createSetupContract(); + + plugin.setup(coreSetup, { savedObjects }); + + expect(savedObjects.registerDecorator).toHaveBeenCalledTimes(1); + expect(savedObjects.registerDecorator).toHaveBeenCalledWith(tagDecoratorConfig); + }); + + it('does not fail if the `savedObjects` plugin is not present', () => { + expect(() => { + plugin.setup(coreSetup, {}); + }).not.toThrow(); + }); + }); + + describe('#start', () => { + let coreStart: ReturnType; + + // need to wait for api promises to resolve + const nextTick = () => + new Promise((resolve) => { + window.setTimeout(resolve, 0); + }); + + beforeEach(() => { + coreStart = coreMock.createStart(); + }); + + it('returns the tagging API if registered', async () => { + const taggingApi = taggingApiMock.create(); + const { registerTaggingApi } = plugin.setup(coreSetup, {}); + + registerTaggingApi(Promise.resolve(taggingApi)); + + await nextTick(); + + const { getTaggingApi, isTaggingAvailable } = plugin.start(coreStart); + + expect(isTaggingAvailable()).toBe(true); + expect(getTaggingApi()).toStrictEqual(taggingApi); + }); + it('does not return the tagging API if not registered', async () => { + plugin.setup(coreSetup, {}); + + await nextTick(); + + const { getTaggingApi, isTaggingAvailable } = plugin.start(coreStart); + + expect(isTaggingAvailable()).toBe(false); + expect(getTaggingApi()).toBeUndefined(); + }); + it('does not return the tagging API if resolution promise rejects', async () => { + const { registerTaggingApi } = plugin.setup(coreSetup, {}); + + registerTaggingApi(Promise.reject(new Error('something went bad'))); + + await nextTick(); + + const { getTaggingApi, isTaggingAvailable } = plugin.start(coreStart); + + expect(isTaggingAvailable()).toBe(false); + expect(getTaggingApi()).toBeUndefined(); + }); + }); +}); diff --git a/src/plugins/saved_objects_tagging_oss/public/plugin.ts b/src/plugins/saved_objects_tagging_oss/public/plugin.ts new file mode 100644 index 0000000000000..fabb9e733a0eb --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/plugin.ts @@ -0,0 +1,73 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CoreSetup, CoreStart, PluginInitializerContext, Plugin } from 'src/core/public'; +import { SavedObjectSetup } from '../../saved_objects/public'; +import { SavedObjectTaggingOssPluginSetup, SavedObjectTaggingOssPluginStart } from './types'; +import { SavedObjectsTaggingApi } from './api'; +import { tagDecoratorConfig } from './decorator'; + +interface SetupDeps { + savedObjects?: SavedObjectSetup; +} + +export class SavedObjectTaggingOssPlugin + implements + Plugin { + private apiRegistered = false; + private api?: SavedObjectsTaggingApi; + + constructor(context: PluginInitializerContext) {} + + public setup({}: CoreSetup, { savedObjects }: SetupDeps) { + if (savedObjects) { + savedObjects.registerDecorator(tagDecoratorConfig); + } + + return { + registerTaggingApi: (provider: Promise) => { + if (this.apiRegistered) { + throw new Error('tagging API can only be registered once'); + } + this.apiRegistered = true; + + provider.then( + (api) => { + this.api = api; + }, + (error) => { + // eslint-disable-next-line no-console + console.log( + 'Error during tagging API promise resolution. SO tagging has been disabled', + error + ); + this.apiRegistered = false; + } + ); + }, + }; + } + + public start({}: CoreStart) { + return { + isTaggingAvailable: () => this.apiRegistered, + getTaggingApi: () => this.api, + }; + } +} diff --git a/src/plugins/saved_objects_tagging_oss/public/types.ts b/src/plugins/saved_objects_tagging_oss/public/types.ts new file mode 100644 index 0000000000000..7b4620554b11c --- /dev/null +++ b/src/plugins/saved_objects_tagging_oss/public/types.ts @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectsTaggingApi } from './api'; + +export interface SavedObjectTaggingOssPluginSetup { + /** + * Register a provider for the tagging API. + * + * Only one provider can be registered, subsequent calls to this method will fail. + * + * @remarks The promise should not resolve any later than the end of the start lifecycle + * (after `getStartServices` resolves). Not respecting this condition may cause + * runtime failures. + */ + registerTaggingApi(provider: Promise): void; +} + +export interface SavedObjectTaggingOssPluginStart { + /** + * Returns true if the tagging feature is available (if a provider registered the API) + */ + isTaggingAvailable(): boolean; + + /** + * Returns the tagging API, if registered. + * This will always returns a value if `isTaggingAvailable` returns true, and undefined otherwise. + */ + getTaggingApi(): SavedObjectsTaggingApi | undefined; +} diff --git a/src/plugins/visualizations/public/saved_visualizations/find_list_items.test.ts b/src/plugins/visualizations/public/saved_visualizations/find_list_items.test.ts index 4a50590e26251..3acfcb5230cb1 100644 --- a/src/plugins/visualizations/public/saved_visualizations/find_list_items.test.ts +++ b/src/plugins/visualizations/public/saved_visualizations/find_list_items.test.ts @@ -130,6 +130,28 @@ describe('saved_visualizations', () => { ]); }); + it('searches with references', async () => { + const props = { + ...testProps(), + references: [ + { type: 'foo', id: 'hello' }, + { type: 'bar', id: 'dolly' }, + ], + }; + const { find } = props.savedObjectsClient; + await findListItems(props); + expect(find.mock.calls).toMatchObject([ + [ + { + hasReference: [ + { type: 'foo', id: 'hello' }, + { type: 'bar', id: 'dolly' }, + ], + }, + ], + ]); + }); + it('uses type-specific toListItem function, if available', async () => { const props = { ...testProps(), diff --git a/src/plugins/visualizations/public/saved_visualizations/find_list_items.ts b/src/plugins/visualizations/public/saved_visualizations/find_list_items.ts index 60945b912e1b3..112cd3de31de2 100644 --- a/src/plugins/visualizations/public/saved_visualizations/find_list_items.ts +++ b/src/plugins/visualizations/public/saved_visualizations/find_list_items.ts @@ -18,7 +18,12 @@ */ import _ from 'lodash'; -import { SavedObjectAttributes, SavedObjectsClientContract } from '../../../../core/public'; +import { + SavedObjectAttributes, + SavedObjectsClientContract, + SavedObjectsFindOptionsReference, + SavedObjectsFindOptions, +} from '../../../../core/public'; import { SavedObjectLoader } from '../../../../plugins/saved_objects/public'; import { VisTypeAlias } from '../vis_types'; import { VisualizationsAppExtension } from '../vis_types/vis_type_alias_registry'; @@ -32,12 +37,14 @@ export async function findListItems({ size, savedObjectsClient, mapSavedObjectApiHits, + references, }: { search: string; size: number; visTypes: VisTypeAlias[]; savedObjectsClient: SavedObjectsClientContract; mapSavedObjectApiHits: SavedObjectLoader['mapSavedObjectApiHits']; + references?: SavedObjectsFindOptionsReference[]; }) { const extensions = visTypes .map((v) => v.appExtensions?.visualizations) @@ -50,13 +57,14 @@ export async function findListItems({ }, {} as { [visType: string]: VisualizationsAppExtension }); const searchOption = (field: string, ...defaults: string[]) => _(extensions).map(field).concat(defaults).compact().flatten().uniq().value() as string[]; - const searchOptions = { + const searchOptions: SavedObjectsFindOptions = { type: searchOption('docTypes', 'visualization'), searchFields: searchOption('searchFields', 'title^3', 'description'), search: search ? `${search}*` : undefined, perPage: size, page: 1, defaultSearchOperator: 'AND' as 'AND', + hasReference: references, }; const { total, savedObjects } = await savedObjectsClient.find( @@ -69,7 +77,10 @@ export async function findListItems({ const config = extensionByType[savedObject.type]; if (config) { - return config.toListItem(savedObject); + return { + ...config.toListItem(savedObject), + references: savedObject.references, + }; } else { return mapSavedObjectApiHits(savedObject); } diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts index 760bf3cc7a362..c30836cb685d2 100644 --- a/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts @@ -16,6 +16,8 @@ * specific language governing permissions and limitations * under the License. */ + +import { SavedObjectReference, SavedObjectsFindOptionsReference } from 'kibana/public'; import { SavedObjectLoader } from '../../../../plugins/saved_objects/public'; import { findListItems } from './find_list_items'; import { createSavedVisClass, SavedVisServices } from './_saved_vis'; @@ -25,13 +27,24 @@ export interface SavedVisServicesWithVisualizations extends SavedVisServices { visualizationTypes: TypesStart; } export type SavedVisualizationsLoader = ReturnType; + +export interface FindListItemsOptions { + size?: number; + references?: SavedObjectsFindOptionsReference[]; +} + export function createSavedVisLoader(services: SavedVisServicesWithVisualizations) { const { savedObjectsClient, visualizationTypes } = services; class SavedObjectLoaderVisualize extends SavedObjectLoader { - mapHitSource = (source: Record, id: string) => { + mapHitSource = ( + source: Record, + id: string, + references: SavedObjectReference[] = [] + ) => { const visTypes = visualizationTypes; source.id = id; + source.references = references; source.url = this.urlFor(id); let typeName = source.typeName; @@ -62,10 +75,17 @@ export function createSavedVisLoader(services: SavedVisServicesWithVisualization } // This behaves similarly to find, except it returns visualizations that are // defined as appExtensions and which may not conform to type: visualization - findListItems(search: string = '', size: number = 100) { + findListItems(search: string = '', sizeOrOptions: number | FindListItemsOptions = 100) { + const { size = 100, references = undefined } = + typeof sizeOrOptions === 'number' + ? { + size: sizeOrOptions, + } + : sizeOrOptions; return findListItems({ search, size, + references, mapSavedObjectApiHits: this.mapSavedObjectApiHits.bind(this), savedObjectsClient, visTypes: visualizationTypes.getAliases(), @@ -74,6 +94,6 @@ export function createSavedVisLoader(services: SavedVisServicesWithVisualization } const SavedVis = createSavedVisClass(services); return new SavedObjectLoaderVisualize(SavedVis, savedObjectsClient) as SavedObjectLoader & { - findListItems: (search: string, size: number) => any; + findListItems: (search: string, sizeOrOptions?: number | FindListItemsOptions) => any; }; } diff --git a/src/plugins/visualize/kibana.json b/src/plugins/visualize/kibana.json index 2e40d29158600..55e0a414d9059 100644 --- a/src/plugins/visualize/kibana.json +++ b/src/plugins/visualize/kibana.json @@ -13,7 +13,11 @@ "dashboard", "uiActions" ], - "optionalPlugins": ["home", "share"], + "optionalPlugins": [ + "home", + "share", + "savedObjectsTaggingOss" + ], "requiredBundles": [ "kibanaUtils", "kibanaReact", diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index c514c9337d940..2a4b1df743800 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -24,6 +24,7 @@ import { i18n } from '@kbn/i18n'; import { useUnmount, useMount } from 'react-use'; import { useLocation } from 'react-router-dom'; +import { SavedObjectsFindOptionsReference } from '../../../../../core/public'; import { useKibana, TableListView } from '../../../../kibana_react/public'; import { VISUALIZE_ENABLE_LABS_SETTING } from '../../../../visualizations/public'; import { VisualizeServices } from '../types'; @@ -41,6 +42,7 @@ export const VisualizeListing = () => { visualizations, savedObjects, savedObjectsPublic, + savedObjectsTagging, uiSettings, visualizeCapabilities, }, @@ -95,13 +97,26 @@ export const VisualizeListing = () => { ); const noItemsFragment = useMemo(() => getNoItemsMessage(createNewVis), [createNewVis]); - const tableColumns = useMemo(() => getTableColumns(application, history), [application, history]); + const tableColumns = useMemo(() => getTableColumns(application, history, savedObjectsTagging), [ + application, + history, + savedObjectsTagging, + ]); const fetchItems = useCallback( (filter) => { + let searchTerm = filter; + let references: SavedObjectsFindOptionsReference[] | undefined; + + if (savedObjectsTagging) { + const parsedQuery = savedObjectsTagging.ui.parseSearchQuery(filter, { useName: true }); + searchTerm = parsedQuery.searchTerm; + references = parsedQuery.tagReferences; + } + const isLabsEnabled = uiSettings.get(VISUALIZE_ENABLE_LABS_SETTING); return savedVisualizations - .findListItems(filter, listingLimit) + .findListItems(searchTerm, { size: listingLimit, references }) .then(({ total, hits }: { total: number; hits: object[] }) => ({ total, hits: hits.filter( @@ -109,7 +124,7 @@ export const VisualizeListing = () => { ), })); }, - [listingLimit, savedVisualizations, uiSettings] + [listingLimit, savedVisualizations, uiSettings, savedObjectsTagging] ); const deleteItems = useCallback( @@ -127,6 +142,12 @@ export const VisualizeListing = () => { [savedObjects.client, toastNotifications] ); + const searchFilters = useMemo(() => { + return savedObjectsTagging + ? [savedObjectsTagging.ui.getSearchBarFilter({ useName: true })] + : []; + }, [savedObjectsTagging]); + return ( { defaultMessage: 'Visualizations', })} toastNotifications={toastNotifications} + searchFilters={searchFilters} /> ); }; diff --git a/src/plugins/visualize/public/application/types.ts b/src/plugins/visualize/public/application/types.ts index 6cc8f5c26584a..c833745592e41 100644 --- a/src/plugins/visualize/public/application/types.ts +++ b/src/plugins/visualize/public/application/types.ts @@ -47,6 +47,7 @@ import { EmbeddableStart } from 'src/plugins/embeddable/public'; import { UrlForwardingStart } from 'src/plugins/url_forwarding/public'; import { EventEmitter } from 'events'; import { DashboardStart } from '../../../dashboard/public'; +import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; export type PureVisState = SavedVisState; @@ -115,6 +116,7 @@ export interface VisualizeServices extends CoreStart { scopedHistory: ScopedHistory; dashboard: DashboardStart; setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; + savedObjectsTagging?: SavedObjectsTaggingApi; } export interface SavedVisInstance { diff --git a/src/plugins/visualize/public/application/utils/get_table_columns.tsx b/src/plugins/visualize/public/application/utils/get_table_columns.tsx index 3541c0dc31db2..5ff172ae5a976 100644 --- a/src/plugins/visualize/public/application/utils/get_table_columns.tsx +++ b/src/plugins/visualize/public/application/utils/get_table_columns.tsx @@ -25,6 +25,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ApplicationStart } from 'kibana/public'; import { VisualizationListItem } from 'src/plugins/visualizations/public'; +import type { SavedObjectsTaggingApi } from 'src/plugins/saved_objects_tagging_oss/public'; const getBadge = (item: VisualizationListItem) => { if (item.stage === 'beta') { @@ -80,7 +81,11 @@ const renderItemTypeIcon = (item: VisualizationListItem) => { return icon; }; -export const getTableColumns = (application: ApplicationStart, history: History) => [ +export const getTableColumns = ( + application: ApplicationStart, + history: History, + taggingApi?: SavedObjectsTaggingApi +) => [ { field: 'title', name: i18n.translate('visualize.listing.table.titleColumnName', { @@ -133,6 +138,7 @@ export const getTableColumns = (application: ApplicationStart, history: History) sortable: true, render: (field: string, record: VisualizationListItem) => {record.description}, }, + ...(taggingApi ? [taggingApi.ui.getTableColumnDefinition()] : []), ]; export const getNoItemsMessage = (createItem: () => void) => ( diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx index eadf404daf918..684e2dbb332e2 100644 --- a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx @@ -80,6 +80,7 @@ export const getTopNavConfig = ( visualizeCapabilities, i18n: { Context: I18nContext }, dashboard, + savedObjectsTagging, }: VisualizeServices ) => { const { vis, embeddableHandler } = visInstance; @@ -306,6 +307,11 @@ export const getTopNavConfig = ( embeddableHandler.updateInput({ title: newTitle }); savedVis.copyOnSave = newCopyOnSave; savedVis.description = newDescription; + + if (savedObjectsTagging && savedObjectsTagging.ui.hasTagDecoration(savedVis)) { + savedVis.setTags(selectedTags); + } + const saveOptions = { confirmOverwrite: false, isTitleDuplicateConfirmed, @@ -320,10 +326,30 @@ export const getTopNavConfig = ( return response; }; + let selectedTags: string[] = []; + let options: React.ReactNode | undefined; + + if ( + savedVis && + savedObjectsTagging && + savedObjectsTagging.ui.hasTagDecoration(savedVis) + ) { + selectedTags = savedVis.getTags(); + options = ( + { + selectedTags = newSelection; + }} + /> + ); + } + const saveModal = ( {}} diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index ef7d8ea189024..bbd7be0d34883 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -50,6 +50,7 @@ import { SavedObjectsStart } from '../../saved_objects/public'; import { EmbeddableStart } from '../../embeddable/public'; import { DashboardStart } from '../../dashboard/public'; import { UiActionsSetup, VISUALIZE_FIELD_TRIGGER } from '../../ui_actions/public'; +import type { SavedObjectTaggingOssPluginStart } from '../../saved_objects_tagging_oss/public'; import { setUISettings, setApplication, @@ -69,6 +70,7 @@ export interface VisualizePluginStartDependencies { urlForwarding: UrlForwardingStart; savedObjects: SavedObjectsStart; dashboard: DashboardStart; + savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; } export interface VisualizePluginSetupDependencies { @@ -198,6 +200,7 @@ export class VisualizePlugin restorePreviousUrl, dashboard: pluginsStart.dashboard, setHeaderActionMenu: params.setHeaderActionMenu, + savedObjectsTagging: pluginsStart.savedObjectsTaggingOss?.getTaggingApi(), }; params.element.classList.add('visAppWrapper'); diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js index f129bf22840da..c2e36b4a669ff 100644 --- a/test/api_integration/apis/saved_objects/find.js +++ b/test/api_integration/apis/saved_objects/find.js @@ -273,6 +273,65 @@ export default function ({ getService }) { }); })); }); + + describe('`has_reference` and `has_reference_operator` parameters', () => { + before(() => esArchiver.load('saved_objects/references')); + after(() => esArchiver.unload('saved_objects/references')); + + it('search for a reference', async () => { + await supertest + .get('/api/saved_objects/_find') + .query({ + type: 'visualization', + has_reference: JSON.stringify({ type: 'ref-type', id: 'ref-1' }), + }) + .expect(200) + .then((resp) => { + const objects = resp.body.saved_objects; + expect(objects.map((obj) => obj.id)).to.eql(['only-ref-1', 'ref-1-and-ref-2']); + }); + }); + + it('search for multiple references with OR operator', async () => { + await supertest + .get('/api/saved_objects/_find') + .query({ + type: 'visualization', + has_reference: JSON.stringify([ + { type: 'ref-type', id: 'ref-1' }, + { type: 'ref-type', id: 'ref-2' }, + ]), + has_reference_operator: 'OR', + }) + .expect(200) + .then((resp) => { + const objects = resp.body.saved_objects; + expect(objects.map((obj) => obj.id)).to.eql([ + 'only-ref-1', + 'ref-1-and-ref-2', + 'only-ref-2', + ]); + }); + }); + + it('search for multiple references with AND operator', async () => { + await supertest + .get('/api/saved_objects/_find') + .query({ + type: 'visualization', + has_reference: JSON.stringify([ + { type: 'ref-type', id: 'ref-1' }, + { type: 'ref-type', id: 'ref-2' }, + ]), + has_reference_operator: 'AND', + }) + .expect(200) + .then((resp) => { + const objects = resp.body.saved_objects; + expect(objects.map((obj) => obj.id)).to.eql(['ref-1-and-ref-2']); + }); + }); + }); }); describe('without kibana index', () => { diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index c1c78570d8fe1..6413f5e9226ee 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -120,6 +120,65 @@ export default function ({ getService }: FtrProviderContext) { }); })); }); + + describe('`hasReference` and `hasReferenceOperator` parameters', () => { + before(() => esArchiver.load('saved_objects/references')); + after(() => esArchiver.unload('saved_objects/references')); + + it('search for a reference', async () => { + await supertest + .get('/api/kibana/management/saved_objects/_find') + .query({ + type: 'visualization', + hasReference: JSON.stringify({ type: 'ref-type', id: 'ref-1' }), + }) + .expect(200) + .then((resp) => { + const objects = resp.body.saved_objects; + expect(objects.map((obj: any) => obj.id)).to.eql(['only-ref-1', 'ref-1-and-ref-2']); + }); + }); + + it('search for multiple references with OR operator', async () => { + await supertest + .get('/api/kibana/management/saved_objects/_find') + .query({ + type: 'visualization', + hasReference: JSON.stringify([ + { type: 'ref-type', id: 'ref-1' }, + { type: 'ref-type', id: 'ref-2' }, + ]), + hasReferenceOperator: 'OR', + }) + .expect(200) + .then((resp) => { + const objects = resp.body.saved_objects; + expect(objects.map((obj: any) => obj.id)).to.eql([ + 'only-ref-1', + 'ref-1-and-ref-2', + 'only-ref-2', + ]); + }); + }); + + it('search for multiple references with AND operator', async () => { + await supertest + .get('/api/kibana/management/saved_objects/_find') + .query({ + type: 'visualization', + hasReference: JSON.stringify([ + { type: 'ref-type', id: 'ref-1' }, + { type: 'ref-type', id: 'ref-2' }, + ]), + hasReferenceOperator: 'AND', + }) + .expect(200) + .then((resp) => { + const objects = resp.body.saved_objects; + expect(objects.map((obj: any) => obj.id)).to.eql(['ref-1-and-ref-2']); + }); + }); + }); }); describe('without kibana index', () => { diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/basic/mappings.json b/test/api_integration/fixtures/es_archiver/saved_objects/basic/mappings.json index 11a7e4cba7458..e601c43431437 100644 --- a/test/api_integration/fixtures/es_archiver/saved_objects/basic/mappings.json +++ b/test/api_integration/fixtures/es_archiver/saved_objects/basic/mappings.json @@ -190,6 +190,20 @@ "namespace": { "type": "keyword" }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, "type": { "type": "keyword" }, @@ -250,4 +264,4 @@ } } } -} \ No newline at end of file +} diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/references/data.json b/test/api_integration/fixtures/es_archiver/saved_objects/references/data.json new file mode 100644 index 0000000000000..8b2095972bd4d --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/saved_objects/references/data.json @@ -0,0 +1,120 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:only-ref-1", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Vis with ref to ref-1", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [ + { + "type": "ref-type", + "id": "ref-1", + "name": "ref-1" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:ref-1-and-ref-2", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Vis with ref to ref-1 and ref-2", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [ + { + "type": "ref-type", + "id": "ref-1", + "name": "ref-1" + }, + { + "type": "ref-type", + "id": "ref-2", + "name": "ref-2" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:only-ref-2", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Vis with ref to ref-2", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [ + { + "type": "ref-type", + "id": "ref-2", + "name": "ref-2" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:only-ref-3", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Vis with ref to ref-3", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [ + { + "type": "ref-type", + "id": "ref-3", + "name": "ref-3" + } + ] + } + } +} diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/references/mappings.json b/test/api_integration/fixtures/es_archiver/saved_objects/references/mappings.json new file mode 100644 index 0000000000000..e601c43431437 --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/saved_objects/references/mappings.json @@ -0,0 +1,267 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "number_of_replicas": "1" + } + }, + "mappings": { + "dynamic": "strict", + "properties": { + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "namespace": { + "type": "keyword" + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + } + } +} diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index b662fd62e4b02..21f0991f7efde 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -42,6 +42,7 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide needsConfirm?: boolean; storeTimeWithDashboard?: boolean; saveAsNew?: boolean; + tags?: string[]; } class DashboardPage { @@ -341,6 +342,10 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide await this.setSaveAsNewCheckBox(saveOptions.saveAsNew); } + if (saveOptions.tags) { + await this.selectDashboardTags(saveOptions.tags); + } + await this.clickSave(); if (saveOptions.waitDialogIsClosed) { await testSubjects.waitForDeleted(modalDialog); @@ -351,6 +356,14 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide await testSubjects.existOrFail('titleDupicateWarnMsg'); } + public async selectDashboardTags(tagNames: string[]) { + await testSubjects.click('savedObjectTagSelector'); + for (const tagName of tagNames) { + await testSubjects.click(`tagSelectorOption-${tagName.replace(' ', '_')}`); + } + await testSubjects.click('savedObjectTitle'); + } + /** * @param dashboardTitle {String} */ diff --git a/test/functional/page_objects/management/saved_objects_page.ts b/test/functional/page_objects/management/saved_objects_page.ts index 0742f14b2eeb4..fc8c8a5626d45 100644 --- a/test/functional/page_objects/management/saved_objects_page.ts +++ b/test/functional/page_objects/management/saved_objects_page.ts @@ -39,6 +39,11 @@ export function SavedObjectsPageProvider({ getService, getPageObjects }: FtrProv await this.waitTableIsLoaded(); } + async getCurrentSearchValue() { + const searchBox = await testSubjects.find('savedObjectSearchBar'); + return await searchBox.getAttribute('value'); + } + async importFile(path: string, overwriteAll = true) { log.debug(`importFile(${path})`); diff --git a/test/functional/services/listing_table.ts b/test/functional/services/listing_table.ts index aebdc734cfb39..0778fe954879d 100644 --- a/test/functional/services/listing_table.ts +++ b/test/functional/services/listing_table.ts @@ -56,7 +56,7 @@ export function ListingTableProvider({ getService, getPageObjects }: FtrProvider private async getAllItemsNamesOnCurrentPage(): Promise { const visualizationNames = []; - const links = await find.allByCssSelector('.kuiLink'); + const links = await find.allByCssSelector('.euiTableRow .euiLink'); for (let i = 0; i < links.length; i++) { visualizationNames.push(await links[i].getVisibleText()); } @@ -73,7 +73,9 @@ export function ListingTableProvider({ getService, getPageObjects }: FtrProvider let visualizationNames: string[] = []; while (morePages) { visualizationNames = visualizationNames.concat(await this.getAllItemsNamesOnCurrentPage()); - morePages = !((await testSubjects.getAttribute('pagerNextButton', 'disabled')) === 'true'); + morePages = !( + (await testSubjects.getAttribute('pagination-button-next', 'disabled')) === 'true' + ); if (morePages) { await testSubjects.click('pagerNextButton'); await header.waitUntilLoadingHasFinished(); @@ -99,15 +101,23 @@ export function ListingTableProvider({ getService, getPageObjects }: FtrProvider * Types name into search field on Landing page and waits till search completed * @param name item name */ - public async searchForItemWithName(name: string) { + public async searchForItemWithName(name: string, { escape = true }: { escape?: boolean } = {}) { log.debug(`searchForItemWithName: ${name}`); await retry.try(async () => { const searchFilter = await this.getSearchFilter(); await searchFilter.clearValue(); await searchFilter.click(); - // Note: this replacement of - to space is to preserve original logic but I'm not sure why or if it's needed. - await searchFilter.type(name.replace('-', ' ')); + + if (escape) { + name = name + // Note: this replacement of - to space is to preserve original logic but I'm not sure why or if it's needed. + .replace('-', ' ') + // Remove `[*]` from search as it is not supported by EUI Query's syntax. + .replace(/ *\[[^)]*\] */g, ''); + } + + await searchFilter.type(name); await common.pressEnterKey(); }); @@ -159,7 +169,9 @@ export function ListingTableProvider({ getService, getPageObjects }: FtrProvider * @param name item name */ public async clickItemLink(appName: 'dashboard' | 'visualize', name: string) { - await testSubjects.click(`${appName}ListingTitleLink-${name.split(' ').join('-')}`); + await testSubjects.click( + `${prefixMap[appName]}ListingTitleLink-${name.split(' ').join('-')}` + ); } /** diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 8993213d91f23..eb44ad4d4eafa 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -47,6 +47,7 @@ "xpack.securitySolution": "plugins/security_solution", "xpack.snapshotRestore": "plugins/snapshot_restore", "xpack.spaces": "plugins/spaces", + "xpack.savedObjectsTagging": ["plugins/saved_objects_tagging"], "xpack.taskManager": "legacy/plugins/task_manager", "xpack.transform": "plugins/transform", "xpack.triggersActionsUI": "plugins/triggers_actions_ui", diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index e516ab1cb73b3..6346e73e6ba51 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -1576,3 +1576,36 @@ describe('#update', () => { ); }); }); + +describe('#removeReferencesTo', () => { + it('redirects request to underlying base client', async () => { + const options = { namespace: 'some-ns' }; + + await wrapper.removeReferencesTo('some-type', 'some-id', options); + + expect(mockBaseClient.removeReferencesTo).toHaveBeenCalledTimes(1); + expect(mockBaseClient.removeReferencesTo).toHaveBeenCalledWith('some-type', 'some-id', options); + }); + + it('returns response from underlying client', async () => { + const returnValue = { + updated: 12, + }; + mockBaseClient.removeReferencesTo.mockResolvedValue(returnValue); + + const result = await wrapper.removeReferencesTo('known-type', 'some-id'); + + expect(result).toBe(returnValue); + }); + + it('fails if base client fails', async () => { + const failureReason = new Error('Something bad happened...'); + mockBaseClient.removeReferencesTo.mockRejectedValue(failureReason); + + await expect(wrapper.removeReferencesTo('known-type', 'some-id')).rejects.toThrowError( + failureReason + ); + + expect(mockBaseClient.removeReferencesTo).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index eef389186d670..59309ab67e772 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -22,7 +22,9 @@ import { SavedObjectsUpdateResponse, SavedObjectsAddToNamespacesOptions, SavedObjectsDeleteFromNamespacesOptions, + SavedObjectsRemoveReferencesToOptions, ISavedObjectTypeRegistry, + SavedObjectsRemoveReferencesToResponse, } from 'src/core/server'; import { AuthenticatedUser } from '../../../security/common/model'; import { EncryptedSavedObjectsService } from '../crypto'; @@ -257,6 +259,14 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.options.baseClient.deleteFromNamespaces(type, id, namespaces, options); } + public async removeReferencesTo( + type: string, + id: string, + options: SavedObjectsRemoveReferencesToOptions = {} + ): Promise { + return await this.options.baseClient.removeReferencesTo(type, id, options); + } + /** * Strips encrypted attributes from any non-bulk Saved Objects API response. If type isn't * registered, response is returned as is. diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index 63a59d59d6d07..f616daebf662a 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -82,6 +82,7 @@ Array [ "canvas-workpad", "lens", "map", + "tag", ], }, "ui": Array [ @@ -115,6 +116,7 @@ Array [ "map", "dashboard", "query", + "tag", ], }, "ui": Array [ @@ -437,6 +439,7 @@ Array [ "read": Array [ "index-pattern", "search", + "tag", ], }, "ui": Array [ @@ -467,6 +470,7 @@ Array [ "visualization", "query", "lens", + "tag", ], }, "ui": Array [ diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index d420aea49c6e1..46dd5fd086b4a 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -88,7 +88,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS catalogue: ['visualize'], savedObject: { all: ['visualization', 'query', 'lens'], - read: ['index-pattern', 'search'], + read: ['index-pattern', 'search', 'tag'], }, ui: ['show', 'delete', 'save', 'saveQuery'], }, @@ -97,7 +97,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS catalogue: ['visualize'], savedObject: { all: [], - read: ['index-pattern', 'search', 'visualization', 'query', 'lens'], + read: ['index-pattern', 'search', 'visualization', 'query', 'lens', 'tag'], }, ui: ['show'], }, @@ -155,6 +155,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS 'canvas-workpad', 'lens', 'map', + 'tag', ], }, ui: ['createNew', 'show', 'showWriteControls', 'saveQuery'], @@ -174,6 +175,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS 'map', 'dashboard', 'query', + 'tag', ], }, ui: ['show'], diff --git a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts index 421a72c989757..61ed01b53a969 100644 --- a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts +++ b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts @@ -18,7 +18,6 @@ import { checkForDuplicateTitle, saveWithConfirmation, isErrorNonFatal, - SavedObjectKibanaServices, } from '../../../../../src/plugins/saved_objects/public'; import { injectReferences, @@ -176,7 +175,7 @@ export async function saveSavedWorkspace( savedObject as any, isTitleDuplicateConfirmed, onTitleDuplicate, - services as SavedObjectKibanaServices + services ); savedObject.isSaving = true; diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index 100527accd1b9..c23c43120050c 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -15,7 +15,7 @@ "uiActions", "embeddable" ], - "optionalPlugins": ["usageCollection", "taskManager", "globalSearch"], + "optionalPlugins": ["usageCollection", "taskManager", "globalSearch", "savedObjectsTagging"], "configPath": ["xpack", "lens"], "extraPublicDirs": ["common/constants"], "requiredBundles": ["savedObjects", "kibanaUtils", "kibanaReact", "embeddable"] diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index c8c0f6d322118..831dd58c373a7 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -503,7 +503,7 @@ describe('Lens App', () => { async function testSave(inst: ReactWrapper, saveProps: SaveProps) { await getButton(inst).run(inst.getDOMNode()); inst.update(); - const handler = inst.find('[data-test-subj="lnsApp_saveModalOrigin"]').prop('onSave') as ( + const handler = inst.find('SavedObjectSaveModalOrigin').prop('onSave') as ( p: unknown ) => void; handler(saveProps); diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 366a27a8a9a05..cdd701271be2c 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -19,7 +19,6 @@ import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import { OnSaveProps, checkForDuplicateTitle, - SavedObjectSaveModalOrigin, } from '../../../../../src/plugins/saved_objects/public'; import { injectFilterReferences } from '../persistence'; import { NativeRenderer } from '../native_renderer'; @@ -33,6 +32,7 @@ import { import { LENS_EMBEDDABLE_TYPE, getFullPath } from '../../common'; import { LensAppProps, LensAppServices, LensAppState } from './types'; import { getLensTopNavConfig } from './lens_top_nav'; +import { TagEnhancedSavedObjectSaveModalOrigin } from './tags_saved_object_save_modal_origin_wrapper'; import { LensByReferenceInput, LensEmbeddableInput, @@ -59,6 +59,7 @@ export function App({ notifications, attributeService, savedObjectsClient, + savedObjectsTagging, getOriginatingAppName, // Temporarily required until the 'by value' paradigm is default. @@ -350,16 +351,24 @@ export function App({ returnToOrigin: boolean; onTitleDuplicate?: OnSaveProps['onTitleDuplicate']; newDescription?: string; + newTags?: string[]; }, options: { saveToLibrary: boolean } ) => { if (!lastKnownDoc) { return; } + + let references = lastKnownDoc.references; + if (savedObjectsTagging && saveProps.newTags) { + references = savedObjectsTagging.ui.updateTagsReferences(references, saveProps.newTags); + } + const docToSave = { ...getLastKnownDocWithoutPinnedFilters()!, description: saveProps.newDescription, title: saveProps.newTitle, + references, }; // Required to serialize filters in by value mode until @@ -508,6 +517,11 @@ export function App({ }, }); + const tagsIds = + state.persistedDoc && savedObjectsTagging + ? savedObjectsTagging.ui.getTagIdsFromReferences(state.persistedDoc.references) + : []; + return ( <>
@@ -623,7 +637,9 @@ export function App({ )}
{lastKnownDoc && state.isSaveModalVisible && ( - runSave(props, { saveToLibrary: true })} onClose={() => { diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index ac5d145eedd5b..c74ac951907e4 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -42,7 +42,7 @@ export async function mountApp( ) { const { createEditorFrame, getByValueFeatureFlag, attributeService } = mountProps; const [coreStart, startDependencies] = await core.getStartServices(); - const { data, navigation, embeddable } = startDependencies; + const { data, navigation, embeddable, savedObjectsTagging } = startDependencies; const instance = await createEditorFrame(); const storage = new Storage(localStorage); @@ -54,6 +54,7 @@ export async function mountApp( data, storage, navigation, + savedObjectsTagging, attributeService: await attributeService(), http: coreStart.http, chrome: coreStart.chrome, diff --git a/x-pack/plugins/lens/public/app_plugin/tags_saved_object_save_modal_origin_wrapper.tsx b/x-pack/plugins/lens/public/app_plugin/tags_saved_object_save_modal_origin_wrapper.tsx new file mode 100644 index 0000000000000..a904ecd05909a --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/tags_saved_object_save_modal_origin_wrapper.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState, useMemo, useCallback } from 'react'; +import { + OriginSaveModalProps, + SavedObjectSaveModalOrigin, + OnSaveProps, + SaveModalState, +} from '../../../../../src/plugins/saved_objects/public'; +import { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public'; + +type TagEnhancedSavedObjectSaveModalOriginProps = Omit & { + initialTags: string[]; + savedObjectsTagging?: SavedObjectTaggingPluginStart; + onSave: (props: OnSaveProps & { returnToOrigin: boolean; newTags?: string[] }) => void; +}; + +export const TagEnhancedSavedObjectSaveModalOrigin: FC = ({ + initialTags, + onSave, + savedObjectsTagging, + options, + ...otherProps +}) => { + const [selectedTags, setSelectedTags] = useState(initialTags); + + const tagSelectorOption = useMemo( + () => + savedObjectsTagging ? ( + + ) : undefined, + [savedObjectsTagging, initialTags] + ); + + const tagEnhancedOptions = + typeof options === 'function' ? ( + (state: SaveModalState) => { + return ( + <> + {tagSelectorOption} + {options(state)} + + ); + } + ) : ( + <> + {tagSelectorOption} + {options} + + ); + + const tagEnhancedOnSave: OriginSaveModalProps['onSave'] = useCallback( + (saveOptions) => { + onSave({ + ...saveOptions, + newTags: selectedTags, + }); + }, + [onSave, selectedTags] + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts index bd5a9b5a8ed0a..6c222bed7a83f 100644 --- a/x-pack/plugins/lens/public/app_plugin/types.ts +++ b/x-pack/plugins/lens/public/app_plugin/types.ts @@ -28,6 +28,7 @@ import { NavigationPublicPluginStart } from '../../../../../src/plugins/navigati import { LensAttributeService } from '../lens_attribute_service'; import { IStorageWrapper } from '../../../../../src/plugins/kibana_utils/public'; import { DashboardFeatureFlagConfig } from '../../../../../src/plugins/dashboard/public'; +import type { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public'; import { VisualizeFieldContext, ACTION_VISUALIZE_LENS_FIELD, @@ -99,6 +100,7 @@ export interface LensAppServices { navigation: NavigationPublicPluginStart; attributeService: LensAttributeService; savedObjectsClient: SavedObjectsStart['client']; + savedObjectsTagging?: SavedObjectTaggingPluginStart; getOriginatingAppName: () => string | undefined; // Temporarily required until the 'by value' paradigm is default. diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 533efcbfe427e..dddbadec00cf8 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -27,6 +27,7 @@ import { } from './datatable_visualization'; import { PieVisualization, PieVisualizationPluginSetupPlugins } from './pie_visualization'; import { AppNavLinkStatus } from '../../../../src/core/public'; +import type { SavedObjectTaggingPluginStart } from '../../saved_objects_tagging/public'; import { UiActionsStart, @@ -58,6 +59,7 @@ export interface LensPluginStartDependencies { uiActions: UiActionsStart; dashboard: DashboardStart; embeddable: EmbeddableStart; + savedObjectsTagging?: SavedObjectTaggingPluginStart; } export class LensPlugin { private datatableVisualization: DatatableVisualization; diff --git a/x-pack/plugins/saved_objects_tagging/README.md b/x-pack/plugins/saved_objects_tagging/README.md new file mode 100644 index 0000000000000..5e4281a8c4e7d --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/README.md @@ -0,0 +1,3 @@ +# SavedObjectsTagging + +Add tagging capability to saved objects \ No newline at end of file diff --git a/x-pack/plugins/saved_objects_tagging/common/capabilities.test.ts b/x-pack/plugins/saved_objects_tagging/common/capabilities.test.ts new file mode 100644 index 0000000000000..75bb4f72d7247 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/common/capabilities.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Capabilities } from 'src/core/types'; +import { getTagsCapabilities } from './capabilities'; +import { tagFeatureId } from './constants'; + +const createCapabilities = (taggingCaps: Record | undefined): Capabilities => ({ + navLinks: {}, + management: {}, + catalogue: {}, + ...(taggingCaps ? { [tagFeatureId]: taggingCaps } : {}), +}); + +describe('getTagsCapabilities', () => { + it('generates the tag capabilities', () => { + expect( + getTagsCapabilities( + createCapabilities({ + view: true, + create: false, + edit: false, + delete: false, + assign: true, + }) + ) + ).toEqual({ + view: true, + create: false, + edit: false, + delete: false, + assign: true, + viewConnections: false, + }); + }); + + it('returns all capabilities as disabled if the tag feature in not present', () => { + expect(getTagsCapabilities(createCapabilities(undefined))).toEqual({ + view: false, + create: false, + edit: false, + delete: false, + assign: false, + viewConnections: false, + }); + }); + + it('populates `viewConnections` from the so management capabilities', () => { + expect( + getTagsCapabilities({ + ...createCapabilities(undefined), + ...{ + savedObjectsManagement: { + read: true, + }, + }, + }) + ).toEqual( + expect.objectContaining({ + viewConnections: true, + }) + ); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/common/capabilities.ts b/x-pack/plugins/saved_objects_tagging/common/capabilities.ts new file mode 100644 index 0000000000000..6171c98ef0510 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/common/capabilities.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Capabilities } from 'src/core/types'; +import { tagFeatureId } from './constants'; + +/** + * Represent the UI capabilities for the `savedObjectsTagging` section of `Capabilities` + */ +export interface TagsCapabilities { + view: boolean; + create: boolean; + edit: boolean; + delete: boolean; + assign: boolean; + viewConnections: boolean; +} + +export const getTagsCapabilities = (capabilities: Capabilities): TagsCapabilities => { + const rawTagCapabilities = capabilities[tagFeatureId]; + return { + view: (rawTagCapabilities?.view as boolean) ?? false, + create: (rawTagCapabilities?.create as boolean) ?? false, + edit: (rawTagCapabilities?.edit as boolean) ?? false, + delete: (rawTagCapabilities?.delete as boolean) ?? false, + assign: (rawTagCapabilities?.assign as boolean) ?? false, + viewConnections: (capabilities.savedObjectsManagement?.read as boolean) ?? false, + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/common/constants.ts b/x-pack/plugins/saved_objects_tagging/common/constants.ts new file mode 100644 index 0000000000000..8f7ba86973f3c --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/common/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const tagFeatureId = 'savedObjectsTagging'; +export const tagSavedObjectTypeName = 'tag'; +export const tagManagementSectionId = 'tags'; diff --git a/x-pack/plugins/saved_objects_tagging/common/index.ts b/x-pack/plugins/saved_objects_tagging/common/index.ts new file mode 100644 index 0000000000000..4bb2e8840e4e3 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/common/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TagsCapabilities, getTagsCapabilities } from './capabilities'; +export { tagFeatureId, tagSavedObjectTypeName, tagManagementSectionId } from './constants'; +export { TagWithRelations, TagAttributes, Tag, ITagsClient, TagSavedObject } from './types'; +export { + TagValidation, + validateTagColor, + validateTagName, + validateTagDescription, + tagNameMinLength, + tagNameMaxLength, + tagDescriptionMaxLength, +} from './validation'; diff --git a/x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts b/x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts new file mode 100644 index 0000000000000..80d2dbc0b1566 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObject, SavedObjectReference } from 'src/core/types'; +import { Tag, TagAttributes } from '../types'; + +export const createTagReference = (id: string): SavedObjectReference => ({ + type: 'tag', + id, + name: `tag-ref-${id}`, +}); + +export const createSavedObject = (parts: Partial): SavedObject => ({ + type: 'tag', + id: 'id', + references: [], + attributes: {}, + ...parts, +}); + +export const createTag = (parts: Partial = {}): Tag => ({ + id: 'tag-id', + name: 'some-tag', + description: 'Some tag', + color: '#FF00CC', + ...parts, +}); + +export const createTagAttributes = (parts: Partial = {}): TagAttributes => ({ + name: 'some-tag', + description: 'Some tag', + color: '#FF00CC', + ...parts, +}); diff --git a/x-pack/plugins/saved_objects_tagging/common/types.ts b/x-pack/plugins/saved_objects_tagging/common/types.ts new file mode 100644 index 0000000000000..c73ef30659bff --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/common/types.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObject } from 'src/core/types'; +import type { Tag, TagAttributes } from '../../../../src/plugins/saved_objects_tagging_oss/common'; + +export type TagSavedObject = SavedObject; + +export type TagWithRelations = Tag & { + /** + * The number of objects that are assigned to this tag. + */ + relationCount: number; +}; + +// re-export types from oss definition +export type { + Tag, + TagAttributes, + ITagsClient, +} from '../../../../src/plugins/saved_objects_tagging_oss/common'; diff --git a/x-pack/plugins/saved_objects_tagging/common/validation.test.ts b/x-pack/plugins/saved_objects_tagging/common/validation.test.ts new file mode 100644 index 0000000000000..232387e964cbf --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/common/validation.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { validateTagColor, validateTagName, validateTagDescription } from './validation'; + +describe('Tag attributes validation', () => { + describe('validateTagName', () => { + it('returns an error message if the name is too short', () => { + expect(validateTagName('a')).toMatchInlineSnapshot( + `"Tag name must be at least 2 characters"` + ); + }); + + it('returns an error message if the name is too long', () => { + expect(validateTagName('a'.repeat(55))).toMatchInlineSnapshot( + `"Tag name may not exceed 50 characters"` + ); + }); + + it('returns an error message if the name contains invalid characters', () => { + expect(validateTagName('t^ag+name&')).toMatchInlineSnapshot( + `"Tag name can only include a-z, 0-9, _, -,:."` + ); + }); + }); + + describe('validateTagColor', () => { + it('returns no error for valid uppercase hex colors', () => { + expect(validateTagColor('#F7D8C4')).toBeUndefined(); + }); + it('returns no error for valid lowercase hex colors', () => { + expect(validateTagColor('#4ac1b7')).toBeUndefined(); + }); + it('returns no error for valid mixed case hex colors', () => { + expect(validateTagColor('#AfeBdC')).toBeUndefined(); + }); + it('returns an error for 3 chars hex colors', () => { + expect(validateTagColor('#AAA')).toMatchInlineSnapshot( + `"Tag color must be a valid hex color"` + ); + }); + it('returns an error for invalid hex colors', () => { + expect(validateTagColor('#Z1B2C3')).toMatchInlineSnapshot( + `"Tag color must be a valid hex color"` + ); + }); + it('returns an error for other strings', () => { + expect(validateTagColor('hello dolly')).toMatchInlineSnapshot( + `"Tag color must be a valid hex color"` + ); + }); + }); + + describe('validateTagDescription', () => { + it('returns an error message if the description is too long', () => { + expect(validateTagDescription('a'.repeat(101))).toMatchInlineSnapshot( + `"Tag description may not exceed 100 characters"` + ); + }); + + it('returns no error if the description is valid', () => { + expect(validateTagDescription('some valid description')).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/common/validation.ts b/x-pack/plugins/saved_objects_tagging/common/validation.ts new file mode 100644 index 0000000000000..12149d7bdbe79 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/common/validation.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { Tag } from './types'; + +export const tagNameMinLength = 2; +export const tagNameMaxLength = 50; +export const tagDescriptionMaxLength = 100; + +const hexColorRegexp = /^#[0-9A-F]{6}$/i; +const nameValidCharsRegexp = /^[0-9A-Z:\-_\s]+$/i; + +export interface TagValidation { + valid: boolean; + warnings: string[]; + errors: Partial>; +} + +const isHexColor = (color: string): boolean => { + return hexColorRegexp.test(color); +}; + +export const validateTagColor = (color: string): string | undefined => { + if (!isHexColor(color)) { + return i18n.translate('xpack.savedObjectsTagging.validation.color.errorInvalid', { + defaultMessage: 'Tag color must be a valid hex color', + }); + } +}; + +export const validateTagName = (name: string): string | undefined => { + if (name.length < tagNameMinLength) { + return i18n.translate('xpack.savedObjectsTagging.validation.name.errorTooShort', { + defaultMessage: 'Tag name must be at least {length} characters', + values: { + length: tagNameMinLength, + }, + }); + } + if (name.length > tagNameMaxLength) { + return i18n.translate('xpack.savedObjectsTagging.validation.name.errorTooLong', { + defaultMessage: 'Tag name may not exceed {length} characters', + values: { + length: tagNameMaxLength, + }, + }); + } + if (!nameValidCharsRegexp.test(name)) { + return i18n.translate('xpack.savedObjectsTagging.validation.name.errorInvalidCharacters', { + defaultMessage: 'Tag name can only include a-z, 0-9, _, -,:.', + }); + } +}; + +export const validateTagDescription = (description: string): string | undefined => { + if (description.length > tagDescriptionMaxLength) { + return i18n.translate('xpack.savedObjectsTagging.validation.description.errorTooLong', { + defaultMessage: 'Tag description may not exceed {length} characters', + values: { + length: tagDescriptionMaxLength, + }, + }); + } +}; diff --git a/x-pack/plugins/saved_objects_tagging/kibana.json b/x-pack/plugins/saved_objects_tagging/kibana.json new file mode 100644 index 0000000000000..89c5e7a134339 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "savedObjectsTagging", + "version": "8.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "configPath": ["xpack", "saved_object_tagging"], + "requiredPlugins": ["features", "management", "savedObjectsTaggingOss"], + "requiredBundles": ["kibanaReact"] +} diff --git a/x-pack/plugins/saved_objects_tagging/public/components/base/index.ts b/x-pack/plugins/saved_objects_tagging/public/components/base/index.ts new file mode 100644 index 0000000000000..d81d28d96250a --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/base/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TagBadge, TagBadgeProps } from './tag_badge'; +export { TagList, TagListProps } from './tag_list'; +export { TagSelector, TagSelectorProps } from './tag_selector'; +export { TagSearchBarOption, TagSearchBarOptionProps } from './tag_searchbar_option'; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/base/tag_badge.tsx b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_badge.tsx new file mode 100644 index 0000000000000..406e5df43f999 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_badge.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiBadge } from '@elastic/eui'; +import { Tag, TagAttributes } from '../../../common/types'; + +export interface TagBadgeProps { + tag: Tag | TagAttributes; +} + +/** + * The badge representation of a Tag, which is the default display to be used for them. + */ +export const TagBadge: FC = ({ tag }) => { + return {tag.name}; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/base/tag_list.tsx b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_list.tsx new file mode 100644 index 0000000000000..a9fe4c1c119a1 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_list.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiBadgeGroup } from '@elastic/eui'; +import { Tag, TagAttributes } from '../../../common/types'; +import { TagBadge } from './tag_badge'; + +export interface TagListProps { + tags: Array; +} + +/** + * Displays a list of tag + */ +export const TagList: FC = ({ tags }) => { + return ( + + {tags.map((tag) => ( + + ))} + + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/base/tag_searchbar_option.tsx b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_searchbar_option.tsx new file mode 100644 index 0000000000000..c505efd9befb5 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_searchbar_option.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiHealth, EuiText } from '@elastic/eui'; +import { Tag } from '../../../common'; +import { testSubjFriendly } from '../../utils'; + +export interface TagSearchBarOptionProps { + tag: Tag; +} + +export const TagSearchBarOption: FC = ({ tag }) => { + const { name, color } = tag; + return ( + + + {name} + + + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/base/tag_selector.tsx b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_selector.tsx new file mode 100644 index 0000000000000..c915ea4eb82d4 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_selector.tsx @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useMemo, useCallback, useState } from 'react'; +import { + EuiComboBox, + EuiHealth, + EuiHighlight, + EuiComboBoxOptionOption, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Tag } from '../../../common'; +import { testSubjFriendly } from '../../utils'; +import { CreateModalOpener } from '../edition_modal'; + +interface CreateOption { + type: '__create_option__'; +} + +const createOptionValue: CreateOption = { + type: '__create_option__', +}; + +type TagComboBoxOption = EuiComboBoxOptionOption; + +function isTagOption(option: TagComboBoxOption): option is EuiComboBoxOptionOption { + const value = option.value as Tag; + return value.name !== undefined && value.color !== undefined && value.id !== undefined; +} + +function isCreateOption( + option: TagComboBoxOption +): option is EuiComboBoxOptionOption { + const value = option.value as CreateOption; + return value.type === '__create_option__'; +} + +export interface TagSelectorProps { + tags: Tag[]; + selected: string[]; + onTagsSelected: (ids: string[]) => void; + 'data-test-subj'?: string; + allowCreate: boolean; + openCreateModal: CreateModalOpener; +} + +const renderCreateOption = () => { + return ( + + + + + + + + + + + ); +}; + +const renderTagOption = ( + option: EuiComboBoxOptionOption, + searchValue: string, + contentClassName: string +) => { + const { name, color } = option.value ?? { name: '' }; + return ( + + + {name} + + + ); +}; + +const renderOption = (option: TagComboBoxOption, searchValue: string, contentClassName: string) => { + if (isCreateOption(option)) { + return renderCreateOption(); + } + // just having an if/else block is not enough for TS to infer the type in the else block. strange... + if (isTagOption(option)) { + return renderTagOption(option, searchValue, contentClassName); + } +}; + +export const TagSelector: FC = ({ + tags, + selected, + onTagsSelected, + allowCreate, + openCreateModal, + ...otherProps +}) => { + const [currentSearch, setCurrentSearch] = useState(''); + + // We are forcing the 'create tag' option to always appear by having its + // label matching the current search term. This is a workaround to address + // the 'limitations' of the combobox that does not allow that feature + // out of the box + const createTagOption = useMemo(() => { + // label and color will never be actually used for rendering. + // label will only be used to check if the option matches the search, + // which will always be true because we set its value to the current search. + // The extra whitespace is required to avoid the combobox to consider that the value + // is selected when closing the dropdown + return { + label: `${currentSearch} `, + color: '#FFFFFF', + value: createOptionValue, + }; + }, [currentSearch]); + + // we append the 'create' option if user is allowed to create tags + const options: TagComboBoxOption[] = useMemo(() => { + return [ + ...tags.map((tag) => ({ + label: tag.name, + color: tag.color, + value: tag, + })), + ...(allowCreate ? [createTagOption] : []), + ]; + }, [allowCreate, tags, createTagOption]); + + const selectedOptions = useMemo(() => { + return options.filter((option) => isTagOption(option) && selected.includes(option.value!.id)); + }, [selected, options]); + + const onChange = useCallback( + (newSelectedOptions: TagComboBoxOption[]) => { + // when clicking on the 'create' option, it is selected. + // we need to remove it from the selection and then open the + // create modal instead. + const tagOptions = newSelectedOptions.filter(isTagOption); + const selectedIds = tagOptions.map((option) => option.value!.id); + onTagsSelected(selectedIds); + + if (newSelectedOptions.find(isCreateOption)) { + openCreateModal({ + defaultValues: { + name: currentSearch, + }, + onCreate: (tag) => { + onTagsSelected([...selected, tag.id]); + }, + }); + } + }, + [selected, onTagsSelected, openCreateModal, currentSearch] + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/connected/index.ts b/x-pack/plugins/saved_objects_tagging/public/components/connected/index.ts new file mode 100644 index 0000000000000..c1c7ee91d60cd --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/connected/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getConnectedTagListComponent } from './tag_list'; +export { getConnectedTagSelectorComponent } from './tag_selector'; +export { getConnectedSavedObjectModalTagSelectorComponent } from './saved_object_save_modal_tag_selector'; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/connected/saved_object_save_modal_tag_selector.tsx b/x-pack/plugins/saved_objects_tagging/public/components/connected/saved_object_save_modal_tag_selector.tsx new file mode 100644 index 0000000000000..53e5a27b9b5d7 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/connected/saved_object_save_modal_tag_selector.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useCallback, useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { EuiFormRow } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { SavedObjectSaveModalTagSelectorComponentProps } from '../../../../../../src/plugins/saved_objects_tagging_oss/public'; +import { TagsCapabilities } from '../../../common'; +import { TagSelector } from '../base'; +import { ITagsCache } from '../../tags'; +import { CreateModalOpener } from '../edition_modal'; + +interface GetConnectedTagSelectorOptions { + cache: ITagsCache; + capabilities: TagsCapabilities; + openCreateModal: CreateModalOpener; +} + +export const getConnectedSavedObjectModalTagSelectorComponent = ({ + cache, + capabilities, + openCreateModal, +}: GetConnectedTagSelectorOptions): FC => { + return ({ + initialSelection, + onTagsSelected: notifySelectionChange, + }: SavedObjectSaveModalTagSelectorComponentProps) => { + const tags = useObservable(cache.getState$(), cache.getState()); + const [selected, setSelected] = useState(initialSelection); + + const setSelectedInternal = useCallback( + (newSelection: string[]) => { + setSelected(newSelection); + notifySelectionChange(newSelection); + }, + [notifySelectionChange] + ); + + return ( + + } + > + + + ); + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx new file mode 100644 index 0000000000000..2ac3fe4fc9ad0 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { SavedObject } from 'src/core/types'; +import { TagListComponentProps } from '../../../../../../src/plugins/saved_objects_tagging_oss/public'; +import { Tag } from '../../../common/types'; +import { getObjectTags } from '../../utils'; +import { TagList } from '../base'; +import { ITagsCache } from '../../tags'; +import { byNameTagSorter } from '../../utils'; + +interface SavedObjectTagListProps { + object: SavedObject; + tags: Tag[]; +} + +const SavedObjectTagList: FC = ({ object, tags: allTags }) => { + const objectTags = useMemo(() => { + const { tags } = getObjectTags(object, allTags); + tags.sort(byNameTagSorter); + return tags; + }, [object, allTags]); + + return ; +}; + +interface GetConnectedTagListOptions { + cache: ITagsCache; +} + +export const getConnectedTagListComponent = ({ + cache, +}: GetConnectedTagListOptions): FC => { + return (props: TagListComponentProps) => { + const tags = useObservable(cache.getState$(), cache.getState()); + return ; + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_selector.tsx b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_selector.tsx new file mode 100644 index 0000000000000..04e567c8d2f3b --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_selector.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { TagSelectorComponentProps } from '../../../../../../src/plugins/saved_objects_tagging_oss/public'; +import { TagsCapabilities } from '../../../common'; +import { TagSelector } from '../base'; +import { ITagsCache } from '../../tags'; +import { CreateModalOpener } from '../edition_modal'; + +interface GetConnectedTagSelectorOptions { + cache: ITagsCache; + capabilities: TagsCapabilities; + openCreateModal: CreateModalOpener; +} + +export const getConnectedTagSelectorComponent = ({ + cache, + capabilities, + openCreateModal, +}: GetConnectedTagSelectorOptions): FC => { + return (props: TagSelectorComponentProps) => { + const tags = useObservable(cache.getState$(), cache.getState()); + return ( + + ); + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_modal.tsx new file mode 100644 index 0000000000000..d6ccce88e9b4a --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_modal.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState, useCallback } from 'react'; +import { ITagsClient, Tag, TagAttributes } from '../../../common/types'; +import { TagValidation } from '../../../common/validation'; +import { isServerValidationError } from '../../tags'; +import { getRandomColor, validateTag } from './utils'; +import { CreateOrEditModal } from './create_or_edit_modal'; + +interface CreateTagModalProps { + defaultValues?: Partial; + onClose: () => void; + onSave: (tag: Tag) => void; + tagClient: ITagsClient; +} + +const getDefaultAttributes = (providedDefaults?: Partial): TagAttributes => ({ + name: '', + description: '', + color: getRandomColor(), + ...providedDefaults, +}); + +const initialValidation: TagValidation = { + valid: true, + warnings: [], + errors: {}, +}; + +export const CreateTagModal: FC = ({ + defaultValues, + tagClient, + onClose, + onSave, +}) => { + const [validation, setValidation] = useState(initialValidation); + const [tagAttributes, setTagAttributes] = useState( + getDefaultAttributes(defaultValues) + ); + + const setField = useCallback( + (field: T) => (value: TagAttributes[T]) => { + setTagAttributes((current) => ({ + ...current, + [field]: value, + })); + }, + [] + ); + + const onSubmit = useCallback(async () => { + const clientValidation = validateTag(tagAttributes); + setValidation(clientValidation); + if (!clientValidation.valid) { + return; + } + + try { + const createdTag = await tagClient.create(tagAttributes); + onSave(createdTag); + } catch (e) { + // if e is HttpFetchError, actual server error payload is in e.body + if (isServerValidationError(e.body)) { + setValidation(e.body.attributes); + } + } + }, [tagAttributes, tagClient, onSave]); + + return ( + + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_or_edit_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_or_edit_modal.tsx new file mode 100644 index 0000000000000..7baebdae2493e --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_or_edit_modal.tsx @@ -0,0 +1,246 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState, useCallback, useMemo } from 'react'; +import { + EuiButtonEmpty, + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiFlexItem, + EuiFlexGroup, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiColorPicker, + EuiTextArea, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + TagAttributes, + TagValidation, + validateTagColor, + tagNameMaxLength, + tagDescriptionMaxLength, +} from '../../../common'; +import { TagBadge } from '../../components'; +import { getRandomColor, useIfMounted } from './utils'; + +interface CreateOrEditModalProps { + onClose: () => void; + onSubmit: () => Promise; + mode: 'create' | 'edit'; + tag: TagAttributes; + validation: TagValidation; + setField: (field: T) => (value: TagAttributes[T]) => void; +} + +export const CreateOrEditModal: FC = ({ + onClose, + onSubmit, + validation, + setField, + tag, + mode, +}) => { + const ifMounted = useIfMounted(); + const [submitting, setSubmitting] = useState(false); + + // we don't want this value to change when the user edit the name. + // eslint-disable-next-line react-hooks/exhaustive-deps + const initialName = useMemo(() => tag.name, []); + + const setName = useMemo(() => setField('name'), [setField]); + const setColor = useMemo(() => setField('color'), [setField]); + const setDescription = useMemo(() => setField('description'), [setField]); + + const isEdit = useMemo(() => mode === 'edit', [mode]); + + const previewTag: TagAttributes = useMemo(() => { + return { + ...tag, + name: tag.name || 'tag', + color: validateTagColor(tag.color) ? '#000000' : tag.color, + }; + }, [tag]); + + const onFormSubmit = useCallback(async () => { + setSubmitting(true); + await onSubmit(); + // onSubmit can close the modal, causing errors in the console when the component tries to setState. + ifMounted(() => { + setSubmitting(false); + }); + }, [ifMounted, onSubmit]); + + return ( + + + + {isEdit ? ( + + ) : ( + + )} + + + + + + + + setName(e.target.value)} + data-test-subj="createModalField-name" + /> + + + + setColor(getRandomColor())} + size="xs" + style={{ height: '18px', fontSize: '0.75rem' }} + > + + + } + > + setColor(text)} + format="hex" + data-test-subj="createModalField-color" + /> + + + + + + + + } + isInvalid={!!validation.errors.description} + error={validation.errors.description} + > + setDescription(e.target.value)} + data-test-subj="createModalField-description" + resize="none" + fullWidth={true} + compressed={true} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + {isEdit ? ( + + ) : ( + + )} + + + + + + + + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/edit_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/edit_modal.tsx new file mode 100644 index 0000000000000..b3898dde9e953 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/edit_modal.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState, useCallback } from 'react'; +import { ITagsClient, Tag, TagAttributes } from '../../../common/types'; +import { TagValidation } from '../../../common/validation'; +import { isServerValidationError } from '../../tags'; +import { CreateOrEditModal } from './create_or_edit_modal'; +import { validateTag } from './utils'; + +interface EditTagModalProps { + tag: Tag; + onClose: () => void; + onSave: (tag: Tag) => void; + tagClient: ITagsClient; +} + +const initialValidation: TagValidation = { + valid: true, + warnings: [], + errors: {}, +}; + +const getAttributes = (tag: Tag): TagAttributes => { + const { id, ...attributes } = tag; + return attributes; +}; + +export const EditTagModal: FC = ({ tag, onSave, onClose, tagClient }) => { + const [validation, setValidation] = useState(initialValidation); + const [tagAttributes, setTagAttributes] = useState(getAttributes(tag)); + + const setField = useCallback( + (field: T) => (value: TagAttributes[T]) => { + setTagAttributes((current) => ({ + ...current, + [field]: value, + })); + }, + [] + ); + + const onSubmit = useCallback(async () => { + const clientValidation = validateTag(tagAttributes); + setValidation(clientValidation); + if (!clientValidation.valid) { + return; + } + + try { + const createdTag = await tagClient.update(tag.id, tagAttributes); + onSave(createdTag); + } catch (e) { + // if e is HttpFetchError, actual server error payload is in e.body + if (isServerValidationError(e.body)) { + setValidation(e.body.attributes); + } + } + }, [tagAttributes, tagClient, onSave, tag]); + + return ( + + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/index.ts b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/index.ts new file mode 100644 index 0000000000000..94a12e1ac5693 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getCreateModalOpener, getEditModalOpener, CreateModalOpener } from './open_modal'; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/open_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/open_modal.tsx new file mode 100644 index 0000000000000..bfe17b88aa512 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/open_modal.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { OverlayStart, OverlayRef } from 'src/core/public'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; +import { Tag, TagAttributes } from '../../../common/types'; +import { ITagInternalClient } from '../../tags'; + +interface GetModalOpenerOptions { + overlays: OverlayStart; + tagClient: ITagInternalClient; +} + +interface OpenCreateModalOptions { + defaultValues?: Partial; + onCreate: (tag: Tag) => void; +} + +export type CreateModalOpener = (options: OpenCreateModalOptions) => Promise; + +export const getCreateModalOpener = ({ + overlays, + tagClient, +}: GetModalOpenerOptions): CreateModalOpener => async ({ + onCreate, + defaultValues, +}: OpenCreateModalOptions) => { + const { CreateTagModal } = await import('./create_modal'); + const modal = overlays.openModal( + toMountPoint( + { + modal.close(); + }} + onSave={(tag) => { + modal.close(); + onCreate(tag); + }} + tagClient={tagClient} + /> + ) + ); + return modal; +}; + +interface OpenEditModalOptions { + tagId: string; + onUpdate: (tag: Tag) => void; +} + +export const getEditModalOpener = ({ overlays, tagClient }: GetModalOpenerOptions) => async ({ + tagId, + onUpdate, +}: OpenEditModalOptions) => { + const { EditTagModal } = await import('./edit_modal'); + const tag = await tagClient.get(tagId); + + const modal = overlays.openModal( + toMountPoint( + { + modal.close(); + }} + onSave={(saved) => { + modal.close(); + onUpdate(saved); + }} + tagClient={tagClient} + /> + ) + ); + + return modal; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/utils.ts b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/utils.ts new file mode 100644 index 0000000000000..d68c2fbfabdd6 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/utils.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useEffect, useRef } from 'react'; +import { + TagAttributes, + TagValidation, + validateTagColor, + validateTagName, + validateTagDescription, +} from '../../../common'; + +/** + * Returns the hex representation of a random color (e.g `#F1B7E2`) + */ +export const getRandomColor = (): string => { + return '#' + String(Math.floor(Math.random() * 16777215).toString(16)).padStart(6, '0'); +}; + +export const validateTag = (tag: TagAttributes): TagValidation => { + const validation: TagValidation = { + valid: true, + warnings: [], + errors: {}, + }; + + validation.errors.name = validateTagName(tag.name); + validation.errors.color = validateTagColor(tag.color); + validation.errors.description = validateTagDescription(tag.description); + + Object.values(validation.errors).forEach((error) => { + if (error) { + validation.valid = false; + } + }); + + return validation; +}; + +export const useIfMounted = () => { + const isMounted = useRef(true); + useEffect( + () => () => { + isMounted.current = false; + }, + [] + ); + + const ifMounted = useCallback((func) => { + if (isMounted.current && func) { + func(); + } + }, []); + + return ifMounted; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/index.ts b/x-pack/plugins/saved_objects_tagging/public/components/index.ts new file mode 100644 index 0000000000000..45eaf717177dd --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/index.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + TagSelector, + TagSelectorProps, + TagList, + TagListProps, + TagBadge, + TagBadgeProps, + TagSearchBarOption, + TagSearchBarOptionProps, +} from './base'; diff --git a/x-pack/plugins/saved_objects_tagging/public/config.ts b/x-pack/plugins/saved_objects_tagging/public/config.ts new file mode 100644 index 0000000000000..4c467eab02291 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/config.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; + +export interface SavedObjectsTaggingClientConfigRawType { + // is a string because the server-side counterpart is a duration + // which is serialized to string when sent to the client + cache_refresh_interval: string; +} + +export class SavedObjectsTaggingClientConfig { + public cacheRefreshInterval: moment.Duration; + + constructor(rawConfig: SavedObjectsTaggingClientConfigRawType) { + this.cacheRefreshInterval = moment.duration(rawConfig.cache_refresh_interval); + } +} diff --git a/x-pack/plugins/saved_objects_tagging/public/index.ts b/x-pack/plugins/saved_objects_tagging/public/index.ts new file mode 100644 index 0000000000000..9519e40d1d2f5 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from '../../../../src/core/public'; +import { SavedObjectTaggingPlugin } from './plugin'; + +export { SavedObjectTaggingPluginStart } from './types'; + +export const plugin = (initializerContext: PluginInitializerContext) => + new SavedObjectTaggingPlugin(initializerContext); diff --git a/x-pack/plugins/saved_objects_tagging/public/management/components/header.tsx b/x-pack/plugins/saved_objects_tagging/public/management/components/header.tsx new file mode 100644 index 0000000000000..73a8b19ae7788 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/components/header.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { + EuiSpacer, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTextColor, + EuiButton, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface HeaderProps { + canCreate: boolean; + onCreate: () => void; +} + +export const Header: FC = ({ canCreate, onCreate }) => { + return ( + <> + + + +

+ +

+
+
+ + {canCreate && ( + + + + )} + +
+ + +

+ + + +

+
+ + + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/components/index.ts b/x-pack/plugins/saved_objects_tagging/public/management/components/index.ts new file mode 100644 index 0000000000000..8435aa0431c23 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/components/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Header } from './header'; +export { TagTable } from './table'; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/components/table.tsx b/x-pack/plugins/saved_objects_tagging/public/management/components/table.tsx new file mode 100644 index 0000000000000..e86977c60ade1 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/components/table.tsx @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useRef, useEffect, FC } from 'react'; +import { EuiInMemoryTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui'; +import { Action as EuiTableAction } from '@elastic/eui/src/components/basic_table/action_types'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { TagsCapabilities, TagWithRelations } from '../../../common'; +import { TagBadge } from '../../components'; + +interface TagTableProps { + loading: boolean; + capabilities: TagsCapabilities; + tags: TagWithRelations[]; + selectedTags: TagWithRelations[]; + onSelectionChange: (selection: TagWithRelations[]) => void; + onEdit: (tag: TagWithRelations) => void; + onDelete: (tag: TagWithRelations) => void; + getTagRelationUrl: (tag: TagWithRelations) => string; + onShowRelations: (tag: TagWithRelations) => void; +} + +const tablePagination = { + initialPageSize: 20, + pageSizeOptions: [5, 10, 20, 50], +}; + +const sorting = { + sort: { + field: 'name', + direction: 'asc' as const, + }, +}; + +export const isModifiedOrPrevented = (event: React.MouseEvent) => + event.metaKey || event.altKey || event.ctrlKey || event.shiftKey || event.defaultPrevented; + +export const TagTable: FC = ({ + loading, + capabilities, + tags, + selectedTags, + onEdit, + onDelete, + onShowRelations, + getTagRelationUrl, +}) => { + const tableRef = useRef>(null); + + useEffect(() => { + if (tableRef.current) { + tableRef.current.setSelection(selectedTags); + } + }, [selectedTags]); + + const actions: Array> = []; + if (capabilities.edit) { + actions.push({ + name: i18n.translate('xpack.savedObjectsTagging.management.table.actions.edit.title', { + defaultMessage: 'Edit', + }), + description: i18n.translate( + 'xpack.savedObjectsTagging.management.table.actions.edit.description', + { + defaultMessage: 'Edit this tag', + } + ), + type: 'icon', + icon: 'pencil', + onClick: (object: TagWithRelations) => onEdit(object), + 'data-test-subj': 'tagsTableAction-edit', + }); + } + if (capabilities.delete) { + actions.push({ + name: i18n.translate('xpack.savedObjectsTagging.management.table.actions.delete.title', { + defaultMessage: 'Delete', + }), + description: i18n.translate( + 'xpack.savedObjectsTagging.management.table.actions.delete.description', + { + defaultMessage: 'Delete this tag', + } + ), + type: 'icon', + icon: 'trash', + onClick: (object: TagWithRelations) => onDelete(object), + 'data-test-subj': 'tagsTableAction-delete', + }); + } + + const columns: Array> = [ + { + field: 'name', + name: i18n.translate('xpack.savedObjectsTagging.management.table.columns.name', { + defaultMessage: 'Name', + }), + sortable: (tag: TagWithRelations) => tag.name, + 'data-test-subj': 'tagsTableRowName', + render: (name: string, tag: TagWithRelations) => { + return ; + }, + }, + { + field: 'description', + name: i18n.translate('xpack.savedObjectsTagging.management.table.columns.description', { + defaultMessage: 'Description', + }), + sortable: true, + 'data-test-subj': 'tagsTableRowDescription', + }, + { + field: 'relationCount', + name: i18n.translate('xpack.savedObjectsTagging.management.table.columns.connections', { + defaultMessage: 'Connections', + }), + sortable: (tag: TagWithRelations) => tag.relationCount, + 'data-test-subj': 'tagsTableRowConnections', + render: (relationCount: number, tag: TagWithRelations) => { + if (relationCount < 1) { + return undefined; + } + + const columnText = ( + + + + ); + + return capabilities.viewConnections ? ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + { + if (!isModifiedOrPrevented(e) && e.button === 0) { + e.preventDefault(); + onShowRelations(tag); + } + }} + > + {columnText} + + ) : ( + columnText + ); + }, + }, + ...(actions.length + ? [ + { + name: i18n.translate('xpack.savedObjectsTagging.management.table.columns.actions', { + defaultMessage: 'Actions', + }), + width: '100px', + actions, + }, + ] + : []), + ]; + + return ( + ({ + 'data-test-subj': 'tagsTableRow', + })} + /> + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/index.ts b/x-pack/plugins/saved_objects_tagging/public/management/index.ts new file mode 100644 index 0000000000000..f8f035039491b --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { mountSection } from './mount_section'; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/mount_section.tsx b/x-pack/plugins/saved_objects_tagging/public/management/mount_section.tsx new file mode 100644 index 0000000000000..8d6296c194abd --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/mount_section.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import ReactDOM from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { CoreSetup, ApplicationStart } from 'src/core/public'; +import { ManagementAppMountParams } from '../../../../../src/plugins/management/public'; +import { getTagsCapabilities } from '../../common'; +import { SavedObjectTaggingPluginStart } from '../types'; +import { ITagInternalClient } from '../tags'; +import { TagManagementPage } from './tag_management_page'; + +interface MountSectionParams { + tagClient: ITagInternalClient; + core: CoreSetup<{}, SavedObjectTaggingPluginStart>; + mountParams: ManagementAppMountParams; +} + +const RedirectToHomeIfUnauthorized: FC<{ + applications: ApplicationStart; +}> = ({ applications, children }) => { + const allowed = applications.capabilities?.management?.kibana?.tags ?? false; + if (!allowed) { + applications.navigateToApp('home'); + return null; + } + return children! as React.ReactElement; +}; + +export const mountSection = async ({ tagClient, core, mountParams }: MountSectionParams) => { + const [coreStart] = await core.getStartServices(); + const { element, setBreadcrumbs } = mountParams; + const capabilities = getTagsCapabilities(coreStart.application.capabilities); + + ReactDOM.render( + + + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx b/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx new file mode 100644 index 0000000000000..4afb15bec6243 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useCallback, useState, useMemo, FC } from 'react'; +import useMount from 'react-use/lib/useMount'; +import { EuiPageContent } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ChromeBreadcrumb, CoreStart } from 'src/core/public'; +import { TagWithRelations, TagsCapabilities } from '../../common'; +import { getCreateModalOpener, getEditModalOpener } from '../components/edition_modal'; +import { ITagInternalClient } from '../tags'; +import { Header, TagTable } from './components'; +import { getTagConnectionsUrl } from './utils'; + +interface TagManagementPageParams { + setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; + core: CoreStart; + tagClient: ITagInternalClient; + capabilities: TagsCapabilities; +} + +export const TagManagementPage: FC = ({ + setBreadcrumbs, + core, + tagClient, + capabilities, +}) => { + const { overlays, notifications, application, http } = core; + const [loading, setLoading] = useState(false); + const [allTags, setAllTags] = useState([]); + const [selectedTags, setSelectedTags] = useState([]); + + const createModalOpener = useMemo(() => getCreateModalOpener({ overlays, tagClient }), [ + overlays, + tagClient, + ]); + const editModalOpener = useMemo(() => getEditModalOpener({ overlays, tagClient }), [ + overlays, + tagClient, + ]); + + useEffect(() => { + setBreadcrumbs([ + { + text: i18n.translate('xpack.savedObjectsTagging.management.breadcrumb.index', { + defaultMessage: 'Tags', + }), + href: '/', + }, + ]); + }, [setBreadcrumbs]); + + const fetchTags = useCallback(async () => { + setLoading(true); + const { tags } = await tagClient.find({ + page: 1, + perPage: 10000, + }); + setAllTags(tags); + setLoading(false); + }, [tagClient]); + + useMount(() => { + fetchTags(); + }); + + const openCreateModal = useCallback(() => { + createModalOpener({ + onCreate: (createdTag) => { + fetchTags(); + notifications.toasts.addSuccess({ + title: i18n.translate('xpack.savedObjectsTagging.notifications.createTagSuccessTitle', { + defaultMessage: 'Created "{name}" tag', + values: { + name: createdTag.name, + }, + }), + }); + }, + }); + }, [notifications, createModalOpener, fetchTags]); + + const openEditModal = useCallback( + (tag: TagWithRelations) => { + editModalOpener({ + tagId: tag.id, + onUpdate: (updatedTag) => { + fetchTags(); + notifications.toasts.addSuccess({ + title: i18n.translate('xpack.savedObjectsTagging.notifications.editTagSuccessTitle', { + defaultMessage: 'Saved changes to "{name}" tag', + values: { + name: updatedTag.name, + }, + }), + }); + }, + }); + }, + [notifications, editModalOpener, fetchTags] + ); + + const getTagRelationUrl = useCallback( + (tag: TagWithRelations) => { + return getTagConnectionsUrl(tag, http.basePath); + }, + [http] + ); + + const showTagRelations = useCallback( + (tag: TagWithRelations) => { + application.navigateToUrl(getTagRelationUrl(tag)); + }, + [application, getTagRelationUrl] + ); + + const deleteTagWithConfirm = useCallback( + async (tag: TagWithRelations) => { + const confirmed = await overlays.openConfirm( + i18n.translate('xpack.savedObjectsTagging.modals.confirmDelete.text', { + defaultMessage: + 'By deleting this tag, you will no longer be able to assign it to saved objects. ' + + 'This tag will be removed from any saved objects that currently use it. ' + + 'Are you sure you wish to proceed?', + }), + { + title: i18n.translate('xpack.savedObjectsTagging.modals.confirmDelete.title', { + defaultMessage: 'Delete "{name}" tag', + values: { + name: tag.name, + }, + }), + confirmButtonText: i18n.translate( + 'xpack.savedObjectsTagging.modals.confirmDelete.confirmButtonText', + { + defaultMessage: 'Delete tag', + } + ), + buttonColor: 'danger', + } + ); + if (confirmed) { + await tagClient.delete(tag.id); + + fetchTags(); + + notifications.toasts.addSuccess({ + title: i18n.translate('xpack.savedObjectsTagging.notifications.deleteTagSuccessTitle', { + defaultMessage: 'Deleted "{name}" tag', + values: { + name: tag.name, + }, + }), + }); + } + }, + [overlays, notifications, fetchTags, tagClient] + ); + + return ( + +
+ { + setSelectedTags(tags); + }} + onEdit={(tag) => { + openEditModal(tag); + }} + onDelete={(tag) => { + deleteTagWithConfirm(tag); + }} + getTagRelationUrl={getTagRelationUrl} + onShowRelations={(tag) => { + showTagRelations(tag); + }} + /> + + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.test.ts b/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.test.ts new file mode 100644 index 0000000000000..812106b4e3bbf --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; +import { getTagConnectionsUrl } from './get_tag_connections_url'; +import { TagWithRelations } from '../../../common/types'; + +const createTag = (name: string): TagWithRelations => ({ + id: 'tag-id', + name, + description: '', + color: '#FF0088', + relationCount: 42, +}); + +const basePath = '/my-base-path'; + +describe('getTagConnectionsUrl', () => { + let httpMock: ReturnType; + + beforeEach(() => { + httpMock = httpServiceMock.createStartContract({ basePath }); + }); + + it('appends the basePath to the generated url', () => { + const tag = createTag('myTag'); + expect(getTagConnectionsUrl(tag, httpMock.basePath)).toMatchInlineSnapshot( + `"/my-base-path/app/management/kibana/objects?initialQuery=tag%3A(myTag)"` + ); + }); + + it('escapes the query', () => { + const tag = createTag('tag with spaces'); + expect(getTagConnectionsUrl(tag, httpMock.basePath)).toMatchInlineSnapshot( + `"/my-base-path/app/management/kibana/objects?initialQuery=tag%3A(tag%20with%20spaces)"` + ); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.ts b/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.ts new file mode 100644 index 0000000000000..808e0ddcf2d65 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IBasePath } from 'src/core/public'; +import { TagWithRelations } from '../../../common/types'; + +/** + * Returns the url to use to redirect to the SavedObject management section with given tag + * already selected in the query/filter bar. + */ +export const getTagConnectionsUrl = (tag: TagWithRelations, basePath: IBasePath) => { + const query = encodeURIComponent(`tag:(${tag.name})`); + return basePath.prepend(`/app/management/kibana/objects?initialQuery=${query}`); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/utils/index.ts b/x-pack/plugins/saved_objects_tagging/public/management/utils/index.ts new file mode 100644 index 0000000000000..bc9d0c7ab470a --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/utils/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getTagConnectionsUrl } from './get_tag_connections_url'; diff --git a/x-pack/plugins/saved_objects_tagging/public/mocks.ts b/x-pack/plugins/saved_objects_tagging/public/mocks.ts new file mode 100644 index 0000000000000..350e3e702f6a9 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/mocks.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { taggingApiMock } from '../../../../src/plugins/saved_objects_tagging_oss/public/mocks'; diff --git a/x-pack/plugins/saved_objects_tagging/public/plugin.test.ts b/x-pack/plugins/saved_objects_tagging/public/plugin.test.ts new file mode 100644 index 0000000000000..16dac75455710 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/plugin.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { coreMock } from '../../../../src/core/public/mocks'; +import { managementPluginMock } from '../../../../src/plugins/management/public/mocks'; +import { savedObjectTaggingOssPluginMock } from '../../../../src/plugins/saved_objects_tagging_oss/public/mocks'; +import { SavedObjectTaggingPlugin } from './plugin'; +import { SavedObjectsTaggingClientConfigRawType } from './config'; +import { TagsCache } from './tags'; +import { tagsCacheMock } from './tags/tags_cache.mock'; + +jest.mock('./tags/tags_cache'); +const MockedTagsCache = (TagsCache as unknown) as jest.Mock>; + +describe('SavedObjectTaggingPlugin', () => { + let plugin: SavedObjectTaggingPlugin; + let managementPluginSetup: ReturnType; + let savedObjectsTaggingOssPluginSetup: ReturnType; + + beforeEach(() => { + const rawConfig: SavedObjectsTaggingClientConfigRawType = { + cache_refresh_interval: moment.duration('15', 'minute').toString(), + }; + const initializerContext = coreMock.createPluginInitializerContext(rawConfig); + + plugin = new SavedObjectTaggingPlugin(initializerContext); + }); + + describe('#setup', () => { + beforeEach(() => { + managementPluginSetup = managementPluginMock.createSetupContract(); + savedObjectsTaggingOssPluginSetup = savedObjectTaggingOssPluginMock.createSetup(); + + plugin.setup(coreMock.createSetup(), { + management: managementPluginSetup, + savedObjectsTaggingOss: savedObjectsTaggingOssPluginSetup, + }); + }); + + it('register the `tags` app to the `kibana` management section', () => { + expect(managementPluginSetup.sections.section.kibana.registerApp).toHaveBeenCalledTimes(1); + expect(managementPluginSetup.sections.section.kibana.registerApp).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'tags', + title: 'Tags', + mount: expect.any(Function), + }) + ); + }); + it('register its API app to the `savedObjectsTaggingOss` plugin', () => { + expect(savedObjectsTaggingOssPluginSetup.registerTaggingApi).toHaveBeenCalledTimes(1); + expect(savedObjectsTaggingOssPluginSetup.registerTaggingApi).toHaveBeenCalledWith( + expect.any(Promise) + ); + }); + }); + + describe('#start', () => { + beforeEach(() => { + managementPluginSetup = managementPluginMock.createSetupContract(); + savedObjectsTaggingOssPluginSetup = savedObjectTaggingOssPluginMock.createSetup(); + MockedTagsCache.mockImplementation(() => tagsCacheMock.create()); + + plugin.setup(coreMock.createSetup(), { + management: managementPluginSetup, + savedObjectsTaggingOss: savedObjectsTaggingOssPluginSetup, + }); + }); + + it('creates its cache with correct parameters', () => { + plugin.start(coreMock.createStart()); + + expect(MockedTagsCache).toHaveBeenCalledTimes(1); + expect(MockedTagsCache).toHaveBeenCalledWith({ + refreshHandler: expect.any(Function), + refreshInterval: expect.any(Object), + }); + + const refreshIntervalParam = MockedTagsCache.mock.calls[0][0].refreshInterval; + + expect(moment.isDuration(refreshIntervalParam)).toBe(true); + expect(refreshIntervalParam.toString()).toBe('PT15M'); + }); + + it('initializes its cache if not on an anonymous page', async () => { + const coreStart = coreMock.createStart(); + coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(false); + + plugin.start(coreStart); + + expect(MockedTagsCache.mock.instances[0].initialize).not.toHaveBeenCalled(); + }); + + it('does not initialize its cache if on an anonymous page', async () => { + const coreStart = coreMock.createStart(); + coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(true); + + plugin.start(coreStart); + + expect(MockedTagsCache.mock.instances[0].initialize).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/plugin.ts b/x-pack/plugins/saved_objects_tagging/public/plugin.ts new file mode 100644 index 0000000000000..9a684637f2e92 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/plugin.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { CoreSetup, CoreStart, PluginInitializerContext, Plugin } from 'src/core/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; +import { SavedObjectTaggingOssPluginSetup } from '../../../../src/plugins/saved_objects_tagging_oss/public'; +import { tagManagementSectionId } from '../common/constants'; +import { getTagsCapabilities } from '../common/capabilities'; +import { SavedObjectTaggingPluginStart } from './types'; +import { TagsClient, TagsCache } from './tags'; +import { getUiApi } from './ui_api'; +import { SavedObjectsTaggingClientConfig, SavedObjectsTaggingClientConfigRawType } from './config'; + +interface SetupDeps { + management: ManagementSetup; + savedObjectsTaggingOss: SavedObjectTaggingOssPluginSetup; +} + +export class SavedObjectTaggingPlugin + implements Plugin<{}, SavedObjectTaggingPluginStart, SetupDeps, {}> { + private tagClient?: TagsClient; + private tagCache?: TagsCache; + private readonly config: SavedObjectsTaggingClientConfig; + + constructor(context: PluginInitializerContext) { + this.config = new SavedObjectsTaggingClientConfig( + context.config.get() + ); + } + + public setup( + core: CoreSetup<{}, SavedObjectTaggingPluginStart>, + { management, savedObjectsTaggingOss }: SetupDeps + ) { + const kibanaSection = management.sections.section.kibana; + kibanaSection.registerApp({ + id: tagManagementSectionId, + title: i18n.translate('xpack.savedObjectsTagging.management.sectionLabel', { + defaultMessage: 'Tags', + }), + order: 2, + mount: async (mountParams) => { + const { mountSection } = await import('./management'); + return mountSection({ + tagClient: this.tagClient!, + core, + mountParams, + }); + }, + }); + + savedObjectsTaggingOss.registerTaggingApi( + core.getStartServices().then(([_core, _deps, startContract]) => startContract) + ); + + return {}; + } + + public start({ http, application, overlays }: CoreStart) { + this.tagCache = new TagsCache({ + refreshHandler: () => this.tagClient!.getAll(), + refreshInterval: this.config.cacheRefreshInterval, + }); + this.tagClient = new TagsClient({ http, changeListener: this.tagCache }); + + // do not fetch tags on anonymous page + if (!http.anonymousPaths.isAnonymous(window.location.pathname)) { + // we don't need to wait for this to resolve. + this.tagCache.initialize().catch(() => { + // cache is resilient to initial load failure. We just need to catch to avoid unhandled promise rejection + }); + } + + return { + client: this.tagClient, + ui: getUiApi({ + cache: this.tagCache, + client: this.tagClient, + capabilities: getTagsCapabilities(application.capabilities), + overlays, + }), + }; + } + + public stop() { + if (this.tagCache) { + this.tagCache.stop(); + } + } +} diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/errors.ts b/x-pack/plugins/saved_objects_tagging/public/tags/errors.ts new file mode 100644 index 0000000000000..d353109c151ec --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/tags/errors.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TagValidation } from '../../common/validation'; + +/** + * Error returned from the server when attributes validation fails for `create` or `update` operations + */ +export interface TagServerValidationError { + statusCode: 400; + attributes: TagValidation; +} + +export const isServerValidationError = (error: any): error is TagServerValidationError => { + return ( + error && + error.statusCode === 400 && + typeof error.attributes?.valid === 'boolean' && + typeof error.attributes.errors === 'object' + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/index.ts b/x-pack/plugins/saved_objects_tagging/public/tags/index.ts new file mode 100644 index 0000000000000..9f2b2c5690efb --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/tags/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TagsClient, ITagInternalClient } from './tags_client'; +export { TagsCache, ITagsChangeListener, ITagsCache } from './tags_cache'; +export { isServerValidationError, TagServerValidationError } from './errors'; diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.mock.ts b/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.mock.ts new file mode 100644 index 0000000000000..731bfe05ffa64 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.mock.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { of } from 'rxjs'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { TagsCache } from './tags_cache'; + +type TagsCacheMock = jest.Mocked>; + +const createTagsCacheMock = () => { + const mock: TagsCacheMock = { + getState: jest.fn(), + getState$: jest.fn(), + initialize: jest.fn(), + stop: jest.fn(), + + onDelete: jest.fn(), + onCreate: jest.fn(), + onUpdate: jest.fn(), + onGetAll: jest.fn(), + }; + + mock.getState.mockReturnValue([]); + mock.getState$.mockReturnValue(of([])); + mock.initialize.mockResolvedValue(undefined); + + return mock; +}; + +export const tagsCacheMock = { + create: createTagsCacheMock, +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.test.ts b/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.test.ts new file mode 100644 index 0000000000000..9260e89f464b7 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import { Tag, TagAttributes } from '../../common/types'; +import { TagsCache, CacheRefreshHandler } from './tags_cache'; + +const createTag = (parts: Partial): Tag => ({ + id: 'tag-id', + name: 'some-tag', + description: 'Some tag', + color: '#FF00CC', + ...parts, +}); + +const createAttributes = (parts: Partial): TagAttributes => ({ + name: 'some-tag', + description: 'Some tag', + color: '#FF00CC', + ...parts, +}); + +const createTags = (ids: string[]): Tag[] => + ids.map((id) => + createTag({ + id, + name: `${id}-name`, + description: `${id}-desc`, + color: '#FF00CC', + }) + ); + +const refreshHandler: CacheRefreshHandler = () => createTags(['tag-1', 'tag-2', 'tag-3']); + +describe('TagsCache', () => { + let tagsCache: TagsCache; + + beforeEach(async () => { + tagsCache = new TagsCache({ + refreshHandler, + }); + await tagsCache.initialize(); + }); + + describe('#onDelete', () => { + it('removes the deleted tag from the cache', async () => { + tagsCache.onDelete('tag-1'); + + expect(tagsCache.getState().map((tag) => tag.id)).toEqual(['tag-2', 'tag-3']); + }); + + it('does nothing if the specified id is not in the cache', async () => { + tagsCache.onDelete('tag-4'); + + expect(tagsCache.getState().map((tag) => tag.id)).toEqual(['tag-1', 'tag-2', 'tag-3']); + }); + }); + + describe('#onCreate', () => { + it('adds the new tag to the cache', async () => { + const newTag = createTag({ id: 'new-tag' }); + tagsCache.onCreate(newTag); + + expect(tagsCache.getState().map((tag) => tag.id)).toEqual([ + 'tag-1', + 'tag-2', + 'tag-3', + 'new-tag', + ]); + }); + + it('replace the entry from the cache if already existing', async () => { + const newTag = createTag({ id: 'tag-2', name: 'new-tag' }); + tagsCache.onCreate(newTag); + + const cacheState = tagsCache.getState(); + expect(cacheState.map((tag) => tag.id)).toEqual(['tag-1', 'tag-3', 'tag-2']); + expect(cacheState[2]).toEqual(newTag); + }); + }); + + describe('#onUpdate', () => { + it('replace the entry from the cache', async () => { + const updatedAttributes = createAttributes({ name: 'updated-name' }); + tagsCache.onUpdate('tag-2', updatedAttributes); + + const cacheState = tagsCache.getState(); + expect(cacheState.map((tag) => tag.id)).toEqual(['tag-1', 'tag-2', 'tag-3']); + expect(cacheState[1]).toEqual({ + id: 'tag-2', + ...updatedAttributes, + }); + }); + }); + + describe('#onGetAll', () => { + it('refreshes the cache with the new list', () => { + const newTags = createTags(['tag-1', 'tag-4', 'tag-5']); + + tagsCache.onGetAll(newTags); + + expect(tagsCache.getState()).toEqual(newTags); + }); + }); + + describe('when `refreshInterval` is provided', () => { + const refreshInterval = moment.duration('15s'); + + let setIntervalSpy: jest.SpyInstance; + let clearIntervalSpy: jest.SpyInstance; + + beforeEach(async () => { + tagsCache = new TagsCache({ + refreshHandler, + refreshInterval, + }); + setIntervalSpy = jest.spyOn(window, 'setInterval'); + clearIntervalSpy = jest.spyOn(window, 'clearInterval'); + }); + + it('calls `setInterval` during `initialize` with correct parameters', async () => { + await tagsCache.initialize(); + + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledWith( + expect.any(Function), + refreshInterval.asMilliseconds() + ); + }); + + it('calls `clearInterval` during `stop` with correct parameters', async () => { + const intervalId = 42; + setIntervalSpy.mockReturnValue(intervalId); + + await tagsCache.initialize(); + tagsCache.stop(); + + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId); + }); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.ts b/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.ts new file mode 100644 index 0000000000000..b33961d51b48f --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Duration } from 'moment'; +import { Observable, BehaviorSubject, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { Tag, TagAttributes } from '../../common/types'; + +export interface ITagsCache { + getState(): Tag[]; + getState$(): Observable; +} + +export interface ITagsChangeListener { + onDelete: (id: string) => void; + onCreate: (tag: Tag) => void; + onUpdate: (id: string, attributes: TagAttributes) => void; + onGetAll: (tags: Tag[]) => void; +} + +export type CacheRefreshHandler = () => Tag[] | Promise; + +interface TagsCacheOptions { + refreshHandler: CacheRefreshHandler; + refreshInterval?: Duration; +} + +/** + * Reactive client-side cache of the existing tags, connected to the TagsClient. + * + * Used (mostly) by the UI components to avoid performing http calls every time a component + * needs to retrieve the list of all the existing tags or the tags associated with an object. + */ +export class TagsCache implements ITagsCache, ITagsChangeListener { + private readonly refreshInterval?: Duration; + private readonly refreshHandler: CacheRefreshHandler; + + private intervalId?: number; + private readonly internal$: BehaviorSubject; + private readonly public$: Observable; + private readonly stop$: Subject; + + constructor({ refreshHandler, refreshInterval }: TagsCacheOptions) { + this.refreshHandler = refreshHandler; + this.refreshInterval = refreshInterval; + + this.stop$ = new Subject(); + this.internal$ = new BehaviorSubject([]); + this.public$ = this.internal$.pipe(takeUntil(this.stop$)); + } + + public async initialize() { + await this.refresh(); + + if (this.refreshInterval) { + this.intervalId = window.setInterval(() => { + this.refresh(); + }, this.refreshInterval.asMilliseconds()); + } + } + + private async refresh() { + try { + const tags = await this.refreshHandler(); + this.internal$.next(tags); + } catch (e) { + // what should we do here? + } + } + + public getState() { + return this.internal$.getValue(); + } + + public getState$() { + return this.public$; + } + + public onDelete(id: string) { + this.internal$.next(this.internal$.value.filter((tag) => tag.id !== id)); + } + + public onCreate(tag: Tag) { + this.internal$.next([...this.internal$.value.filter((f) => f.id !== tag.id), tag]); + } + + public onUpdate(id: string, attributes: TagAttributes) { + this.internal$.next( + this.internal$.value.map((tag) => { + if (tag.id === id) { + return { + ...tag, + ...attributes, + }; + } + return tag; + }) + ); + } + + public onGetAll(tags: Tag[]) { + this.internal$.next(tags); + } + + public stop() { + if (this.intervalId) { + window.clearInterval(this.intervalId); + } + this.stop$.next(); + } +} diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.test.ts b/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.test.ts new file mode 100644 index 0000000000000..ac73880e52949 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.test.ts @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from '../../../../../src/core/public/mocks'; +import { Tag } from '../../common/types'; +import { createTag, createTagAttributes } from '../../common/test_utils'; +import { tagsCacheMock } from './tags_cache.mock'; +import { TagsClient, FindTagsOptions } from './tags_client'; + +describe('TagsClient', () => { + let tagsClient: TagsClient; + let changeListener: ReturnType; + let http: ReturnType; + + beforeEach(() => { + http = httpServiceMock.createSetupContract(); + changeListener = tagsCacheMock.create(); + tagsClient = new TagsClient({ + http, + changeListener, + }); + }); + + describe('#create', () => { + let expectedTag: Tag; + + beforeEach(() => { + expectedTag = createTag(); + http.post.mockResolvedValue({ tag: expectedTag }); + }); + + it('calls `http.post` with the correct parameters', async () => { + const attributes = createTagAttributes(); + + await tagsClient.create(attributes); + + expect(http.post).toHaveBeenCalledTimes(1); + expect(http.post).toHaveBeenCalledWith('/api/saved_objects_tagging/tags/create', { + body: JSON.stringify(attributes), + }); + }); + it('returns the tag object from the response', async () => { + const tag = await tagsClient.create(createTagAttributes()); + expect(tag).toEqual(expectedTag); + }); + it('forwards the error from the http call if any', async () => { + const error = new Error('something when wrong'); + http.post.mockRejectedValue(error); + + await expect(tagsClient.create(createTagAttributes())).rejects.toThrowError(error); + }); + it('notifies its changeListener if the http call succeed', async () => { + await tagsClient.create(createTagAttributes()); + + expect(changeListener.onCreate).toHaveBeenCalledTimes(1); + expect(changeListener.onCreate).toHaveBeenCalledWith(expectedTag); + }); + it('ignores potential errors when calling `changeListener.onCreate`', async () => { + changeListener.onCreate.mockImplementation(() => { + throw new Error('error in onCreate'); + }); + + await expect(tagsClient.create(createTagAttributes())).resolves.toBeDefined(); + }); + }); + + describe('#update', () => { + const tagId = 'test-id'; + let expectedTag: Tag; + + beforeEach(() => { + expectedTag = createTag({ id: tagId }); + http.post.mockResolvedValue({ tag: expectedTag }); + }); + + it('calls `http.post` with the correct parameters', async () => { + const attributes = createTagAttributes(); + + await tagsClient.update(tagId, attributes); + + expect(http.post).toHaveBeenCalledTimes(1); + expect(http.post).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags/${tagId}`, { + body: JSON.stringify(attributes), + }); + }); + it('returns the tag object from the response', async () => { + const tag = await tagsClient.update(tagId, createTagAttributes()); + expect(tag).toEqual(expectedTag); + }); + it('forwards the error from the http call if any', async () => { + const error = new Error('something when wrong'); + http.post.mockRejectedValue(error); + + await expect(tagsClient.update(tagId, createTagAttributes())).rejects.toThrowError(error); + }); + it('notifies its changeListener if the http call succeed', async () => { + await tagsClient.update(tagId, createTagAttributes()); + + const { id, ...attributes } = expectedTag; + expect(changeListener.onUpdate).toHaveBeenCalledTimes(1); + expect(changeListener.onUpdate).toHaveBeenCalledWith(id, attributes); + }); + it('ignores potential errors when calling `changeListener.onUpdate`', async () => { + changeListener.onUpdate.mockImplementation(() => { + throw new Error('error in onUpdate'); + }); + + await expect(tagsClient.update(tagId, createTagAttributes())).resolves.toBeDefined(); + }); + }); + + describe('#get', () => { + const tagId = 'test-id'; + let expectedTag: Tag; + + beforeEach(() => { + expectedTag = createTag({ id: tagId }); + http.get.mockResolvedValue({ tag: expectedTag }); + }); + + it('calls `http.get` with the correct parameters', async () => { + await tagsClient.get(tagId); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags/${tagId}`); + }); + it('returns the tag object from the response', async () => { + const tag = await tagsClient.get(tagId); + expect(tag).toEqual(expectedTag); + }); + it('forwards the error from the http call if any', async () => { + const error = new Error('something when wrong'); + http.get.mockRejectedValue(error); + + await expect(tagsClient.get(tagId)).rejects.toThrowError(error); + }); + }); + + describe('#getAll', () => { + let expectedTags: Tag[]; + + beforeEach(() => { + expectedTags = [ + createTag({ id: 'tag-1' }), + createTag({ id: 'tag-2' }), + createTag({ id: 'tag-3' }), + ]; + http.get.mockResolvedValue({ tags: expectedTags }); + }); + + it('calls `http.get` with the correct parameters', async () => { + await tagsClient.getAll(); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags`); + }); + it('returns the tag objects from the response', async () => { + const tags = await tagsClient.getAll(); + expect(tags).toEqual(expectedTags); + }); + it('forwards the error from the http call if any', async () => { + const error = new Error('something when wrong'); + http.get.mockRejectedValue(error); + + await expect(tagsClient.getAll()).rejects.toThrowError(error); + }); + it('notifies its changeListener if the http call succeed', async () => { + await tagsClient.getAll(); + + expect(changeListener.onGetAll).toHaveBeenCalledTimes(1); + expect(changeListener.onGetAll).toHaveBeenCalledWith(expectedTags); + }); + it('ignores potential errors when calling `changeListener.onDelete`', async () => { + changeListener.onGetAll.mockImplementation(() => { + throw new Error('error in onCreate'); + }); + + await expect(tagsClient.getAll()).resolves.toBeDefined(); + }); + }); + + describe('#delete', () => { + const tagId = 'id-to-delete'; + + beforeEach(() => { + http.delete.mockResolvedValue({}); + }); + + it('calls `http.delete` with the correct parameters', async () => { + await tagsClient.delete(tagId); + + expect(http.delete).toHaveBeenCalledTimes(1); + expect(http.delete).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags/${tagId}`); + }); + it('forwards the error from the http call if any', async () => { + const error = new Error('something when wrong'); + http.delete.mockRejectedValue(error); + + await expect(tagsClient.delete(tagId)).rejects.toThrowError(error); + }); + it('notifies its changeListener if the http call succeed', async () => { + await tagsClient.delete(tagId); + + expect(changeListener.onDelete).toHaveBeenCalledTimes(1); + expect(changeListener.onDelete).toHaveBeenCalledWith(tagId); + }); + it('ignores potential errors when calling `changeListener.onDelete`', async () => { + changeListener.onDelete.mockImplementation(() => { + throw new Error('error in onCreate'); + }); + + await expect(tagsClient.delete(tagId)).resolves.toBeUndefined(); + }); + }); + + ///// + + describe('#find', () => { + const findOptions: FindTagsOptions = { + search: 'for, you know.', + }; + let expectedTags: Tag[]; + + beforeEach(() => { + expectedTags = [ + createTag({ id: 'tag-1' }), + createTag({ id: 'tag-2' }), + createTag({ id: 'tag-3' }), + ]; + http.get.mockResolvedValue({ tags: expectedTags, total: expectedTags.length }); + }); + + it('calls `http.get` with the correct parameters', async () => { + await tagsClient.find(findOptions); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith(`/internal/saved_objects_tagging/tags/_find`, { + query: findOptions, + }); + }); + it('returns the tag objects from the response', async () => { + const { tags, total } = await tagsClient.find(findOptions); + expect(tags).toEqual(expectedTags); + expect(total).toEqual(3); + }); + it('forwards the error from the http call if any', async () => { + const error = new Error('something when wrong'); + http.get.mockRejectedValue(error); + + await expect(tagsClient.find(findOptions)).rejects.toThrowError(error); + }); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.ts b/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.ts new file mode 100644 index 0000000000000..3169babb2bae8 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpSetup } from 'src/core/public'; +import { Tag, TagAttributes, ITagsClient, TagWithRelations } from '../../common/types'; +import { ITagsChangeListener } from './tags_cache'; + +export interface TagsClientOptions { + http: HttpSetup; + changeListener?: ITagsChangeListener; +} + +export interface FindTagsOptions { + page?: number; + perPage?: number; + search?: string; +} + +export interface FindTagsResponse { + tags: TagWithRelations[]; + total: number; +} + +const trapErrors = (fn: () => void) => { + try { + fn(); + } catch (e) { + // trap + } +}; + +export interface ITagInternalClient extends ITagsClient { + find(options: FindTagsOptions): Promise; +} + +export class TagsClient implements ITagInternalClient { + private readonly http: HttpSetup; + private readonly changeListener?: ITagsChangeListener; + + constructor({ http, changeListener }: TagsClientOptions) { + this.http = http; + this.changeListener = changeListener; + } + + // public APIs from ITagsClient + + public async create(attributes: TagAttributes) { + const { tag } = await this.http.post<{ tag: Tag }>('/api/saved_objects_tagging/tags/create', { + body: JSON.stringify(attributes), + }); + + trapErrors(() => { + if (this.changeListener) { + this.changeListener.onCreate(tag); + } + }); + + return tag; + } + + public async update(id: string, attributes: TagAttributes) { + const { tag } = await this.http.post<{ tag: Tag }>(`/api/saved_objects_tagging/tags/${id}`, { + body: JSON.stringify(attributes), + }); + + trapErrors(() => { + if (this.changeListener) { + const { id: newId, ...newAttributes } = tag; + this.changeListener.onUpdate(newId, newAttributes); + } + }); + + return tag; + } + + public async get(id: string) { + const { tag } = await this.http.get<{ tag: Tag }>(`/api/saved_objects_tagging/tags/${id}`); + return tag; + } + + public async getAll() { + const { tags } = await this.http.get<{ tags: Tag[] }>('/api/saved_objects_tagging/tags'); + + trapErrors(() => { + if (this.changeListener) { + this.changeListener.onGetAll(tags); + } + }); + + return tags; + } + + public async delete(id: string) { + await this.http.delete<{}>(`/api/saved_objects_tagging/tags/${id}`); + + trapErrors(() => { + if (this.changeListener) { + this.changeListener.onDelete(id); + } + }); + } + + // internal APIs from ITagInternalClient + + public async find({ page, perPage, search }: FindTagsOptions) { + return await this.http.get('/internal/saved_objects_tagging/tags/_find', { + query: { + page, + perPage, + search, + }, + }); + } +} diff --git a/x-pack/plugins/saved_objects_tagging/public/types.ts b/x-pack/plugins/saved_objects_tagging/public/types.ts new file mode 100644 index 0000000000000..b30e78d059e56 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { SavedObjectsTaggingApi } from '../../../../src/plugins/saved_objects_tagging_oss/public'; + +export type SavedObjectTaggingPluginStart = SavedObjectsTaggingApi; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/components.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/components.ts new file mode 100644 index 0000000000000..5b73ff906ecdd --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/components.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { OverlayStart } from 'src/core/public'; +import { SavedObjectsTaggingApiUiComponent } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; +import { TagsCapabilities } from '../../common'; +import { ITagInternalClient, ITagsCache } from '../tags'; +import { + getConnectedTagListComponent, + getConnectedTagSelectorComponent, + getConnectedSavedObjectModalTagSelectorComponent, +} from '../components/connected'; +import { getCreateModalOpener } from '../components/edition_modal'; + +export interface GetComponentsOptions { + capabilities: TagsCapabilities; + cache: ITagsCache; + overlays: OverlayStart; + tagClient: ITagInternalClient; +} + +export const getComponents = ({ + capabilities, + cache, + overlays, + tagClient, +}: GetComponentsOptions): SavedObjectsTaggingApiUiComponent => { + const openCreateModal = getCreateModalOpener({ overlays, tagClient }); + return { + TagList: getConnectedTagListComponent({ cache }), + TagSelector: getConnectedTagSelectorComponent({ cache, capabilities, openCreateModal }), + SavedObjectSaveModalTagSelector: getConnectedSavedObjectModalTagSelectorComponent({ + cache, + capabilities, + openCreateModal, + }), + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/convert_name_to_reference.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/convert_name_to_reference.ts new file mode 100644 index 0000000000000..df207791aa197 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/convert_name_to_reference.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; +import { ITagsCache } from '../tags'; +import { convertTagNameToId } from '../utils'; + +export interface BuildConvertNameToReferenceOptions { + cache: ITagsCache; +} + +export const buildConvertNameToReference = ({ + cache, +}: BuildConvertNameToReferenceOptions): SavedObjectsTaggingApiUi['convertNameToReference'] => { + return (tagName: string) => { + const tagId = convertTagNameToId(tagName, cache.getState()); + return tagId ? { type: 'tag', id: tagId } : undefined; + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts new file mode 100644 index 0000000000000..f4a2413dab6e9 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; +import { tagsCacheMock } from '../tags/tags_cache.mock'; +import { Tag } from '../../common/types'; +import { createTag } from '../../common/test_utils'; +import { buildGetSearchBarFilter } from './get_search_bar_filter'; + +const expectTagOption = (tag: Tag, useName: boolean) => ({ + value: useName ? tag.name : tag.id, + name: tag.name, + view: expect.anything(), +}); + +describe('getSearchBarFilter', () => { + let cache: ReturnType; + let getSearchBarFilter: SavedObjectsTaggingApiUi['getSearchBarFilter']; + + beforeEach(() => { + cache = tagsCacheMock.create(); + getSearchBarFilter = buildGetSearchBarFilter({ cache }); + }); + + it('has the correct base configuration', () => { + expect(getSearchBarFilter()).toEqual({ + type: 'field_value_selection', + field: 'tag', + name: expect.any(String), + multiSelect: 'or', + options: expect.any(Function), + }); + }); + + it('uses the specified field', () => { + expect(getSearchBarFilter({ tagField: 'foo' })).toEqual( + expect.objectContaining({ + field: 'foo', + }) + ); + }); + + it('resolves the options', async () => { + const tags = [ + createTag({ id: 'id-1', name: 'name-1' }), + createTag({ id: 'id-2', name: 'name-2' }), + createTag({ id: 'id-3', name: 'name-3' }), + ]; + cache.getState.mockReturnValue(tags); + + // EUI types for filters are incomplete + const { options } = getSearchBarFilter() as any; + + const fetched = await options(); + expect(fetched).toEqual(tags.map((tag) => expectTagOption(tag, true))); + }); + + it('sorts the tags by name', async () => { + const tag1 = createTag({ id: 'id-1', name: 'aaa' }); + const tag2 = createTag({ id: 'id-2', name: 'ccc' }); + const tag3 = createTag({ id: 'id-3', name: 'bbb' }); + + cache.getState.mockReturnValue([tag1, tag2, tag3]); + + // EUI types for filters are incomplete + const { options } = getSearchBarFilter() as any; + + const fetched = await options(); + expect(fetched).toEqual([tag1, tag3, tag2].map((tag) => expectTagOption(tag, true))); + }); + + it('uses the `useName` option', async () => { + const tags = [ + createTag({ id: 'id-1', name: 'name-1' }), + createTag({ id: 'id-2', name: 'name-2' }), + createTag({ id: 'id-3', name: 'name-3' }), + ]; + cache.getState.mockReturnValue(tags); + + // EUI types for filters are incomplete + const { options } = getSearchBarFilter({ useName: false }) as any; + + const fetched = await options(); + expect(fetched).toEqual(tags.map((tag) => expectTagOption(tag, false))); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx new file mode 100644 index 0000000000000..539759a0f1320 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { + SavedObjectsTaggingApiUi, + GetSearchBarFilterOptions, +} from '../../../../../src/plugins/saved_objects_tagging_oss/public'; +import { ITagsCache } from '../tags'; +import { TagSearchBarOption } from '../components'; +import { byNameTagSorter } from '../utils'; + +export interface BuildGetSearchBarFilterOptions { + cache: ITagsCache; +} + +export const buildGetSearchBarFilter = ({ + cache, +}: BuildGetSearchBarFilterOptions): SavedObjectsTaggingApiUi['getSearchBarFilter'] => { + return ({ useName = true, tagField = 'tag' }: GetSearchBarFilterOptions = {}) => { + return { + type: 'field_value_selection', + field: tagField, + name: i18n.translate('xpack.savedObjectsTagging.uiApi.searchBar.filterButtonLabel', { + defaultMessage: 'Tags', + }), + multiSelect: 'or', + options: () => { + // we are using the promise version of `options` because the handler is called + // everytime the filter is opened. That way we can keep in sync in case of tags + // that would be added without the searchbar having trigger a re-render. + return Promise.resolve( + cache + .getState() + .sort(byNameTagSorter) + .map((tag) => { + return { + value: useName ? tag.name : tag.id, + name: tag.name, + view: , + }; + }) + ); + }, + }; + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.test.ts new file mode 100644 index 0000000000000..f1c26aca26c2f --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; +import { taggingApiMock } from '../../../../../src/plugins/saved_objects_tagging_oss/public/mocks'; +import { tagsCacheMock } from '../tags/tags_cache.mock'; +import { createTagReference, createSavedObject, createTag } from '../../common/test_utils'; +import { buildGetTableColumnDefinition } from './get_table_column_definition'; + +describe('getTableColumnDefinition', () => { + let cache: ReturnType; + let components: ReturnType; + let getTableColumnDefinition: SavedObjectsTaggingApiUi['getTableColumnDefinition']; + + beforeEach(() => { + cache = tagsCacheMock.create(); + components = taggingApiMock.createComponents(); + + getTableColumnDefinition = buildGetTableColumnDefinition({ + cache, + components, + }); + }); + + it('returns a valid definition for a EUI field data column', () => { + const tableDef = getTableColumnDefinition(); + + expect(tableDef).toEqual( + expect.objectContaining({ + field: 'references', + name: expect.any(String), + description: expect.any(String), + sortable: expect.any(Function), + render: expect.any(Function), + }) + ); + }); + + it('returns the correct sorting value', () => { + const allTags = [ + createTag({ id: 'tag-1', name: 'Tag 1' }), + createTag({ id: 'tag-2', name: 'Tag 2' }), + createTag({ id: 'tag-3', name: 'Tag 3' }), + ]; + cache.getState.mockReturnValue(allTags); + + const tagReferences = [createTagReference('tag-3'), createTagReference('tag-1')]; + + const savedObject = createSavedObject({ + references: tagReferences, + }); + + const { sortable } = getTableColumnDefinition(); + + // we know this returns a function even if the generic column signature allows other types + expect((sortable as Function)(savedObject)).toEqual('Tag 1'); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.tsx b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.tsx new file mode 100644 index 0000000000000..e50c163a4814f --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { SavedObject, SavedObjectReference } from 'src/core/public'; +import { + SavedObjectsTaggingApiUi, + SavedObjectsTaggingApiUiComponent, +} from '../../../../../src/plugins/saved_objects_tagging_oss/public'; +import { ITagsCache } from '../tags'; +import { getTagsFromReferences, byNameTagSorter } from '../utils'; + +export interface GetTableColumnDefinitionOptions { + components: SavedObjectsTaggingApiUiComponent; + cache: ITagsCache; +} + +export const buildGetTableColumnDefinition = ({ + components, + cache, +}: GetTableColumnDefinitionOptions): SavedObjectsTaggingApiUi['getTableColumnDefinition'] => { + return () => { + return { + field: 'references', + name: i18n.translate('xpack.savedObjectsTagging.uiApi.table.columnTagsName', { + defaultMessage: 'Tags', + }), + description: i18n.translate('xpack.savedObjectsTagging.uiApi.table.columnTagsDescription', { + defaultMessage: 'Tags associated with this saved object', + }), + sortable: (object: SavedObject) => { + const { tags } = getTagsFromReferences(object.references, cache.getState()); + tags.sort(byNameTagSorter); + return tags.length ? tags[0].name : undefined; + }, + render: (references: SavedObjectReference[], object: SavedObject) => { + return ; + }, + 'data-test-subj': 'listingTableRowTags', + }; + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/has_tag_decoration.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/has_tag_decoration.ts new file mode 100644 index 0000000000000..2245fe752e9a0 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/has_tag_decoration.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + SavedObjectsTaggingApiUi, + TagDecoratedSavedObject, +} from '../../../../../src/plugins/saved_objects_tagging_oss/public'; + +export const hasTagDecoration: SavedObjectsTaggingApiUi['hasTagDecoration'] = ( + object +): object is TagDecoratedSavedObject => { + return 'getTags' in object && 'setTags' in object; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts new file mode 100644 index 0000000000000..52ce8812454d9 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { OverlayStart } from 'src/core/public'; +import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; +import { TagsCapabilities } from '../../common'; +import { ITagsCache, ITagInternalClient } from '../tags'; +import { getTagIdsFromReferences, updateTagsReferences } from '../utils'; +import { getComponents } from './components'; +import { buildGetTableColumnDefinition } from './get_table_column_definition'; +import { buildGetSearchBarFilter } from './get_search_bar_filter'; +import { buildParseSearchQuery } from './parse_search_query'; +import { buildConvertNameToReference } from './convert_name_to_reference'; +import { hasTagDecoration } from './has_tag_decoration'; + +interface GetUiApiOptions { + overlays: OverlayStart; + capabilities: TagsCapabilities; + cache: ITagsCache; + client: ITagInternalClient; +} + +export const getUiApi = ({ + cache, + capabilities, + client, + overlays, +}: GetUiApiOptions): SavedObjectsTaggingApiUi => { + const components = getComponents({ cache, capabilities, overlays, tagClient: client }); + + return { + components, + getTableColumnDefinition: buildGetTableColumnDefinition({ components, cache }), + getSearchBarFilter: buildGetSearchBarFilter({ cache }), + parseSearchQuery: buildParseSearchQuery({ cache }), + convertNameToReference: buildConvertNameToReference({ cache }), + hasTagDecoration, + getTagIdsFromReferences, + updateTagsReferences, + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts new file mode 100644 index 0000000000000..726e43e02e3b8 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; +import { tagsCacheMock } from '../tags/tags_cache.mock'; +import { createTag } from '../../common/test_utils'; +import { buildParseSearchQuery } from './parse_search_query'; + +const tagRef = (id: string) => ({ + id, + type: 'tag', +}); + +const tags = [ + createTag({ id: 'id-1', name: 'name-1' }), + createTag({ id: 'id-2', name: 'name-2' }), + createTag({ id: 'id-3', name: 'name-3' }), +]; + +describe('parseSearchQuery', () => { + let cache: ReturnType; + let parseSearchQuery: SavedObjectsTaggingApiUi['parseSearchQuery']; + + beforeEach(() => { + cache = tagsCacheMock.create(); + cache.getState.mockReturnValue(tags); + + parseSearchQuery = buildParseSearchQuery({ cache }); + }); + + it('returns the search term when there is no field clause', () => { + const searchTerm = 'my search term'; + + expect(parseSearchQuery(searchTerm)).toEqual({ + searchTerm, + tagReferences: undefined, + }); + }); + + it('returns the tag references matching the tag field clause when using `useName: false`', () => { + const searchTerm = 'tag:(id-1 OR id-2) my search term'; + + expect(parseSearchQuery(searchTerm, { useName: false })).toEqual({ + searchTerm: 'my search term', + tagReferences: [tagRef('id-1'), tagRef('id-2')], + }); + }); + + it('returns the tag references matching the tag field clause when using `useName: true`', () => { + const searchTerm = 'tag:(name-1 OR name-2) my search term'; + + expect(parseSearchQuery(searchTerm, { useName: true })).toEqual({ + searchTerm: 'my search term', + tagReferences: [tagRef('id-1'), tagRef('id-2')], + }); + }); + + it('uses the `tagField` option', () => { + const searchTerm = 'custom:(name-1 OR name-2) my search term'; + + expect(parseSearchQuery(searchTerm, { tagField: 'custom' })).toEqual({ + searchTerm: 'my search term', + tagReferences: [tagRef('id-1'), tagRef('id-2')], + }); + }); + + it('ignores names not in the cache', () => { + const searchTerm = 'tag:(name-1 OR missing-name) my search term'; + + expect(parseSearchQuery(searchTerm, { useName: true })).toEqual({ + searchTerm: 'my search term', + tagReferences: [tagRef('id-1')], + }); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts new file mode 100644 index 0000000000000..138b2a60ad15d --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Query } from '@elastic/eui'; +import { SavedObjectsFindOptionsReference } from 'src/core/public'; +import { + ParseSearchQueryOptions, + SavedObjectsTaggingApiUi, +} from '../../../../../src/plugins/saved_objects_tagging_oss/public'; +import { ITagsCache } from '../tags'; + +export interface BuildParseSearchQueryOptions { + cache: ITagsCache; +} + +export const buildParseSearchQuery = ({ + cache, +}: BuildParseSearchQueryOptions): SavedObjectsTaggingApiUi['parseSearchQuery'] => { + return (query: string, { tagField = 'tag', useName = true }: ParseSearchQueryOptions = {}) => { + const parsed = Query.parse(query); + // from other usages of `Query.parse` in the codebase, it seems that + // for empty term, the parsed query can be undefined, even if the type def state otherwise. + if (!query) { + return { + searchTerm: '', + tagReferences: [], + }; + } + + let searchTerm: string = ''; + let tagReferences: SavedObjectsFindOptionsReference[] = []; + + if (parsed.ast.getTermClauses().length) { + searchTerm = parsed.ast + .getTermClauses() + .map((clause: any) => clause.value) + .join(' '); + } + if (parsed.ast.getFieldClauses(tagField)) { + const selectedTags = parsed.ast.getFieldClauses(tagField)[0].value as string[]; + if (useName) { + selectedTags.forEach((tagName) => { + const found = cache.getState().find((tag) => tag.name === tagName); + if (found) { + tagReferences.push({ + type: 'tag', + id: found.id, + }); + } + }); + } else { + tagReferences = selectedTags.map((tagId) => ({ type: 'tag', id: tagId })); + } + } + + return { + searchTerm, + tagReferences: tagReferences.length ? tagReferences : undefined, + }; + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/utils.test.ts b/x-pack/plugins/saved_objects_tagging/public/utils.test.ts new file mode 100644 index 0000000000000..601a30ce9c892 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/utils.test.ts @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObject, SavedObjectReference } from 'src/core/types'; +import { + getObjectTags, + convertTagNameToId, + byNameTagSorter, + updateTagsReferences, + getTagIdsFromReferences, + tagIdToReference, +} from './utils'; + +const createTag = (id: string, name: string = id) => ({ + id, + name, + description: `desc ${id}`, + color: '#FFCC00', +}); + +const ref = (type: string, id: string): SavedObjectReference => ({ + id, + type, + name: `${type}-ref-${id}`, +}); + +const tagRef = (id: string) => ref('tag', id); + +const createObject = (refs: SavedObjectReference[]): SavedObject => { + return { + type: 'unkown', + id: 'irrelevant', + references: refs, + } as SavedObject; +}; + +const tag1 = createTag('id-1', 'name-1'); +const tag2 = createTag('id-2', 'name-2'); +const tag3 = createTag('id-3', 'name-3'); + +const allTags = [tag1, tag2, tag3]; + +describe('getObjectTags', () => { + it('returns the tags for the tag references of the object', () => { + const { tags } = getObjectTags( + createObject([tagRef('id-1'), ref('dashboard', 'dash-1'), tagRef('id-3')]), + allTags + ); + + expect(tags).toEqual([tag1, tag3]); + }); + + it('returns the missing references for tags that were not found', () => { + const missingRef = tagRef('missing-tag'); + const refs = [tagRef('id-1'), ref('dashboard', 'dash-1'), missingRef]; + const { tags, missingRefs } = getObjectTags(createObject(refs), allTags); + + expect(tags).toEqual([tag1]); + expect(missingRefs).toEqual([missingRef]); + }); +}); + +describe('convertTagNameToId', () => { + it('returns the id for the given tag name', () => { + expect(convertTagNameToId('name-2', allTags)).toBe('id-2'); + }); + + it('returns undefined if no tag was found', () => { + expect(convertTagNameToId('name-4', allTags)).toBeUndefined(); + }); +}); + +describe('byNameTagSorter', () => { + it('sorts tags by name', () => { + const tags = [ + createTag('id-1', 'tag-b'), + createTag('id-2', 'tag-a'), + createTag('id-3', 'tag-d'), + createTag('id-4', 'tag-c'), + ]; + + tags.sort(byNameTagSorter); + + expect(tags.map(({ id }) => id)).toEqual(['id-2', 'id-1', 'id-4', 'id-3']); + }); +}); + +describe('tagIdToReference', () => { + it('returns a reference for given tag id', () => { + expect(tagIdToReference('some-tag-id')).toEqual({ + id: 'some-tag-id', + type: 'tag', + name: 'tag-ref-some-tag-id', + }); + }); +}); + +describe('getTagIdsFromReferences', () => { + it('returns the tag ids from the given references', () => { + expect( + getTagIdsFromReferences([ + tagRef('tag-1'), + ref('dashboard', 'dash-1'), + tagRef('tag-2'), + ref('lens', 'lens-1'), + ]) + ).toEqual(['tag-1', 'tag-2']); + }); +}); + +describe('updateTagsReferences', () => { + it('updates the tag references', () => { + expect( + updateTagsReferences([tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3')], ['tag-2', 'tag-4']) + ).toEqual([tagRef('tag-2'), tagRef('tag-4')]); + }); + it('leaves the non-tag references unchanged', () => { + expect( + updateTagsReferences( + [ref('dashboard', 'dash-1'), tagRef('tag-1'), ref('lens', 'lens-1'), tagRef('tag-2')], + ['tag-2', 'tag-4'] + ) + ).toEqual([ + ref('dashboard', 'dash-1'), + ref('lens', 'lens-1'), + tagRef('tag-2'), + tagRef('tag-4'), + ]); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/utils.ts b/x-pack/plugins/saved_objects_tagging/public/utils.ts new file mode 100644 index 0000000000000..c74011dc605b6 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/utils.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObject, SavedObjectReference } from 'src/core/types'; +import { SavedObjectsFindOptionsReference } from 'src/core/public'; +import { Tag, tagSavedObjectTypeName } from '../common'; + +type SavedObjectReferenceLike = SavedObjectReference | SavedObjectsFindOptionsReference; + +export const getObjectTags = (object: SavedObject, allTags: Tag[]) => { + return getTagsFromReferences(object.references, allTags); +}; + +export const getTagsFromReferences = (references: SavedObjectReference[], allTags: Tag[]) => { + const tagReferences = references.filter((ref) => ref.type === tagSavedObjectTypeName); + + const foundTags: Tag[] = []; + const missingRefs: SavedObjectReference[] = []; + + tagReferences.forEach((ref) => { + const found = allTags.find((tag) => tag.id === ref.id); + if (found) { + foundTags.push(found); + } else { + missingRefs.push(ref); + } + }); + + return { + tags: foundTags, + missingRefs, + }; +}; + +export const convertTagNameToId = (tagName: string, allTags: Tag[]): string | undefined => { + const found = allTags.find((tag) => tag.name === tagName); + return found?.id; +}; + +export const byNameTagSorter = (tagA: Tag, tagB: Tag): number => { + return tagA.name.localeCompare(tagB.name); +}; + +export const testSubjFriendly = (name: string) => { + return name.replace(' ', '_'); +}; + +export const getTagIdsFromReferences = (references: SavedObjectReferenceLike[]): string[] => { + return references.filter((ref) => ref.type === tagSavedObjectTypeName).map(({ id }) => id); +}; + +export const tagIdToReference = (tagId: string): SavedObjectReference => ({ + type: tagSavedObjectTypeName, + id: tagId, + name: `tag-ref-${tagId}`, +}); + +export const updateTagsReferences = ( + references: SavedObjectReference[], + newTagIds: string[] +): SavedObjectReference[] => { + return [ + ...references.filter(({ type }) => type !== tagSavedObjectTypeName), + ...newTagIds.map(tagIdToReference), + ]; +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/config.ts b/x-pack/plugins/saved_objects_tagging/server/config.ts new file mode 100644 index 0000000000000..88c8eae384cfd --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/config.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'kibana/server'; + +const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), + cache_refresh_interval: schema.duration({ defaultValue: '15m' }), +}); + +export type SavedObjectsTaggingConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + cache_refresh_interval: true, + }, +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/features.ts b/x-pack/plugins/saved_objects_tagging/server/features.ts new file mode 100644 index 0000000000000..cb6ea335a17ba --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/features.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; +import { KibanaFeatureConfig } from '../../features/server'; +import { tagSavedObjectTypeName, tagManagementSectionId, tagFeatureId } from '../common/constants'; + +export const savedObjectsTaggingFeature: KibanaFeatureConfig = { + id: tagFeatureId, + name: i18n.translate('xpack.savedObjectsTagging.feature.featureName', { + defaultMessage: 'Tag Management', + }), + category: DEFAULT_APP_CATEGORIES.management, + order: 1800, + app: [], + management: { + kibana: [tagManagementSectionId], + }, + privileges: { + all: { + savedObject: { + all: [tagSavedObjectTypeName], + read: [], + }, + api: [], + management: { + kibana: [tagManagementSectionId], + }, + ui: ['view', 'create', 'edit', 'delete', 'assign'], + }, + read: { + savedObject: { + all: [], + read: [tagSavedObjectTypeName], + }, + management: { + kibana: [tagManagementSectionId], + }, + api: [], + ui: ['view', 'assign'], + }, + }, +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/index.ts b/x-pack/plugins/saved_objects_tagging/server/index.ts new file mode 100644 index 0000000000000..3b1d7a1ba6f27 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from '../../../../src/core/server'; +import { SavedObjectTaggingPlugin } from './plugin'; + +export { config } from './config'; + +export const plugin = (initializerContext: PluginInitializerContext) => + new SavedObjectTaggingPlugin(initializerContext); diff --git a/x-pack/plugins/saved_objects_tagging/server/plugin.test.mocks.ts b/x-pack/plugins/saved_objects_tagging/server/plugin.test.mocks.ts new file mode 100644 index 0000000000000..1223b1ec20389 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/plugin.test.mocks.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const registerRoutesMock = jest.fn(); +jest.doMock('./routes', () => ({ + registerRoutes: registerRoutesMock, +})); diff --git a/x-pack/plugins/saved_objects_tagging/server/plugin.test.ts b/x-pack/plugins/saved_objects_tagging/server/plugin.test.ts new file mode 100644 index 0000000000000..1a3e4071f5e09 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/plugin.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { registerRoutesMock } from './plugin.test.mocks'; + +import { coreMock } from '../../../../src/core/server/mocks'; +import { featuresPluginMock } from '../../features/server/mocks'; +import { SavedObjectTaggingPlugin } from './plugin'; +import { savedObjectsTaggingFeature } from './features'; + +describe('SavedObjectTaggingPlugin', () => { + let plugin: SavedObjectTaggingPlugin; + let featuresPluginSetup: ReturnType; + + beforeEach(() => { + plugin = new SavedObjectTaggingPlugin(coreMock.createPluginInitializerContext()); + featuresPluginSetup = featuresPluginMock.createSetup(); + }); + + describe('#setup', () => { + it('registers routes', async () => { + await plugin.setup(coreMock.createSetup(), { features: featuresPluginSetup }); + expect(registerRoutesMock).toHaveBeenCalledTimes(1); + }); + + it('registers the globalSearch route handler context', async () => { + const coreSetup = coreMock.createSetup(); + await plugin.setup(coreSetup, { features: featuresPluginSetup }); + expect(coreSetup.http.registerRouteHandlerContext).toHaveBeenCalledTimes(1); + expect(coreSetup.http.registerRouteHandlerContext).toHaveBeenCalledWith( + 'tags', + expect.any(Function) + ); + }); + + it('registers the `savedObjectsTagging` feature', async () => { + await plugin.setup(coreMock.createSetup(), { features: featuresPluginSetup }); + expect(featuresPluginSetup.registerKibanaFeature).toHaveBeenCalledTimes(1); + expect(featuresPluginSetup.registerKibanaFeature).toHaveBeenCalledWith( + savedObjectsTaggingFeature + ); + }); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/server/plugin.ts b/x-pack/plugins/saved_objects_tagging/server/plugin.ts new file mode 100644 index 0000000000000..8347fb1f8ef20 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/plugin.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup, CoreStart, PluginInitializerContext, Plugin } from 'src/core/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; +import { savedObjectsTaggingFeature } from './features'; +import { tagType } from './saved_objects'; +import { ITagsRequestHandlerContext } from './types'; +import { registerRoutes } from './routes'; +import { TagsRequestHandlerContext } from './request_handler_context'; + +interface SetupDeps { + features: FeaturesPluginSetup; +} + +export class SavedObjectTaggingPlugin implements Plugin<{}, {}, SetupDeps, {}> { + constructor(context: PluginInitializerContext) {} + + public setup({ savedObjects, http }: CoreSetup, { features }: SetupDeps) { + savedObjects.registerType(tagType); + + const router = http.createRouter(); + registerRoutes({ router }); + + http.registerRouteHandlerContext( + 'tags', + async (context, req, res): Promise => { + return new TagsRequestHandlerContext(context.core); + } + ); + + features.registerKibanaFeature(savedObjectsTaggingFeature); + + return {}; + } + + public start(core: CoreStart) { + return {}; + } +} diff --git a/x-pack/plugins/saved_objects_tagging/server/request_handler_context.ts b/x-pack/plugins/saved_objects_tagging/server/request_handler_context.ts new file mode 100644 index 0000000000000..08514a32d3e0c --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/request_handler_context.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { RequestHandlerContext } from 'src/core/server'; +import { ITagsClient } from '../common/types'; +import { ITagsRequestHandlerContext } from './types'; +import { TagsClient } from './tags'; + +export class TagsRequestHandlerContext implements ITagsRequestHandlerContext { + #client?: ITagsClient; + + constructor(private readonly coreContext: RequestHandlerContext['core']) {} + + public get tagsClient() { + if (this.#client == null) { + this.#client = new TagsClient({ client: this.coreContext.savedObjects.client }); + } + return this.#client; + } +} diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/create_tag.ts b/x-pack/plugins/saved_objects_tagging/server/routes/create_tag.ts new file mode 100644 index 0000000000000..2db9ed33972fe --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/create_tag.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { TagValidationError } from '../tags'; + +export const registerCreateTagRoute = (router: IRouter) => { + router.post( + { + path: '/api/saved_objects_tagging/tags/create', + validate: { + body: schema.object({ + name: schema.string(), + description: schema.string(), + color: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + try { + const tag = await ctx.tags!.tagsClient.create(req.body); + return res.ok({ + body: { + tag, + }, + }); + } catch (e) { + if (e instanceof TagValidationError) { + return res.badRequest({ + body: { + message: e.message, + attributes: e.validation, + }, + }); + } + throw e; + } + }) + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/delete_tag.ts b/x-pack/plugins/saved_objects_tagging/server/routes/delete_tag.ts new file mode 100644 index 0000000000000..84f798063555e --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/delete_tag.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; + +export const registerDeleteTagRoute = (router: IRouter) => { + router.delete( + { + path: '/api/saved_objects_tagging/tags/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + const { id } = req.params; + await ctx.tags!.tagsClient.delete(id); + return res.ok(); + }) + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/get_all_tags.ts b/x-pack/plugins/saved_objects_tagging/server/routes/get_all_tags.ts new file mode 100644 index 0000000000000..cbc43d66f0ecc --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/get_all_tags.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'src/core/server'; + +export const registerGetAllTagsRoute = (router: IRouter) => { + router.get( + { + path: '/api/saved_objects_tagging/tags', + validate: {}, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + const tags = await ctx.tags!.tagsClient.getAll(); + return res.ok({ + body: { + tags, + }, + }); + }) + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/get_tag.ts b/x-pack/plugins/saved_objects_tagging/server/routes/get_tag.ts new file mode 100644 index 0000000000000..559c5ed2d8dd1 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/get_tag.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; + +export const registerGetTagRoute = (router: IRouter) => { + router.get( + { + path: '/api/saved_objects_tagging/tags/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + const { id } = req.params; + const tag = await ctx.tags!.tagsClient.get(id); + return res.ok({ + body: { + tag, + }, + }); + }) + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/index.ts b/x-pack/plugins/saved_objects_tagging/server/routes/index.ts new file mode 100644 index 0000000000000..9519f54e01693 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'src/core/server'; +import { registerCreateTagRoute } from './create_tag'; +import { registerDeleteTagRoute } from './delete_tag'; +import { registerGetAllTagsRoute } from './get_all_tags'; +import { registerGetTagRoute } from './get_tag'; +import { registerUpdateTagRoute } from './update_tag'; +import { registerInternalFindTagsRoute } from './internal'; + +export const registerRoutes = ({ router }: { router: IRouter }) => { + // public API + registerCreateTagRoute(router); + registerUpdateTagRoute(router); + registerDeleteTagRoute(router); + registerGetAllTagsRoute(router); + registerGetTagRoute(router); + // internal API + registerInternalFindTagsRoute(router); +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/internal/find_tags.ts b/x-pack/plugins/saved_objects_tagging/server/routes/internal/find_tags.ts new file mode 100644 index 0000000000000..2b7515a93acab --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/internal/find_tags.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { tagSavedObjectTypeName } from '../../../common/constants'; +import { TagAttributes } from '../../../common/types'; +import { savedObjectToTag } from '../../tags'; +import { addConnectionCount } from '../lib'; + +export const registerInternalFindTagsRoute = (router: IRouter) => { + router.get( + { + path: '/internal/saved_objects_tagging/tags/_find', + validate: { + query: schema.object({ + perPage: schema.number({ min: 0, defaultValue: 20 }), + page: schema.number({ min: 0, defaultValue: 1 }), + search: schema.maybe(schema.string()), + }), + }, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + const { query } = req; + const { client, typeRegistry } = ctx.core.savedObjects; + + const findResponse = await client.find({ + page: query.page, + perPage: query.perPage, + search: query.search, + type: [tagSavedObjectTypeName], + searchFields: ['title', 'description'], + }); + + const tags = findResponse.saved_objects.map(savedObjectToTag); + const allTypes = typeRegistry.getAllTypes().map((type) => type.name); + + const tagsWithConnections = await addConnectionCount(tags, allTypes, client); + + return res.ok({ + body: { + tags: tagsWithConnections, + total: findResponse.total, + }, + }); + }) + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/internal/index.ts b/x-pack/plugins/saved_objects_tagging/server/routes/internal/index.ts new file mode 100644 index 0000000000000..9d427cfe5831c --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/internal/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { registerInternalFindTagsRoute } from './find_tags'; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/lib/get_connection_count.ts b/x-pack/plugins/saved_objects_tagging/server/routes/lib/get_connection_count.ts new file mode 100644 index 0000000000000..4f77b8ab15fbb --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/lib/get_connection_count.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract, SavedObjectsFindOptionsReference } from 'src/core/server'; +import { tagSavedObjectTypeName } from '../../../common/constants'; +import { Tag, TagWithRelations } from '../../../common/types'; + +export const addConnectionCount = async ( + tags: Tag[], + targetTypes: string[], + client: SavedObjectsClientContract +): Promise => { + const ids = new Set(tags.map((tag) => tag.id)); + const counts: Map = new Map(tags.map((tag) => [tag.id, 0])); + + const references: SavedObjectsFindOptionsReference[] = tags.map(({ id }) => ({ + type: 'tag', + id, + })); + + const allResults = await client.find({ + type: targetTypes, + page: 1, + perPage: 10000, + hasReference: references, + hasReferenceOperator: 'OR', + }); + allResults.saved_objects.forEach((obj) => { + obj.references.forEach((ref) => { + if (ref.type === tagSavedObjectTypeName && ids.has(ref.id)) { + counts.set(ref.id, counts.get(ref.id)! + 1); + } + }); + }); + + return tags.map((tag) => ({ + ...tag, + relationCount: counts.get(tag.id)!, + })); +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/lib/index.ts b/x-pack/plugins/saved_objects_tagging/server/routes/lib/index.ts new file mode 100644 index 0000000000000..c860e3eeb9666 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/lib/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { addConnectionCount } from './get_connection_count'; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/update_tag.ts b/x-pack/plugins/saved_objects_tagging/server/routes/update_tag.ts new file mode 100644 index 0000000000000..2377e86aca3a1 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/update_tag.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { TagValidationError } from '../tags'; + +export const registerUpdateTagRoute = (router: IRouter) => { + router.post( + { + path: '/api/saved_objects_tagging/tags/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + name: schema.string(), + description: schema.string(), + color: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + const { id } = req.params; + try { + const tag = await ctx.tags!.tagsClient.update(id, req.body); + return res.ok({ + body: { + tag, + }, + }); + } catch (e) { + if (e instanceof TagValidationError) { + return res.badRequest({ + body: { + message: e.message, + attributes: e.validation, + }, + }); + } + throw e; + } + }) + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/saved_objects/index.ts b/x-pack/plugins/saved_objects_tagging/server/saved_objects/index.ts new file mode 100644 index 0000000000000..2a9e1c21a1169 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/saved_objects/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { tagType } from './tag'; diff --git a/x-pack/plugins/saved_objects_tagging/server/saved_objects/tag.ts b/x-pack/plugins/saved_objects_tagging/server/saved_objects/tag.ts new file mode 100644 index 0000000000000..d5dee35b9476e --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/saved_objects/tag.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObject, SavedObjectsType } from 'src/core/server'; +import { tagSavedObjectTypeName, TagAttributes } from '../../common'; + +export const tagType: SavedObjectsType = { + name: tagSavedObjectTypeName, + hidden: false, + namespaceType: 'single', + mappings: { + properties: { + name: { + type: 'text', + }, + description: { + type: 'text', + }, + color: { + type: 'text', + }, + }, + }, + management: { + importableAndExportable: true, + defaultSearchField: 'name', + icon: 'tag', + getTitle: (obj: SavedObject) => obj.attributes.name, + }, +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/errors.test.ts b/x-pack/plugins/saved_objects_tagging/server/tags/errors.test.ts new file mode 100644 index 0000000000000..a120b2f5ed557 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/tags/errors.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TagValidation } from '../../common/validation'; +import { TagValidationError } from './errors'; + +const createValidation = (errors: TagValidation['errors'] = {}): TagValidation => ({ + valid: Object.keys(errors).length === 0, + warnings: [], + errors, +}); + +describe('TagValidationError', () => { + it('is assignable to its instances', () => { + // this test is here to ensure the `Object.setPrototypeOf` constructor workaround for TS is not removed. + const error = new TagValidationError('validation error', createValidation()); + + expect(error instanceof TagValidationError).toBe(true); + }); + + it('allow access to the underlying validation', () => { + const validation = createValidation(); + + const error = new TagValidationError('validation error', createValidation()); + + expect(error.validation).toStrictEqual(validation); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/errors.ts b/x-pack/plugins/saved_objects_tagging/server/tags/errors.ts new file mode 100644 index 0000000000000..ee1f247dcf56b --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/tags/errors.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TagValidation } from '../../common'; + +/** + * Error returned from {@link TagsClient#create} or {@link TagsClient#update} when tag + * validation failed. + */ +export class TagValidationError extends Error { + constructor(message: string, public readonly validation: TagValidation) { + super(message); + Object.setPrototypeOf(this, TagValidationError.prototype); + } +} diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/index.ts b/x-pack/plugins/saved_objects_tagging/server/tags/index.ts new file mode 100644 index 0000000000000..4dacf94af73ad --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/tags/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TagsClient } from './tags_client'; +export { TagValidationError } from './errors'; +export { savedObjectToTag } from './utils'; diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.mock.ts b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.mock.ts new file mode 100644 index 0000000000000..a5eafb127e5c7 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.mock.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ITagsClient } from '../../common/types'; + +const createClientMock = () => { + const mock: jest.Mocked = { + create: jest.fn(), + get: jest.fn(), + getAll: jest.fn(), + delete: jest.fn(), + update: jest.fn(), + }; + + return mock; +}; + +export const tagsClientMock = { + create: createClientMock, +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.mocks.ts b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.mocks.ts new file mode 100644 index 0000000000000..c8c77164131cd --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.mocks.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const validateTagMock = jest.fn(); + +jest.doMock('./validate_tag', () => ({ + validateTag: validateTagMock, +})); diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.ts b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.ts new file mode 100644 index 0000000000000..7e656acb0204c --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.ts @@ -0,0 +1,241 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { validateTagMock } from './tags_client.test.mocks'; + +import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { TagAttributes, TagSavedObject } from '../../common/types'; +import { TagValidation } from '../../common/validation'; +import { TagsClient } from './tags_client'; +import { TagValidationError } from './errors'; + +const createAttributes = (parts: Partial = {}): TagAttributes => ({ + name: 'a-tag', + description: 'some-desc', + color: '#FF00CC', + ...parts, +}); + +const createTagSavedObject = ( + id: string = 'tag-id', + attributes: TagAttributes = createAttributes() +): TagSavedObject => ({ + id, + attributes, + type: 'tag', + references: [], +}); + +const createValidation = (errors: TagValidation['errors'] = {}): TagValidation => ({ + valid: Object.keys(errors).length === 0, + warnings: [], + errors, +}); + +describe('TagsClient', () => { + let soClient: ReturnType; + let tagsClient: TagsClient; + + beforeEach(() => { + soClient = savedObjectsClientMock.create(); + tagsClient = new TagsClient({ client: soClient }); + + validateTagMock.mockReturnValue({ valid: true }); + }); + + describe('#create', () => { + beforeEach(() => { + soClient.create.mockResolvedValue(createTagSavedObject()); + }); + + it('calls `soClient.create` with the correct parameters', async () => { + const attributes = createAttributes(); + + await tagsClient.create(attributes); + + expect(soClient.create).toHaveBeenCalledTimes(1); + expect(soClient.create).toHaveBeenCalledWith('tag', attributes); + }); + + it('converts the object returned from the soClient to a `Tag`', async () => { + const id = 'some-id'; + const attributes = createAttributes(); + soClient.create.mockResolvedValue(createTagSavedObject(id, attributes)); + + const tag = await tagsClient.create(attributes); + expect(tag).toEqual({ + id, + ...attributes, + }); + }); + + it('returns a `TagValidationError` if attributes validation fails', async () => { + const validation = createValidation({ + name: 'Invalid name', + }); + validateTagMock.mockReturnValue(validation); + + await expect(tagsClient.create(createAttributes())).rejects.toThrowError(TagValidationError); + }); + + it('does not call `soClient.create` if attributes validation fails', async () => { + expect.assertions(1); + + const validation = createValidation({ + name: 'Invalid name', + }); + validateTagMock.mockReturnValue(validation); + + try { + await tagsClient.create(createAttributes()); + } catch (e) { + expect(soClient.create).not.toHaveBeenCalled(); + } + }); + }); + + describe('#update', () => { + const tagId = 'some-id'; + + beforeEach(() => { + soClient.update.mockResolvedValue(createTagSavedObject()); + }); + + it('calls `soClient.update` with the correct parameters', async () => { + const attributes = createAttributes(); + + await tagsClient.update(tagId, attributes); + + expect(soClient.update).toHaveBeenCalledTimes(1); + expect(soClient.update).toHaveBeenCalledWith('tag', tagId, attributes); + }); + + it('converts the object returned from the soClient to a `Tag`', async () => { + const attributes = createAttributes(); + soClient.update.mockResolvedValue(createTagSavedObject(tagId, attributes)); + + const tag = await tagsClient.update(tagId, attributes); + expect(tag).toEqual({ + id: tagId, + ...attributes, + }); + }); + + it('returns a `TagValidationError` if attributes validation fails', async () => { + const validation = createValidation({ + name: 'Invalid name', + }); + validateTagMock.mockReturnValue(validation); + + await expect(tagsClient.update(tagId, createAttributes())).rejects.toThrowError( + TagValidationError + ); + }); + + it('does not call `soClient.create` if attributes validation fails', async () => { + expect.assertions(1); + + const validation = createValidation({ + name: 'Invalid name', + }); + validateTagMock.mockReturnValue(validation); + + try { + await tagsClient.update(tagId, createAttributes()); + } catch (e) { + expect(soClient.update).not.toHaveBeenCalled(); + } + }); + }); + + describe('#get', () => { + const tagId = 'some-id'; + const tagObject = createTagSavedObject(tagId); + + beforeEach(() => { + soClient.get.mockResolvedValue(tagObject); + }); + + it('calls `soClient.get` with the correct parameters', async () => { + await tagsClient.get(tagId); + + expect(soClient.get).toHaveBeenCalledTimes(1); + expect(soClient.get).toHaveBeenCalledWith('tag', tagId); + }); + + it('converts the object returned from the soClient to a `Tag`', async () => { + const tag = await tagsClient.get(tagId); + expect(tag).toEqual({ + id: tagId, + ...tagObject.attributes, + }); + }); + }); + describe('#getAll', () => { + const tags = [ + createTagSavedObject('tag-1'), + createTagSavedObject('tag-2'), + createTagSavedObject('tag-3'), + ]; + + beforeEach(() => { + soClient.find.mockResolvedValue({ + saved_objects: tags.map((tag) => ({ ...tag, score: 1 })), + total: 3, + per_page: 1000, + page: 0, + }); + }); + + it('calls `soClient.find` with the correct parameters', async () => { + await tagsClient.getAll(); + + expect(soClient.find).toHaveBeenCalledTimes(1); + expect(soClient.find).toHaveBeenCalledWith({ + type: 'tag', + perPage: 10000, + }); + }); + + it('converts the objects returned from the soClient to tags', async () => { + const returnedTags = await tagsClient.getAll(); + expect(returnedTags).toEqual(tags.map((tag) => ({ id: tag.id, ...tag.attributes }))); + }); + }); + describe('#delete', () => { + const tagId = 'tag-id'; + + it('calls `soClient.delete` with the correct parameters', async () => { + await tagsClient.delete(tagId); + + expect(soClient.delete).toHaveBeenCalledTimes(1); + expect(soClient.delete).toHaveBeenCalledWith('tag', tagId); + }); + + it('calls `soClient.removeReferencesTo` with the correct parameters', async () => { + await tagsClient.delete(tagId); + + expect(soClient.removeReferencesTo).toHaveBeenCalledTimes(1); + expect(soClient.removeReferencesTo).toHaveBeenCalledWith('tag', tagId); + }); + + it('calls `soClient.removeReferencesTo` before `soClient.delete`', async () => { + await tagsClient.delete(tagId); + + expect(soClient.removeReferencesTo.mock.invocationCallOrder[0]).toBeLessThan( + soClient.delete.mock.invocationCallOrder[0] + ); + }); + + it('does not calls `soClient.delete` if `soClient.removeReferencesTo` throws', async () => { + soClient.removeReferencesTo.mockRejectedValue(new Error('something went wrong')); + + await expect(tagsClient.delete(tagId)).rejects.toThrowError(); + + expect(soClient.delete).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.ts b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.ts new file mode 100644 index 0000000000000..ef4ad6f128346 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'src/core/server'; +import { TagSavedObject, TagAttributes, ITagsClient } from '../../common/types'; +import { tagSavedObjectTypeName } from '../../common/constants'; +import { TagValidationError } from './errors'; +import { validateTag } from './validate_tag'; +import { savedObjectToTag } from './utils'; + +interface TagsClientOptions { + client: SavedObjectsClientContract; +} + +export class TagsClient implements ITagsClient { + private readonly soClient: SavedObjectsClientContract; + private readonly type = tagSavedObjectTypeName; + + constructor({ client }: TagsClientOptions) { + this.soClient = client; + } + + public async create(attributes: TagAttributes) { + const validation = validateTag(attributes); + if (!validation.valid) { + throw new TagValidationError('Error validating tag attributes', validation); + } + const raw = await this.soClient.create(this.type, attributes); + return savedObjectToTag(raw); + } + + public async update(id: string, attributes: TagAttributes) { + const validation = validateTag(attributes); + if (!validation.valid) { + throw new TagValidationError('Error validating tag attributes', validation); + } + const raw = await this.soClient.update(this.type, id, attributes); + return savedObjectToTag(raw as TagSavedObject); // all attributes are updated, this is not a partial + } + + public async get(id: string) { + const raw = await this.soClient.get(this.type, id); + return savedObjectToTag(raw); + } + + public async getAll() { + const result = await this.soClient.find({ + type: this.type, + perPage: 10000, + }); + + return result.saved_objects.map(savedObjectToTag); + } + + public async delete(id: string) { + // `removeReferencesTo` security check is the same as a `delete` operation's, so we can use the scoped client here. + // If that was to change, we would need to use the internal client instead. A FTR test is ensuring + // that this behave properly even with only 'tag' SO type write permission. + await this.soClient.removeReferencesTo(this.type, id); + // deleting the tag after reference removal in case of failure during the first call. + await this.soClient.delete(this.type, id); + } +} diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/utils.ts b/x-pack/plugins/saved_objects_tagging/server/tags/utils.ts new file mode 100644 index 0000000000000..bd9dece0eaf61 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/tags/utils.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Tag, TagSavedObject } from '../../common/types'; + +export const savedObjectToTag = (savedObject: TagSavedObject): Tag => { + return { + id: savedObject.id, + ...savedObject.attributes, + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.mocks.ts b/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.mocks.ts new file mode 100644 index 0000000000000..62b6b203f42cf --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.mocks.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const validateTagNameMock = jest.fn(); +export const validateTagColorMock = jest.fn(); +export const validateTagDescriptionMock = jest.fn(); + +jest.doMock('../../common/validation', () => ({ + validateTagName: validateTagNameMock, + validateTagColor: validateTagColorMock, + validateTagDescription: validateTagDescriptionMock, +})); diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.ts b/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.ts new file mode 100644 index 0000000000000..2e8201d560245 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + validateTagColorMock, + validateTagNameMock, + validateTagDescriptionMock, +} from './validate_tag.test.mocks'; + +import { TagAttributes } from '../../common/types'; +import { validateTag } from './validate_tag'; + +const createAttributes = (parts: Partial = {}): TagAttributes => ({ + name: 'a-tag', + description: 'some-desc', + color: '#FF00CC', + ...parts, +}); + +describe('validateTag', () => { + beforeEach(() => { + validateTagNameMock.mockReset(); + validateTagColorMock.mockReset(); + validateTagDescriptionMock.mockReset(); + }); + + it('calls `validateTagName` with attributes.name', () => { + const attributes = createAttributes(); + + validateTag(attributes); + + expect(validateTagNameMock).toHaveBeenCalledTimes(1); + expect(validateTagNameMock).toHaveBeenCalledWith(attributes.name); + }); + + it('returns the error from `validateTagName` in `errors.name`', () => { + const nameError = 'invalid name'; + const attributes = createAttributes(); + validateTagNameMock.mockReturnValue(nameError); + + const validation = validateTag(attributes); + + expect(validation.errors.name).toBe(nameError); + }); + + it('calls `validateTagColor` with attributes.color', () => { + const attributes = createAttributes(); + + validateTag(attributes); + + expect(validateTagColorMock).toHaveBeenCalledTimes(1); + expect(validateTagColorMock).toHaveBeenCalledWith(attributes.color); + }); + + it('returns the error from `validateTagColor` in `errors.color`', () => { + const nameError = 'invalid color'; + const attributes = createAttributes(); + validateTagColorMock.mockReturnValue(nameError); + + const validation = validateTag(attributes); + + expect(validation.errors.color).toBe(nameError); + }); + + it('returns `valid: true` if no field has error', () => { + const attributes = createAttributes(); + validateTagNameMock.mockReturnValue(undefined); + validateTagColorMock.mockReturnValue(undefined); + + const validation = validateTag(attributes); + expect(validation.valid).toBe(true); + }); + + it('calls `validateTagDescription` with attributes.description', () => { + const attributes = createAttributes(); + + validateTag(attributes); + + expect(validateTagDescriptionMock).toHaveBeenCalledTimes(1); + expect(validateTagDescriptionMock).toHaveBeenCalledWith(attributes.description); + }); + + it('returns the error from `validateTagDescription` in `errors.description`', () => { + const descError = 'invalid description'; + const attributes = createAttributes(); + validateTagDescriptionMock.mockReturnValue(descError); + + const validation = validateTag(attributes); + + expect(validation.errors.description).toBe(descError); + }); + + it('returns `valid: false` if any field has error', () => { + const attributes = createAttributes(); + validateTagNameMock.mockReturnValue('invalid name'); + validateTagColorMock.mockReturnValue(undefined); + validateTagDescriptionMock.mockReturnValue(undefined); + + let validation = validateTag(attributes); + expect(validation.valid).toBe(false); + + validateTagNameMock.mockReturnValue(undefined); + validateTagColorMock.mockReturnValue('invalid color'); + validateTagDescriptionMock.mockReturnValue(undefined); + + validation = validateTag(attributes); + expect(validation.valid).toBe(false); + + validateTagNameMock.mockReturnValue(undefined); + validateTagColorMock.mockReturnValue(undefined); + validateTagDescriptionMock.mockReturnValue('invalid desc'); + + validation = validateTag(attributes); + expect(validation.valid).toBe(false); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.ts b/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.ts new file mode 100644 index 0000000000000..e49c4cee504b8 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TagAttributes } from '../../common/types'; +import { + TagValidation, + validateTagColor, + validateTagName, + validateTagDescription, +} from '../../common/validation'; + +export const validateTag = (attributes: TagAttributes): TagValidation => { + const validation: TagValidation = { + valid: true, + warnings: [], + errors: {}, + }; + + validation.errors.name = validateTagName(attributes.name); + validation.errors.color = validateTagColor(attributes.color); + validation.errors.description = validateTagDescription(attributes.description); + + Object.values(validation.errors).forEach((error) => { + if (error) { + validation.valid = false; + } + }); + + return validation; +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/types.ts b/x-pack/plugins/saved_objects_tagging/server/types.ts new file mode 100644 index 0000000000000..9997be0c3cb22 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/types.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ITagsClient } from '../common/types'; + +export interface ITagsRequestHandlerContext { + tagsClient: ITagsClient; +} + +declare module 'src/core/server' { + interface RequestHandlerContext { + tags?: ITagsRequestHandlerContext; + } +} diff --git a/x-pack/plugins/security/server/audit/audit_events.test.ts b/x-pack/plugins/security/server/audit/audit_events.test.ts index f153b9efb9d43..1713badede2f7 100644 --- a/x-pack/plugins/security/server/audit/audit_events.test.ts +++ b/x-pack/plugins/security/server/audit/audit_events.test.ts @@ -105,6 +105,34 @@ describe('#savedObjectEvent', () => { } `); }); + + test('creates event with `success` outcome for `REMOVE_REFERENCES` action', () => { + expect( + savedObjectEvent({ + action: SavedObjectAction.REMOVE_REFERENCES, + savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "saved_object_remove_references", + "category": "database", + "outcome": "success", + "type": "change", + }, + "kibana": Object { + "add_to_spaces": undefined, + "delete_from_spaces": undefined, + "saved_object": Object { + "id": "SAVED_OBJECT_ID", + "type": "dashboard", + }, + }, + "message": "User has removed references to dashboard [id=SAVED_OBJECT_ID]", + } + `); + }); }); describe('#userLoginEvent', () => { diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index d91c18bf82e02..e3c1f95349c92 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -175,6 +175,7 @@ export enum SavedObjectAction { FIND = 'saved_object_find', ADD_TO_SPACES = 'saved_object_add_to_spaces', DELETE_FROM_SPACES = 'saved_object_delete_from_spaces', + REMOVE_REFERENCES = 'saved_object_remove_references', } const eventVerbs = { @@ -185,6 +186,11 @@ const eventVerbs = { saved_object_find: ['access', 'accessing', 'accessed'], saved_object_add_to_spaces: ['update', 'updating', 'updated'], saved_object_delete_from_spaces: ['update', 'updating', 'updated'], + saved_object_remove_references: [ + 'remove references to', + 'removing references to', + 'removed references to', + ], }; const eventTypes = { @@ -195,6 +201,7 @@ const eventTypes = { saved_object_find: EventType.ACCESS, saved_object_add_to_spaces: EventType.CHANGE, saved_object_delete_from_spaces: EventType.CHANGE, + saved_object_remove_references: EventType.CHANGE, }; export interface SavedObjectParams { diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index 8136553e4a623..6b9592815dfc5 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -1098,6 +1098,57 @@ describe('#update', () => { }); }); +describe('#removeReferencesTo', () => { + const type = 'foo'; + const id = `${type}-id`; + const namespace = 'some-ns'; + const options = { namespace }; + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.removeReferencesTo, { type, id, options }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + await expectForbiddenError( + client.removeReferencesTo, + { type, id, options }, + 'removeReferences' + ); + }); + + test(`returns result of baseClient.removeReferencesTo when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.removeReferencesTo.mockReturnValue(apiCallReturnValue as any); + + const result = await expectSuccess( + client.removeReferencesTo, + { type, id, options }, + 'removeReferences' + ); + expect(result).toBe(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + await expectPrivilegeCheck(client.removeReferencesTo, { type, id, options }, namespace); + }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.removeReferencesTo.mockReturnValue(apiCallReturnValue as any); + await client.removeReferencesTo(type, id); + + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_remove_references', EventOutcome.UNKNOWN, { type, id }); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.removeReferencesTo(type, id)).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_remove_references', EventOutcome.FAILURE, { type, id }); + }); +}); + describe('other', () => { test(`assigns errors from constructor to .errors`, () => { expect(client.errors).toBe(clientOpts.errors); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index 85e8e21da81b0..2ef0cafcd6fdb 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -5,6 +5,7 @@ */ import type { PublicMethodsOf } from '@kbn/utility-types'; import { + SavedObjectsAddToNamespacesOptions, SavedObjectsBaseOptions, SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, @@ -12,18 +13,23 @@ import { SavedObjectsCheckConflictsObject, SavedObjectsClientContract, SavedObjectsCreateOptions, + SavedObjectsDeleteFromNamespacesOptions, SavedObjectsFindOptions, + SavedObjectsRemoveReferencesToOptions, SavedObjectsUpdateOptions, - SavedObjectsAddToNamespacesOptions, - SavedObjectsDeleteFromNamespacesOptions, SavedObjectsUtils, } from '../../../../../src/core/server'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; -import { SecurityAuditLogger } from '../audit'; +import { + AuditLogger, + EventOutcome, + SavedObjectAction, + savedObjectEvent, + SecurityAuditLogger, +} from '../audit'; import { Actions, CheckSavedObjectsPrivileges } from '../authorization'; import { CheckPrivilegesResponse } from '../authorization/types'; import { SpacesService } from '../plugin'; -import { AuditLogger, EventOutcome, SavedObjectAction, savedObjectEvent } from '../audit'; interface SecureSavedObjectsClientWrapperOptions { actions: Actions; @@ -476,6 +482,39 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.redactSavedObjectsNamespaces(response); } + public async removeReferencesTo( + type: string, + id: string, + options: SavedObjectsRemoveReferencesToOptions = {} + ) { + try { + const args = { type, id, options }; + await this.ensureAuthorized(type, 'delete', options.namespace, { + args, + auditAction: 'removeReferences', + }); + } catch (error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.REMOVE_REFERENCES, + savedObject: { type, id }, + error, + }) + ); + throw error; + } + + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.REMOVE_REFERENCES, + savedObject: { type, id }, + outcome: EventOutcome.UNKNOWN, + }) + ); + + return await this.baseClient.removeReferencesTo(type, id, options); + } + private async checkPrivileges( actions: string | string[], namespaceOrNamespaces?: string | Array diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 254cf3817ca48..bee3b9ccb5f26 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -528,5 +528,34 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); }); }); + + describe('#removeReferencesTo', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = await createSpacesSavedObjectsClient(); + + await expect( + // @ts-expect-error + client.removeReferencesTo(null, null, { namespace: 'bar' }) + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = { updated: 12 }; + baseClient.removeReferencesTo.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const type = Symbol(); + const id = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-expect-error + const actualReturnValue = await client.removeReferencesTo(type, id, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.removeReferencesTo).toHaveBeenCalledWith(type, id, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); }); }); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index e96ae49b41679..582690f945c08 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -17,6 +17,7 @@ import { SavedObjectsUpdateOptions, SavedObjectsAddToNamespacesOptions, SavedObjectsDeleteFromNamespacesOptions, + SavedObjectsRemoveReferencesToOptions, SavedObjectsUtils, ISavedObjectTypeRegistry, } from '../../../../../src/core/server'; @@ -335,4 +336,23 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { namespace: spaceIdToNamespace(this.spaceId), }); } + + /** + * Remove outward references to given object. + * + * @param type + * @param id + * @param options + */ + public async removeReferencesTo( + type: string, + id: string, + options: SavedObjectsRemoveReferencesToOptions = {} + ) { + throwErrorIfNamespaceSpecified(options); + return await this.client.removeReferencesTo(type, id, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } } diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 7667321872347..3bf697bd97b14 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -65,6 +65,9 @@ const onlyNotInCoverageTests = [ require.resolve('../test/reporting_api_integration/reporting_without_security.config.ts'), require.resolve('../test/security_solution_endpoint_api_int/config.ts'), require.resolve('../test/ingest_manager_api_integration/config.ts'), + require.resolve('../test/saved_object_tagging/functional/config.ts'), + require.resolve('../test/saved_object_tagging/api_integration/security_and_spaces/config.ts'), + require.resolve('../test/saved_object_tagging/api_integration/tagging_api/config.ts'), ]; require('../../src/setup_node_env'); diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index 42e3abfa145a0..bc1df21773a71 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -105,6 +105,7 @@ export default function ({ getService }: FtrProviderContext) { 'graph', 'monitoring', 'savedObjectsManagement', + 'savedObjectsTagging', 'ml', 'apm', 'stackAlerts', diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 9143219477353..b6f77e9842296 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -27,6 +27,7 @@ export default function ({ getService }: FtrProviderContext) { advancedSettings: ['all', 'read'], indexPatterns: ['all', 'read'], savedObjectsManagement: ['all', 'read'], + savedObjectsTagging: ['all', 'read'], timelion: ['all', 'read'], graph: ['all', 'read'], maps: ['all', 'read'], diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index 25061f0ded651..679e96dd21514 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -25,6 +25,7 @@ export default function ({ getService }: FtrProviderContext) { advancedSettings: ['all', 'read'], indexPatterns: ['all', 'read'], savedObjectsManagement: ['all', 'read'], + savedObjectsTagging: ['all', 'read'], timelion: ['all', 'read'], graph: ['all', 'read'], maps: ['all', 'read'], diff --git a/x-pack/test/functional/apps/management/feature_controls/management_security.ts b/x-pack/test/functional/apps/management/feature_controls/management_security.ts index 46d8df74e5315..6a70aed4ec9c9 100644 --- a/x-pack/test/functional/apps/management/feature_controls/management_security.ts +++ b/x-pack/test/functional/apps/management/feature_controls/management_security.ts @@ -66,7 +66,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); expect(sections[1]).to.eql({ sectionId: 'kibana', - sectionLinks: ['indexPatterns', 'objects', 'spaces', 'settings'], + sectionLinks: ['indexPatterns', 'objects', 'tags', 'spaces', 'settings'], }); }); }); diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index 7569904fe90fd..da5b55f4aa2a1 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -36,6 +36,7 @@ import { InfraMetricExplorerProvider } from './infra_metric_explorer'; import { RoleMappingsPageProvider } from './role_mappings_page'; import { SpaceSelectorPageProvider } from './space_selector_page'; import { IngestPipelinesPageProvider } from './ingest_pipelines_page'; +import { TagManagementPageProvider } from './tag_management_page'; // just like services, PageObjects are defined as a map of // names to Providers. Merge in Kibana's or pick specific ones @@ -63,6 +64,7 @@ export const pageObjects = { licenseManagement: LicenseManagementPageProvider, indexManagement: IndexManagementPageProvider, indexLifecycleManagement: IndexLifecycleManagementPageProvider, + tagManagement: TagManagementPageProvider, snapshotRestore: SnapshotRestorePageProvider, crossClusterReplication: CrossClusterReplicationPageProvider, remoteClusters: RemoteClustersPageProvider, diff --git a/x-pack/test/functional/page_objects/tag_management_page.ts b/x-pack/test/functional/page_objects/tag_management_page.ts new file mode 100644 index 0000000000000..8b354e9d0e1c4 --- /dev/null +++ b/x-pack/test/functional/page_objects/tag_management_page.ts @@ -0,0 +1,311 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// eslint-disable-next-line max-classes-per-file +import { WebElementWrapper } from '../../../../test/functional/services/lib/web_element_wrapper'; +import { FtrProviderContext } from '../ftr_provider_context'; + +interface FillTagFormFields { + name?: string; + color?: string; + description?: string; +} + +type TagFormValidation = FillTagFormFields; + +export function TagManagementPageProvider({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const browser = getService('browser'); + const PageObjects = getPageObjects(['header', 'common', 'savedObjects', 'settings']); + const retry = getService('retry'); + + /** + * Sub page object to manipulate the create/edit tag modal. + */ + class TagModal { + constructor(private readonly page: TagManagementPage) {} + /** + * Open the create tag modal, by clicking on the associated button. + */ + async openCreate() { + return await testSubjects.click('createTagButton'); + } + + /** + * Open the edit tag modal for given tag name. The tag must be in the currently displayed tags. + */ + async openEdit(tagName: string) { + await this.page.clickEdit(tagName); + } + + /** + * Fills the given fields in the form. + * + * If a field is undefined, will not set the value (use a empty string for that) + * If `submit` is true, will call `clickConfirm` once the fields have been filled. + */ + async fillForm(fields: FillTagFormFields, { submit = false }: { submit?: boolean } = {}) { + if (fields.name !== undefined) { + await testSubjects.click('createModalField-name'); + await testSubjects.setValue('createModalField-name', fields.name); + } + if (fields.color !== undefined) { + // EuiColorPicker does not allow to specify data-test-subj for the colorpicker input + await testSubjects.setValue('colorPickerAnchor', fields.color); + } + if (fields.description !== undefined) { + await testSubjects.click('createModalField-description'); + await testSubjects.setValue('createModalField-description', fields.description); + } + + if (submit) { + await this.clickConfirm(); + } + } + + /** + * Return the values currently displayed in the form. + */ + async getFormValues(): Promise> { + return { + name: await testSubjects.getAttribute('createModalField-name', 'value'), + color: await testSubjects.getAttribute('colorPickerAnchor', 'value'), + description: await testSubjects.getAttribute('createModalField-description', 'value'), + }; + } + + /** + * Return the validation errors currently displayed for each field. + */ + async getValidationErrors(): Promise { + const errors: TagFormValidation = {}; + + const getError = async (rowDataTestSubj: string) => { + const row = await testSubjects.find(rowDataTestSubj); + const errorElements = await row.findAllByClassName('euiFormErrorText'); + return errorElements.length ? await errorElements[0].getVisibleText() : undefined; + }; + + errors.name = await getError('createModalRow-name'); + errors.color = await getError('createModalRow-color'); + errors.description = await getError('createModalRow-description'); + + return errors; + } + + /** + * Returns true if the form as at least one error displayed, false otherwise + */ + async hasError() { + const errors = await this.getValidationErrors(); + return Boolean(errors.name || errors.color || errors.description); + } + + /** + * Click on the 'cancel' button in the create/edit modal. + */ + async clickCancel() { + await testSubjects.click('createModalCancelButton'); + } + + /** + * Click on the 'confirm' button in the create/edit modal if not disabled. + */ + async clickConfirm() { + await testSubjects.click('createModalConfirmButton'); + await PageObjects.header.waitUntilLoadingHasFinished(); + } + + /** + * Return true if the confirm button is disabled, false otherwise. + */ + async isConfirmDisabled() { + const disabled = await testSubjects.getAttribute('createModalConfirmButton', 'disabled'); + return disabled === 'true'; + } + + /** + * Return true if the modal is currently opened. + */ + async isOpened() { + return await testSubjects.exists('tagModalForm'); + } + + /** + * Wait until the modal is closed. + */ + async waitUntilClosed() { + await retry.try(async () => { + if (await this.isOpened()) { + throw new Error('save modal still open'); + } + }); + } + + /** + * Close the modal if currently opened. + */ + async closeIfOpened() { + if (await this.isOpened()) { + await this.clickCancel(); + } + } + } + + class TagManagementPage { + public readonly tagModal = new TagModal(this); + + /** + * Navigate to the tag management section, by accessing the management app, then clicking + * on the `tags` link. + */ + async navigateTo() { + await PageObjects.settings.navigateTo(); + await testSubjects.click('tags'); + await this.waitUntilTableIsLoaded(); + } + + /** + * Wait until the tags table is displayed and is not in a the loading state + */ + async waitUntilTableIsLoaded() { + return retry.try(async () => { + const isLoaded = await find.existsByDisplayedByCssSelector( + '*[data-test-subj="tagsManagementTable"]:not(.euiBasicTable-loading)' + ); + + if (isLoaded) { + return true; + } else { + throw new Error('Waiting'); + } + }); + } + + /** + * Type given `term` in the table's search bar + */ + async searchForTerm(term: string) { + const searchBox = await testSubjects.find('tagsManagementSearchBar'); + await searchBox.clearValue(); + await searchBox.type(term); + await searchBox.pressKeys(browser.keys.ENTER); + await PageObjects.header.waitUntilLoadingHasFinished(); + await this.waitUntilTableIsLoaded(); + } + + /** + * Return true if the `Create new tag` button is visible, false otherwise. + */ + async isCreateButtonVisible() { + return await testSubjects.exists('createTagButton'); + } + + /** + * Return true if the `Delete tag` action button in the tag rows is visible, false otherwise. + */ + async isDeleteButtonVisible() { + return await testSubjects.exists('tagsTableAction-delete'); + } + + /** + * Return true if the `Edit tag` action button in the tag rows is visible, false otherwise. + */ + async isEditButtonVisible() { + return await testSubjects.exists('tagsTableAction-edit'); + } + + /** + * Return the (table ordered) name of the tags currently displayed in the table. + */ + async getDisplayedTagNames() { + const tags = await this.getDisplayedTagsInfo(); + return tags.map((tag) => tag.name); + } + + /** + * Return true if the 'connections' link is displayed for given tag name. + * + * Having the link not displayed can either mean that the user don't have the view + * permission to see the connections, or that the tag don't have any. + */ + async isConnectionLinkDisplayed(tagName: string) { + const tags = await this.getDisplayedTagsInfo(); + const tag = tags.find((t) => t.name === tagName); + return tag ? tag.connectionCount !== undefined : false; + } + + /** + * Click the 'edit' action button in the table for given tag name. + */ + async clickEdit(tagName: string) { + const tagRow = await this.getRowByName(tagName); + if (tagRow) { + const editButton = await testSubjects.findDescendant('tagsTableAction-edit', tagRow); + editButton?.click(); + } + } + + /** + * Return the raw `WebElementWrapper` of the table's row for given tag name. + */ + async getRowByName(tagName: string) { + const tagNames = await this.getDisplayedTagNames(); + const tagIndex = tagNames.indexOf(tagName); + const rows = await testSubjects.findAll('tagsTableRow'); + return rows[tagIndex]; + } + + /** + * Click on the 'connections' link in the table for given tag name. + */ + async clickOnConnectionsLink(tagName: string) { + const tagRow = await this.getRowByName(tagName); + const connectionLink = await testSubjects.findDescendant( + 'tagsTableRowConnectionsLink', + tagRow + ); + await connectionLink.click(); + } + + /** + * Return the info of all the tags currently displayed in the table (in table's order) + */ + async getDisplayedTagsInfo() { + const rows = await testSubjects.findAll('tagsTableRow'); + return Promise.all([...rows.map(parseTableRow)]); + } + + /** + * Converts the tagName to the format used in test subjects + * @param tagName + */ + testSubjFriendly(tagName: string) { + return tagName.replace(' ', '_'); + } + } + + const parseTableRow = async (row: WebElementWrapper) => { + const dom = await row.parseDomContent(); + + const connectionsText = dom.findTestSubject('tagsTableRowConnectionsLink').text(); + const rawConnectionCount = connectionsText.replace(/[^0-9]/g, ''); + const connectionCount = rawConnectionCount ? parseInt(rawConnectionCount, 10) : undefined; + + // ideally we would also return the color, but it can't be easily retrieved from the DOM + return { + name: dom.findTestSubject('tagsTableRowName').find('.euiTableCellContent').text(), + description: dom + .findTestSubject('tagsTableRowDescription') + .find('.euiTableCellContent') + .text(), + connectionCount, + }; + }; + + return new TagManagementPage(); +} diff --git a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/_find.ts b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/_find.ts new file mode 100644 index 0000000000000..4f08134365e95 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/_find.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { USERS, User, ExpectedResponse } from '../../../common/lib'; +import { FtrProviderContext } from '../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('GET /internal/saved_objects_tagging/tags/_find', () => { + before(async () => { + await esArchiver.load('rbac_tags'); + }); + + after(async () => { + await esArchiver.unload('rbac_tags'); + }); + + const responses: Record = { + authorized: { + httpCode: 200, + expectResponse: ({ body }) => { + expect(body).to.eql({ + tags: [ + { + id: 'default-space-tag-2', + name: 'tag-2', + description: 'Tag 2 in default space', + color: '#77CC11', + relationCount: 0, + }, + ], + total: 1, + }); + }, + }, + unauthorized: { + httpCode: 200, + expectResponse: ({ body }) => { + expect(body).to.eql({ + tags: [], + total: 0, + }); + }, + }, + }; + + const expectedResults: Record = { + authorized: [ + USERS.SUPERUSER, + USERS.DEFAULT_SPACE_READ_USER, + USERS.DEFAULT_SPACE_SO_MANAGEMENT_WRITE_USER, + USERS.DEFAULT_SPACE_SO_TAGGING_READ_USER, + USERS.DEFAULT_SPACE_SO_TAGGING_WRITE_USER, + USERS.DEFAULT_SPACE_DASHBOARD_READ_USER, + USERS.DEFAULT_SPACE_VISUALIZE_READ_USER, + ], + unauthorized: [USERS.NOT_A_KIBANA_USER, USERS.DEFAULT_SPACE_ADVANCED_SETTINGS_READ_USER], + }; + + const createUserTest = ( + { username, password, description }: User, + { httpCode, expectResponse }: ExpectedResponse + ) => { + it(`returns expected ${httpCode} response for ${description ?? username}`, async () => { + await supertest + .get(`/internal/saved_objects_tagging/tags/_find`) + .query({ + search: '2', + }) + .auth(username, password) + .expect(httpCode) + .then(expectResponse); + }); + }; + + const createTestSuite = () => { + Object.entries(expectedResults).forEach(([responseId, users]) => { + const response: ExpectedResponse = responses[responseId]; + users.forEach((user) => { + createUserTest(user, response); + }); + }); + }; + + createTestSuite(); + }); +} diff --git a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/create.ts new file mode 100644 index 0000000000000..70884ba6c968b --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/create.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { USERS, User, ExpectedResponse } from '../../../common/lib'; +import { FtrProviderContext } from '../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('POST /api/saved_objects_tagging/tags/create', () => { + beforeEach(async () => { + await esArchiver.load('rbac_tags'); + }); + + afterEach(async () => { + await esArchiver.unload('rbac_tags'); + }); + + const responses: Record = { + authorized: { + httpCode: 200, + expectResponse: ({ body }) => { + expect(body).to.eql({ + tag: { + id: body.tag.id, + name: 'My new tag', + description: 'I just created that', + color: '#009000', + }, + }); + }, + }, + unauthorized: { + httpCode: 403, + expectResponse: ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: 'Unable to create tag', + }); + }, + }, + }; + + const expectedResults: Record = { + authorized: [ + USERS.SUPERUSER, + USERS.DEFAULT_SPACE_SO_MANAGEMENT_WRITE_USER, + USERS.DEFAULT_SPACE_SO_TAGGING_WRITE_USER, + ], + unauthorized: [ + USERS.DEFAULT_SPACE_READ_USER, + USERS.DEFAULT_SPACE_SO_TAGGING_READ_USER, + USERS.DEFAULT_SPACE_DASHBOARD_READ_USER, + USERS.DEFAULT_SPACE_VISUALIZE_READ_USER, + USERS.DEFAULT_SPACE_ADVANCED_SETTINGS_READ_USER, + USERS.NOT_A_KIBANA_USER, + ], + }; + + const createUserTest = ( + { username, password, description }: User, + { httpCode, expectResponse }: ExpectedResponse + ) => { + it(`returns expected ${httpCode} response for ${description ?? username}`, async () => { + await supertest + .post(`/api/saved_objects_tagging/tags/create`) + .send({ + name: 'My new tag', + description: 'I just created that', + color: '#009000', + }) + .auth(username, password) + .expect(httpCode) + .then(expectResponse); + }); + }; + + const createTestSuite = () => { + Object.entries(expectedResults).forEach(([responseId, users]) => { + const response: ExpectedResponse = responses[responseId]; + users.forEach((user) => { + createUserTest(user, response); + }); + }); + }; + + createTestSuite(); + }); +} diff --git a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/delete.ts b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/delete.ts new file mode 100644 index 0000000000000..64f120fd75629 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/delete.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { USERS, User, ExpectedResponse } from '../../../common/lib'; +import { FtrProviderContext } from '../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('DELETE /api/saved_objects_tagging/tags/{id}', () => { + beforeEach(async () => { + await esArchiver.load('rbac_tags'); + }); + + afterEach(async () => { + await esArchiver.unload('rbac_tags'); + }); + + const responses: Record = { + authorized: { + httpCode: 200, + expectResponse: ({ body }) => { + expect(body).to.eql({}); + }, + }, + unauthorized: { + httpCode: 403, + expectResponse: ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: 'Unable to delete tag', + }); + }, + }, + }; + + const expectedResults: Record = { + authorized: [ + USERS.SUPERUSER, + USERS.DEFAULT_SPACE_SO_MANAGEMENT_WRITE_USER, + USERS.DEFAULT_SPACE_SO_TAGGING_WRITE_USER, + ], + unauthorized: [ + USERS.DEFAULT_SPACE_READ_USER, + USERS.DEFAULT_SPACE_SO_TAGGING_READ_USER, + USERS.DEFAULT_SPACE_DASHBOARD_READ_USER, + USERS.DEFAULT_SPACE_VISUALIZE_READ_USER, + USERS.DEFAULT_SPACE_ADVANCED_SETTINGS_READ_USER, + USERS.NOT_A_KIBANA_USER, + ], + }; + + const createUserTest = ( + { username, password, description }: User, + { httpCode, expectResponse }: ExpectedResponse + ) => { + it(`returns expected ${httpCode} response for ${description ?? username}`, async () => { + await supertest + .delete(`/api/saved_objects_tagging/tags/default-space-tag-1`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse); + }); + }; + + const createTestSuite = () => { + Object.entries(expectedResults).forEach(([responseId, users]) => { + const response: ExpectedResponse = responses[responseId]; + users.forEach((user) => { + createUserTest(user, response); + }); + }); + }; + + createTestSuite(); + }); +} diff --git a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/get.ts b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/get.ts new file mode 100644 index 0000000000000..1a354bbbcb660 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/get.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { USERS, User, ExpectedResponse } from '../../../common/lib'; +import { FtrProviderContext } from '../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('GET /api/saved_objects_tagging/tags/{id}', () => { + before(async () => { + await esArchiver.load('rbac_tags'); + }); + + after(async () => { + await esArchiver.unload('rbac_tags'); + }); + + const responses: Record = { + authorized: { + httpCode: 200, + expectResponse: ({ body }) => { + expect(body).to.eql({ + tag: { + id: 'default-space-tag-1', + name: 'tag-1', + description: 'Tag 1 in default space', + color: '#FF00FF', + }, + }); + }, + }, + unauthorized: { + httpCode: 403, + expectResponse: ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: 'Unable to get tag', + }); + }, + }, + }; + + const expectedResults: Record = { + authorized: [ + USERS.SUPERUSER, + USERS.DEFAULT_SPACE_READ_USER, + USERS.DEFAULT_SPACE_SO_MANAGEMENT_WRITE_USER, + USERS.DEFAULT_SPACE_SO_TAGGING_READ_USER, + USERS.DEFAULT_SPACE_SO_TAGGING_WRITE_USER, + USERS.DEFAULT_SPACE_DASHBOARD_READ_USER, + USERS.DEFAULT_SPACE_VISUALIZE_READ_USER, + ], + unauthorized: [USERS.NOT_A_KIBANA_USER, USERS.DEFAULT_SPACE_ADVANCED_SETTINGS_READ_USER], + }; + + const createUserTest = ( + { username, password, description }: User, + { httpCode, expectResponse }: ExpectedResponse + ) => { + it(`returns expected ${httpCode} response for ${description ?? username}`, async () => { + await supertest + .get(`/api/saved_objects_tagging/tags/default-space-tag-1`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse); + }); + }; + + const createTestSuite = () => { + Object.entries(expectedResults).forEach(([responseId, users]) => { + const response: ExpectedResponse = responses[responseId]; + users.forEach((user) => { + createUserTest(user, response); + }); + }); + }; + + createTestSuite(); + }); +} diff --git a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/get_all.ts b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/get_all.ts new file mode 100644 index 0000000000000..61b859cf81992 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/get_all.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { USERS, User, ExpectedResponse } from '../../../common/lib'; +import { FtrProviderContext } from '../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('GET /api/saved_objects_tagging/tags', () => { + before(async () => { + await esArchiver.load('rbac_tags'); + }); + + after(async () => { + await esArchiver.unload('rbac_tags'); + }); + + const responses: Record = { + authorized: { + httpCode: 200, + expectResponse: ({ body }) => { + expect(body).to.eql({ + tags: [ + { + id: 'default-space-tag-1', + name: 'tag-1', + description: 'Tag 1 in default space', + color: '#FF00FF', + }, + { + id: 'default-space-tag-2', + name: 'tag-2', + description: 'Tag 2 in default space', + color: '#77CC11', + }, + ], + }); + }, + }, + unauthorized: { + httpCode: 200, + expectResponse: ({ body }) => { + expect(body).to.eql({ + tags: [], + }); + }, + }, + }; + + const expectedResults: Record = { + authorized: [ + USERS.SUPERUSER, + USERS.DEFAULT_SPACE_READ_USER, + USERS.DEFAULT_SPACE_SO_MANAGEMENT_WRITE_USER, + USERS.DEFAULT_SPACE_SO_TAGGING_READ_USER, + USERS.DEFAULT_SPACE_SO_TAGGING_WRITE_USER, + USERS.DEFAULT_SPACE_DASHBOARD_READ_USER, + USERS.DEFAULT_SPACE_VISUALIZE_READ_USER, + ], + unauthorized: [USERS.NOT_A_KIBANA_USER, USERS.DEFAULT_SPACE_ADVANCED_SETTINGS_READ_USER], + }; + + const createUserTest = ( + { username, password, description }: User, + { httpCode, expectResponse }: ExpectedResponse + ) => { + it(`returns expected ${httpCode} response for ${description ?? username}`, async () => { + await supertest + .get(`/api/saved_objects_tagging/tags`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse); + }); + }; + + const createTestSuite = () => { + Object.entries(expectedResults).forEach(([responseId, users]) => { + const response: ExpectedResponse = responses[responseId]; + users.forEach((user) => { + createUserTest(user, response); + }); + }); + }; + + createTestSuite(); + }); +} diff --git a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/index.ts b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/index.ts new file mode 100644 index 0000000000000..5f3d1cf854f82 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../services'; +import { createUsersAndRoles } from '../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, loadTestFile }: FtrProviderContext) { + describe('saved objects tagging API - security and spaces integration', function () { + this.tags('ciGroup10'); + + before(async () => { + await createUsersAndRoles(getService); + }); + + loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./get_all')); + loadTestFile(require.resolve('./create')); + loadTestFile(require.resolve('./update')); + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./_find')); + }); +} diff --git a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/update.ts b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/update.ts new file mode 100644 index 0000000000000..77bf9d7ca3287 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/update.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { USERS, User, ExpectedResponse } from '../../../common/lib'; +import { FtrProviderContext } from '../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('POST /api/saved_objects_tagging/tags/{id}', () => { + beforeEach(async () => { + await esArchiver.load('rbac_tags'); + }); + + afterEach(async () => { + await esArchiver.unload('rbac_tags'); + }); + + const responses: Record = { + authorized: { + httpCode: 200, + expectResponse: ({ body }) => { + expect(body).to.eql({ + tag: { + id: body.tag.id, + name: 'Updated title', + description: 'I just updated that', + color: '#009000', + }, + }); + }, + }, + unauthorized: { + httpCode: 403, + expectResponse: ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: 'Unable to update tag', + }); + }, + }, + }; + + const expectedResults: Record = { + authorized: [ + USERS.SUPERUSER, + USERS.DEFAULT_SPACE_SO_MANAGEMENT_WRITE_USER, + USERS.DEFAULT_SPACE_SO_TAGGING_WRITE_USER, + ], + unauthorized: [ + USERS.DEFAULT_SPACE_READ_USER, + USERS.DEFAULT_SPACE_SO_TAGGING_READ_USER, + USERS.DEFAULT_SPACE_DASHBOARD_READ_USER, + USERS.DEFAULT_SPACE_VISUALIZE_READ_USER, + USERS.DEFAULT_SPACE_ADVANCED_SETTINGS_READ_USER, + USERS.NOT_A_KIBANA_USER, + ], + }; + + const createUserTest = ( + { username, password, description }: User, + { httpCode, expectResponse }: ExpectedResponse + ) => { + it(`returns expected ${httpCode} response for ${description ?? username}`, async () => { + await supertest + .post(`/api/saved_objects_tagging/tags/default-space-tag-1`) + .send({ + name: 'Updated title', + description: 'I just updated that', + color: '#009000', + }) + .auth(username, password) + .expect(httpCode) + .then(expectResponse); + }); + }; + + const createTestSuite = () => { + Object.entries(expectedResults).forEach(([responseId, users]) => { + const response: ExpectedResponse = responses[responseId]; + users.forEach((user) => { + createUserTest(user, response); + }); + }); + }; + + createTestSuite(); + }); +} diff --git a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/config.ts b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/config.ts new file mode 100644 index 0000000000000..dce6934ea83cf --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/config.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import path from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { services } from './services'; + +// eslint-disable-next-line import/no-default-export +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const apiIntegrationConfig = await readConfigFile( + require.resolve('../../../api_integration/config.ts') + ); + + return { + testFiles: [require.resolve('./apis')], + servers: apiIntegrationConfig.get('servers'), + services, + junit: { + reportName: + 'X-Pack Saved Object Tagging API Integration Tests - Security and Spaces integration', + }, + esArchiver: { + directory: path.resolve(__dirname, '..', '..', 'common', 'fixtures', 'es_archiver'), + }, + esTestCluster: { + ...apiIntegrationConfig.get('esTestCluster'), + license: 'trial', + }, + kbnTestServer: { + ...apiIntegrationConfig.get('kbnTestServer'), + serverArgs: [ + ...apiIntegrationConfig.get('kbnTestServer.serverArgs'), + '--server.xsrf.disableProtection=true', + ], + }, + }; +} diff --git a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/services.ts b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/services.ts new file mode 100644 index 0000000000000..cf1c43820e2d6 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/services.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; +import { services as apiIntegrationServices } from '../../../api_integration/services'; + +export const services = { + ...apiIntegrationServices, +}; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/create.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/create.ts new file mode 100644 index 0000000000000..bd7fa7538703c --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/create.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + + describe('POST /api/saved_objects_tagging/tags/create', () => { + beforeEach(async () => { + await esArchiver.load('functional_base'); + }); + + afterEach(async () => { + await esArchiver.unload('functional_base'); + }); + + it('should create the tag when validation succeed', async () => { + const createResponse = await supertest + .post(`/api/saved_objects_tagging/tags/create`) + .send({ + name: 'my new tag', + description: 'some desc', + color: '#772299', + }) + .expect(200); + + const newTagId = createResponse.body.tag.id; + expect(createResponse.body).to.eql({ + tag: { + id: newTagId, + name: 'my new tag', + description: 'some desc', + color: '#772299', + }, + }); + + await supertest + .get(`/api/saved_objects_tagging/tags/${newTagId}`) + .expect(200) + .then(({ body }) => { + expect(body).to.eql({ + tag: { + id: newTagId, + name: 'my new tag', + description: 'some desc', + color: '#772299', + }, + }); + }); + }); + + it('should return an error with details when validation failed', async () => { + await supertest + .post(`/api/saved_objects_tagging/tags/create`) + .send({ + name: 'Inv%li& t@g n*me', + description: 'some desc', + color: 'this is not a valid color', + }) + .expect(400) + .then(({ body }) => { + expect(body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Error validating tag attributes', + attributes: { + valid: false, + warnings: [], + errors: { + name: 'Tag name can only include a-z, 0-9, _, -,:.', + color: 'Tag color must be a valid hex color', + }, + }, + }); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts new file mode 100644 index 0000000000000..deed7a36f42c5 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + + describe('DELETE /api/saved_objects_tagging/tags/{id}', () => { + beforeEach(async () => { + await esArchiver.load('delete_with_references'); + }); + + afterEach(async () => { + await esArchiver.unload('delete_with_references'); + }); + + it('should delete the tag', async () => { + await supertest.get(`/api/saved_objects_tagging/tags/tag-1`).expect(200); + + await supertest.delete(`/api/saved_objects_tagging/tags/tag-1`).expect(200); + + await supertest.get(`/api/saved_objects_tagging/tags/tag-1`).expect(404); + }); + + it('should remove references to the deleted tag', async () => { + await supertest.get(`/api/saved_objects_tagging/tags/tag-1`).expect(200); + + await supertest.delete(`/api/saved_objects_tagging/tags/tag-1`).expect(200); + + const bulkResponse = await supertest + .post(`/api/saved_objects/_bulk_get`) + .send([ + { type: 'visualization', id: 'ref-to-tag-1' }, + { type: 'visualization', id: 'ref-to-tag-1-and-tag-2' }, + ]) + .expect(200); + + const [vis1, vis2] = bulkResponse.body.saved_objects; + + expect(vis1.references).to.eql([]); + expect(vis2.references).to.eql([{ type: 'tag', id: 'tag-2', name: 'tag-2' }]); + }); + }); +} diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/index.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/index.ts new file mode 100644 index 0000000000000..d78513ca06206 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ loadTestFile }: FtrProviderContext) { + describe('saved objects tagging API', function () { + this.tags('ciGroup10'); + + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./create')); + loadTestFile(require.resolve('./update')); + }); +} diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/update.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/update.ts new file mode 100644 index 0000000000000..7b4298607c666 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/update.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + + describe('POST /api/saved_objects_tagging/tags/{id}', () => { + beforeEach(async () => { + await esArchiver.load('functional_base'); + }); + + afterEach(async () => { + await esArchiver.unload('functional_base'); + }); + + it('should update the tag when validation succeed', async () => { + await supertest + .post(`/api/saved_objects_tagging/tags/tag-1`) + .send({ + name: 'updated name', + description: 'updated desc', + color: '#123456', + }) + .expect(200) + .then(({ body }) => { + expect(body).to.eql({ + tag: { + id: 'tag-1', + name: 'updated name', + description: 'updated desc', + color: '#123456', + }, + }); + }); + + await supertest + .get(`/api/saved_objects_tagging/tags/tag-1`) + .expect(200) + .then(({ body }) => { + expect(body).to.eql({ + tag: { + id: 'tag-1', + name: 'updated name', + description: 'updated desc', + color: '#123456', + }, + }); + }); + }); + + it('should return a 404 when trying to update a non existing tag', async () => { + await supertest + .post(`/api/saved_objects_tagging/tags/unknown-tag-id`) + .send({ + name: 'updated name', + description: 'updated desc', + color: '#123456', + }) + .expect(404) + .then(({ body }) => { + expect(body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Saved object [tag/unknown-tag-id] not found', + }); + }); + }); + + it('should return an error with details when validation failed', async () => { + await supertest + .post(`/api/saved_objects_tagging/tags/tag-1`) + .send({ + name: 'Inv%li& t@g n*me', + description: 'some desc', + color: 'this is not a valid color', + }) + .expect(400) + .then(({ body }) => { + expect(body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Error validating tag attributes', + attributes: { + valid: false, + warnings: [], + errors: { + name: 'Tag name can only include a-z, 0-9, _, -,:.', + color: 'Tag color must be a valid hex color', + }, + }, + }); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/config.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_api/config.ts new file mode 100644 index 0000000000000..7256c9e8cd9a5 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_api/config.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import path from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { services } from './services'; + +// eslint-disable-next-line import/no-default-export +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const apiIntegrationConfig = await readConfigFile( + require.resolve('../../../api_integration/config.ts') + ); + + return { + testFiles: [require.resolve('./apis')], + servers: apiIntegrationConfig.get('servers'), + services, + junit: { + reportName: 'X-Pack Saved Object Tagging API Integration Tests', + }, + esArchiver: { + directory: path.resolve(__dirname, '..', '..', 'common', 'fixtures', 'es_archiver'), + }, + esTestCluster: { + ...apiIntegrationConfig.get('esTestCluster'), + license: 'trial', + }, + kbnTestServer: { + ...apiIntegrationConfig.get('kbnTestServer'), + serverArgs: [ + ...apiIntegrationConfig.get('kbnTestServer.serverArgs'), + '--server.xsrf.disableProtection=true', + ], + }, + }; +} diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/services.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_api/services.ts new file mode 100644 index 0000000000000..cf1c43820e2d6 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_api/services.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; +import { services as apiIntegrationServices } from '../../../api_integration/services'; + +export const services = { + ...apiIntegrationServices, +}; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/dashboard/data.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/dashboard/data.json new file mode 100644 index 0000000000000..2f1f4c1c8d894 --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/dashboard/data.json @@ -0,0 +1,357 @@ +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana", + "source": { + "space": { + "_reserved": true, + "description": "This is the default space", + "name": "Default Space" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-1", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-1", + "description": "My first tag!", + "color": "#FF00FF" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-2", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-2", + "description": "Another awesome tag", + "color": "#11FF22" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-3", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-3", + "description": "Last but not least", + "color": "#AA0077" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "config:6.3.0", + "index": ".kibana", + "source": { + "config": { + "buildNum": 8467, + "defaultIndex": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c" + }, + "references": [ + ], + "type": "config", + "updated_at": "2018-04-11T20:43:55.434Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:61c58ad0-3dd3-11e8-b2b9-5d5dc1715159", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 4 with real data (tag-1)", + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"gridData\":{\"w\":24,\"h\":15,\"x\":0,\"y\":0,\"i\":\"1\"},\"version\":\"7.3.0\",\"panelIndex\":\"1\",\"embeddableConfig\":{},\"panelRefName\":\"panel_0\"},{\"gridData\":{\"w\":24,\"h\":15,\"x\":24,\"y\":0,\"i\":\"2\"},\"version\":\"7.3.0\",\"panelIndex\":\"2\",\"embeddableConfig\":{},\"panelRefName\":\"panel_1\"}]", + "refreshInterval": { + "display": "Off", + "pause": false, + "value": 0 + }, + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + }, + { + "id": "50643b60-3dd3-11e8-b2b9-5d5dc1715159", + "name": "panel_0", + "type": "visualization" + }, + { + "id": "a16d1990-3dca-11e8-8660-4d65aa086b3c", + "name": "panel_1", + "type": "search" + }, + { + "id": "tag-1", + "name": "tag-1-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:ref-to-tag-2", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 1 (tag-2)", + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "tag-2", + "name": "tag-2-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:ref-to-tag-3", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 2 (tag-3)", + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "tag-3", + "name": "tag-3-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:ref-to-tag-1-and-tag-3", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 3 (tag-1 and tag-3)", + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "tag-1", + "name": "tag-1-ref", + "type": "tag" + }, + { + "id": "tag-3", + "name": "tag-3-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "index-pattern:a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "index": ".kibana", + "source": { + "index-pattern": { + "fieldFormatMap": "{\"weightLbs\":{\"id\":\"number\",\"params\":{\"pattern\":\"0,0.0\"}},\"is_dog\":{\"id\":\"boolean\"},\"isDog\":{\"id\":\"boolean\"}}", + "fields": "[{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"animal\",\"type\":\"string\",\"count\":3,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"animal.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"name\",\"type\":\"string\",\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"name.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"sound\",\"type\":\"string\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"sound.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"weightLbs\",\"type\":\"number\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"isDog\",\"type\":\"boolean\",\"count\":0,\"scripted\":true,\"script\":\"return doc['animal.keyword'].value == 'dog'\",\"lang\":\"painless\",\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false}]", + "timeFieldName": "@timestamp", + "title": "animals-*" + }, + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "references": [ + ], + "type": "index-pattern", + "updated_at": "2018-05-09T20:55:44.314Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:50643b60-3dd3-11e8-b2b9-5d5dc1715159", + "index": ".kibana", + "source": { + "migrationVersion": { + "visualization": "7.8.0" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2018-04-17T15:06:34.195Z", + "visualization": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "Rendering Test: animal sounds pie", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Rendering Test: animal sounds pie\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"sound.keyword\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "search:a16d1990-3dca-11e8-8660-4d65aa086b3c", + "index": ".kibana", + "source": { + "migrationVersion": { + "search": "7.4.0" + }, + "references": [ + { + "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "search": { + "columns": [ + "animal", + "isDog", + "name", + "sound", + "weightLbs" + ], + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"lucene\",\"query\":\"weightLbs:>40\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "sort": [ + [ + "weightLbs", + "desc" + ] + ], + "title": "animal weights", + "version": 1 + }, + "type": "search", + "updated_at": "2018-04-11T20:55:26.317Z" + } + } +} diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/dashboard/mappings.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/dashboard/mappings.json new file mode 100644 index 0000000000000..4156cc9e2373d --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/dashboard/mappings.json @@ -0,0 +1,528 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": ".kibana", + "mappings": { + "_meta": { + "migrationMappingPropertyHashes": { + "application_usage_totals": "c897e4310c5f24b07caaff3db53ae2c1", + "application_usage_transactional": "965839e75f809fefe04f92dc4d99722a", + "config": "ae24d22d5986d04124cc6568f771066f", + "dashboard": "d00f614b29a80360e1190193fd333bab", + "index-pattern": "66eccb05066c5a89924f48a9e9736499", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "181661168bbadd1eff5902361e2a0d5c", + "telemetry": "36a616f7026dfa617d6655df850fe16d", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "tsvb-validation-telemetry": "3a37ef6c8700ae6fc97d5c7da00e9215", + "type": "2f4316de49999235636386fe51dc06c1", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "url": "b675c3be8d76ecf029294d51dc7ec65d", + "visualization": "52d7a13ad68a150c4525b292d23e12cc" + } + }, + "dynamic": "strict", + "properties": { + "application_usage_totals": { + "properties": { + "appId": { + "type": "keyword" + }, + "minutesOnScreen": { + "type": "float" + }, + "numberOfClicks": { + "type": "long" + } + } + }, + "application_usage_transactional": { + "properties": { + "appId": { + "type": "keyword" + }, + "minutesOnScreen": { + "type": "float" + }, + "numberOfClicks": { + "type": "long" + }, + "timestamp": { + "type": "date" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "notifications:lifetime:banner": { + "type": "long" + }, + "notifications:lifetime:error": { + "type": "long" + }, + "notifications:lifetime:info": { + "type": "long" + }, + "notifications:lifetime:warning": { + "type": "long" + }, + "xPackMonitoring:showBanner": { + "type": "boolean" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "dashboard": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "index-pattern": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "search": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "visualization": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "query": { + "properties": { + "description": { + "type": "text" + }, + "filters": { + "enabled": false, + "type": "object" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "index": false, + "type": "keyword" + } + } + }, + "timefilter": { + "enabled": false, + "type": "object" + }, + "title": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "sample-data-telemetry": { + "properties": { + "installCount": { + "type": "long" + }, + "unInstallCount": { + "type": "long" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "tag": { + "properties": { + "name": { + "type": "text" + }, + "description": { + "type": "text" + }, + "color": { + "type": "text" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "telemetry": { + "properties": { + "allowChangingOptInStatus": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "lastReported": { + "type": "date" + }, + "lastVersionChecked": { + "type": "keyword" + }, + "reportFailureCount": { + "type": "integer" + }, + "reportFailureVersion": { + "type": "keyword" + }, + "sendUsageFrom": { + "type": "keyword" + }, + "userHasSeenNotice": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "tsvb-validation-telemetry": { + "properties": { + "failedRequests": { + "type": "long" + } + } + }, + "type": { + "type": "keyword" + }, + "ui-metric": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchRefName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/delete_with_references/data.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/delete_with_references/data.json new file mode 100644 index 0000000000000..5b986c3061a81 --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/delete_with_references/data.json @@ -0,0 +1,164 @@ +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana", + "source": { + "space": { + "_reserved": true, + "description": "This is the default space", + "name": "Default Space" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-1", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-1", + "description": "Tag 1", + "color": "#FF00FF" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-2", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-2", + "description": "Tag 2", + "color": "#FFFFFF" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-3", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-3", + "description": "Tag 3", + "color": "#000000" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:ref-to-tag-1", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Vis with ref to tag-1", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [ + { + "type": "tag", + "id": "tag-1", + "name": "tag-1" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:ref-to-tag-1-and-tag-2", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Vis with ref to tag-1 and tag-2", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [ + { + "type": "tag", + "id": "tag-1", + "name": "tag-1" + }, + { + "type": "tag", + "id": "tag-2", + "name": "tag-2" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:ref-to-tag-2", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Vis with ref to tag-2", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [ + { + "type": "tag", + "id": "tag-2", + "name": "tag-2" + } + ] + } + } +} + diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/delete_with_references/mappings.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/delete_with_references/mappings.json new file mode 100644 index 0000000000000..4ea82eb30e06a --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/delete_with_references/mappings.json @@ -0,0 +1,225 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": ".kibana", + "mappings": { + "dynamic": "strict", + "properties": { + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "dashboard": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "tag": { + "properties": { + "name": { + "type": "text" + }, + "description": { + "type": "text" + }, + "color": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/functional_base/data.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/functional_base/data.json new file mode 100644 index 0000000000000..9d791a8b65998 --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/functional_base/data.json @@ -0,0 +1,200 @@ +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana", + "source": { + "space": { + "_reserved": true, + "description": "This is the default space", + "name": "Default Space" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-1", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-1", + "description": "My first tag!", + "color": "#FF00FF" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-2", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-2", + "description": "Another awesome tag", + "color": "#FFFFFF" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-3", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-3", + "description": "Last but not least", + "color": "#000000" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-with-whitespace", + "index": ".kibana", + "source": { + "tag": { + "name": "tag with whitespace", + "description": "I have some whitespaces in my name", + "color": "#FC7D4E" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:another-tag", + "index": ".kibana", + "source": { + "tag": { + "name": "my-favorite-tag", + "description": "This one is really the best", + "color": "#123456" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:ref-to-tag-1", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Vis with ref to tag-1", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [ + { + "type": "tag", + "id": "tag-1", + "name": "tag-1" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:ref-to-tag-1-and-tag-2", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Vis with ref to tag-1 and tag-2", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [ + { + "type": "tag", + "id": "tag-1", + "name": "tag-1" + }, + { + "type": "tag", + "id": "tag-2", + "name": "tag-2" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:ref-to-tag-2", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Vis with ref to tag-2", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [ + { + "type": "tag", + "id": "tag-2", + "name": "tag-2" + } + ] + } + } +} + diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/functional_base/mappings.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/functional_base/mappings.json new file mode 100644 index 0000000000000..4ea82eb30e06a --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/functional_base/mappings.json @@ -0,0 +1,225 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": ".kibana", + "mappings": { + "dynamic": "strict", + "properties": { + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "dashboard": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "tag": { + "properties": { + "name": { + "type": "text" + }, + "description": { + "type": "text" + }, + "color": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/logstash_functional/data.json.gz b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/logstash_functional/data.json.gz new file mode 100644 index 0000000000000..a4f889da61128 Binary files /dev/null and b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/logstash_functional/data.json.gz differ diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/logstash_functional/mappings.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/logstash_functional/mappings.json new file mode 100644 index 0000000000000..010abff9cf6a9 --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/logstash_functional/mappings.json @@ -0,0 +1,1118 @@ +{ + "type": "index", + "value": { + "index": "logstash-2015.09.22", + "mappings": { + "dynamic_templates": [ + { + "string_fields": { + "mapping": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "match": "*", + "match_mapping_type": "string" + } + } + ], + "properties": { + "@message": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "@tags": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "@timestamp": { + "type": "date" + }, + "agent": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "bytes": { + "type": "long" + }, + "clientip": { + "type": "ip" + }, + "extension": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "geo": { + "properties": { + "coordinates": { + "type": "geo_point" + }, + "dest": { + "type": "keyword" + }, + "src": { + "type": "keyword" + }, + "srcdest": { + "type": "keyword" + } + } + }, + "headings": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "host": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "id": { + "type": "integer" + }, + "index": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "ip": { + "type": "ip" + }, + "links": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "machine": { + "properties": { + "os": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "ram": { + "type": "long" + } + } + }, + "memory": { + "type": "double" + }, + "meta": { + "properties": { + "char": { + "type": "keyword" + }, + "related": { + "type": "text" + }, + "user": { + "properties": { + "firstname": { + "type": "text" + }, + "lastname": { + "type": "integer" + } + } + } + } + }, + "nestedField": { + "type": "nested", + "properties": { + "child": { + "type": "keyword" + } + } + }, + "phpmemory": { + "type": "long" + }, + "referer": { + "type": "keyword" + }, + "relatedContent": { + "properties": { + "article:modified_time": { + "type": "date" + }, + "article:published_time": { + "type": "date" + }, + "article:section": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "article:tag": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:description": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:image": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:image:height": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:image:width": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:site_name": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:title": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:type": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:url": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:card": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:description": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:image": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:site": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:title": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "url": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "request": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "response": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "spaces": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "type": { + "type": "keyword" + }, + "url": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "utc_time": { + "type": "date" + }, + "xss": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "settings": { + "index": { + "analysis": { + "analyzer": { + "url": { + "max_token_length": "1000", + "tokenizer": "uax_url_email", + "type": "standard" + } + } + }, + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} + +{ + "type": "index", + "value": { + "index": "logstash-2015.09.20", + "mappings": { + "dynamic_templates": [ + { + "string_fields": { + "mapping": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "match": "*", + "match_mapping_type": "string" + } + } + ], + "properties": { + "@message": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "@tags": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "@timestamp": { + "type": "date" + }, + "agent": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "bytes": { + "type": "long" + }, + "clientip": { + "type": "ip" + }, + "extension": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "geo": { + "properties": { + "coordinates": { + "type": "geo_point" + }, + "dest": { + "type": "keyword" + }, + "src": { + "type": "keyword" + }, + "srcdest": { + "type": "keyword" + } + } + }, + "headings": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "host": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "id": { + "type": "integer" + }, + "index": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "ip": { + "type": "ip" + }, + "links": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "machine": { + "properties": { + "os": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "ram": { + "type": "long" + } + } + }, + "memory": { + "type": "double" + }, + "meta": { + "properties": { + "char": { + "type": "keyword" + }, + "related": { + "type": "text" + }, + "user": { + "properties": { + "firstname": { + "type": "text" + }, + "lastname": { + "type": "integer" + } + } + } + } + }, + "nestedField": { + "type": "nested", + "properties": { + "child": { + "type": "keyword" + } + } + }, + "phpmemory": { + "type": "long" + }, + "referer": { + "type": "keyword" + }, + "relatedContent": { + "properties": { + "article:modified_time": { + "type": "date" + }, + "article:published_time": { + "type": "date" + }, + "article:section": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "article:tag": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:description": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:image": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:image:height": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:image:width": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:site_name": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:title": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:type": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:url": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:card": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:description": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:image": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:site": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:title": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "url": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "request": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "response": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "spaces": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "type": { + "type": "keyword" + }, + "url": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "utc_time": { + "type": "date" + }, + "xss": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "settings": { + "index": { + "analysis": { + "analyzer": { + "url": { + "max_token_length": "1000", + "tokenizer": "uax_url_email", + "type": "standard" + } + } + }, + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} + +{ + "type": "index", + "value": { + "index": "logstash-2015.09.21", + "mappings": { + "dynamic_templates": [ + { + "string_fields": { + "mapping": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "match": "*", + "match_mapping_type": "string" + } + } + ], + "properties": { + "@message": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "@tags": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "@timestamp": { + "type": "date" + }, + "agent": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "bytes": { + "type": "long" + }, + "clientip": { + "type": "ip" + }, + "extension": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "geo": { + "properties": { + "coordinates": { + "type": "geo_point" + }, + "dest": { + "type": "keyword" + }, + "src": { + "type": "keyword" + }, + "srcdest": { + "type": "keyword" + } + } + }, + "headings": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "host": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "id": { + "type": "integer" + }, + "index": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "ip": { + "type": "ip" + }, + "links": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "machine": { + "properties": { + "os": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "ram": { + "type": "long" + } + } + }, + "memory": { + "type": "double" + }, + "meta": { + "properties": { + "char": { + "type": "keyword" + }, + "related": { + "type": "text" + }, + "user": { + "properties": { + "firstname": { + "type": "text" + }, + "lastname": { + "type": "integer" + } + } + } + } + }, + "nestedField": { + "type": "nested", + "properties": { + "child": { + "type": "keyword" + } + } + }, + "phpmemory": { + "type": "long" + }, + "referer": { + "type": "keyword" + }, + "relatedContent": { + "properties": { + "article:modified_time": { + "type": "date" + }, + "article:published_time": { + "type": "date" + }, + "article:section": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "article:tag": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:description": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:image": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:image:height": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:image:width": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:site_name": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:title": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:type": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "og:url": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:card": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:description": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:image": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:site": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "twitter:title": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "url": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "request": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "response": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "spaces": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "type": { + "type": "keyword" + }, + "url": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + }, + "utc_time": { + "type": "date" + }, + "xss": { + "fields": { + "raw": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "settings": { + "index": { + "analysis": { + "analyzer": { + "url": { + "max_token_length": "1000", + "tokenizer": "uax_url_email", + "type": "standard" + } + } + }, + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/rbac_tags/data.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/rbac_tags/data.json new file mode 100644 index 0000000000000..0027754ff1e26 --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/rbac_tags/data.json @@ -0,0 +1,106 @@ +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana", + "source": { + "space": { + "_reserved": true, + "description": "This is the default space", + "name": "Default Space" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "space:space_1", + "index": ".kibana", + "source": { + "space": { + "description": "This is the first test space", + "name": "Space 1" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "space:space_2", + "index": ".kibana", + "source": { + "space": { + "description": "This is the second test space", + "name": "Space 2" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:default-space-tag-1", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-1", + "description": "Tag 1 in default space", + "color": "#FF00FF" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:default-space-tag-2", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-2", + "description": "Tag 2 in default space", + "color": "#77CC11" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "space_1:tag:space_1-tag-3", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-3", + "description": "Tag 3 in space 1", + "color": "#117744" + }, + "type": "tag", + "namespace": "space_1", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/rbac_tags/mappings.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/rbac_tags/mappings.json new file mode 100644 index 0000000000000..cd0d076258721 --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/rbac_tags/mappings.json @@ -0,0 +1,182 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": ".kibana", + "mappings": { + "dynamic": "strict", + "properties": { + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "dashboard": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "tag": { + "properties": { + "name": { + "type": "text" + }, + "description": { + "type": "text" + }, + "color": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/so_management/data.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/so_management/data.json new file mode 100644 index 0000000000000..f20435d7afc99 --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/so_management/data.json @@ -0,0 +1,200 @@ +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana", + "source": { + "space": { + "_reserved": true, + "description": "This is the default space", + "name": "Default Space" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-1", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-1", + "description": "My first tag!", + "color": "#FF00FF" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-2", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-2", + "description": "Another awesome tag", + "color": "#11FF22" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-3", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-3", + "description": "Last but not least", + "color": "#AA0077" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "index-pattern:logstash-*", + "index": ".kibana", + "source": { + "index-pattern": { + "fieldFormatMap": "{\"bytes\":{\"id\":\"bytes\"}}", + "fields": "[{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false}]", + "timeFieldName": "@timestamp", + "title": "logstash-*" + }, + "type": "index-pattern" + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-1", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 1 (tag-1)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-1", + "name": "tag-1-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-2", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 2 (tag-2)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-2", + "name": "tag-2-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-3", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 3 (tag-1 + tag-3)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-1", + "name": "tag-1-ref" + }, + { "type": "tag", + "id": "tag-3", + "name": "tag-3-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-4", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 4 (tag-2)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-2", + "name": "tag-2-ref" + } + ] + } + } +} diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/so_management/mappings.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/so_management/mappings.json new file mode 100644 index 0000000000000..4ea82eb30e06a --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/so_management/mappings.json @@ -0,0 +1,225 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": ".kibana", + "mappings": { + "dynamic": "strict", + "properties": { + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "dashboard": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "tag": { + "properties": { + "name": { + "type": "text" + }, + "description": { + "type": "text" + }, + "color": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/visualize/data.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/visualize/data.json new file mode 100644 index 0000000000000..5b504f085b1a0 --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/visualize/data.json @@ -0,0 +1,173 @@ +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana", + "source": { + "space": { + "_reserved": true, + "description": "This is the default space", + "name": "Default Space" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-1", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-1", + "description": "My first tag!", + "color": "#FF00FF" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-2", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-2", + "description": "Another awesome tag", + "color": "#11FF22" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-3", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-3", + "description": "Last but not least", + "color": "#AA0077" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "index-pattern:logstash-*", + "index": ".kibana", + "source": { + "index-pattern": { + "fieldFormatMap": "{\"bytes\":{\"id\":\"bytes\"}}", + "fields": "[{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false}]", + "timeFieldName": "@timestamp", + "title": "logstash-*" + }, + "type": "index-pattern" + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-1", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 1 (tag-1)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-1", + "name": "tag-1-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-2", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 2 (tag-2)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-2", + "name": "tag-2-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-3", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 3 (tag-1 + tag-3)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-1", + "name": "tag-1-ref" + }, + { "type": "tag", + "id": "tag-3", + "name": "tag-3-ref" + } + ] + } + } +} diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/visualize/mappings.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/visualize/mappings.json new file mode 100644 index 0000000000000..4ea82eb30e06a --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/visualize/mappings.json @@ -0,0 +1,225 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": ".kibana", + "mappings": { + "dynamic": "strict", + "properties": { + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "dashboard": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "tag": { + "properties": { + "name": { + "type": "text" + }, + "description": { + "type": "text" + }, + "color": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/saved_object_tagging/common/lib/authentication.ts b/x-pack/test/saved_object_tagging/common/lib/authentication.ts new file mode 100644 index 0000000000000..c318755bedcdd --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/lib/authentication.ts @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const ROLES = { + KIBANA_RBAC_DEFAULT_SPACE_READ_USER: { + name: 'kibana_rbac_default_space_read_user', + privileges: { + kibana: [ + { + base: ['read'], + spaces: ['default'], + }, + ], + }, + }, + KIBANA_RBAC_DEFAULT_SPACE_WRITE_USER: { + name: 'kibana_rbac_default_space_write_user', + privileges: { + kibana: [ + { + base: ['all'], + spaces: ['default'], + }, + ], + }, + }, + KIBANA_RBAC_DEFAULT_SPACE_SO_MANAGEMENT_WRITE_USER: { + name: 'kibana_rbac_default_space_so_management_write_user', + privileges: { + kibana: [ + { + feature: { + savedObjectsManagement: ['all'], + }, + spaces: ['default'], + }, + ], + }, + }, + KIBANA_RBAC_DEFAULT_SPACE_SO_MANAGEMENT_READ_USER: { + name: 'kibana_rbac_default_space_so_management_read_user', + privileges: { + kibana: [ + { + feature: { + savedObjectsManagement: ['read'], + }, + spaces: ['default'], + }, + ], + }, + }, + KIBANA_RBAC_DEFAULT_SPACE_SO_TAGGING_READ_USER: { + name: 'kibana_rbac_default_space_so_tagging_read_user', + privileges: { + kibana: [ + { + feature: { + savedObjectsTagging: ['read'], + }, + spaces: ['default'], + }, + ], + }, + }, + KIBANA_RBAC_DEFAULT_SPACE_SO_TAGGING_WRITE_USER: { + name: 'kibana_rbac_default_space_so_tagging_write_user', + privileges: { + kibana: [ + { + feature: { + savedObjectsTagging: ['all'], + }, + spaces: ['default'], + }, + ], + }, + }, + KIBANA_RBAC_DEFAULT_SPACE_DASHBOARD_READ_USER: { + name: 'kibana_rbac_default_space_dashboard_read_user', + privileges: { + kibana: [ + { + feature: { + dashboard: ['read'], + }, + spaces: ['default'], + }, + ], + }, + }, + KIBANA_RBAC_DEFAULT_SPACE_VISUALIZE_READ_USER: { + name: 'kibana_rbac_default_space_visualize_read_user', + privileges: { + kibana: [ + { + feature: { + visualize: ['read'], + }, + spaces: ['default'], + }, + ], + }, + }, + KIBANA_RBAC_DEFAULT_SPACE_ADVANCED_SETTINGS_READ_USER: { + name: 'kibana_rbac_default_space_advanced_settings_read_user', + privileges: { + kibana: [ + { + feature: { + advancedSettings: ['read'], + }, + spaces: ['default'], + }, + ], + }, + }, +}; + +export const USERS = { + NOT_A_KIBANA_USER: { + username: 'not_a_kibana_user', + password: 'password', + roles: [], + description: 'user with no access', + }, + SUPERUSER: { + username: 'elastic', + password: 'changeme', + roles: [], + superuser: true, + description: 'superuser', + }, + DEFAULT_SPACE_READ_USER: { + username: 'a_kibana_rbac_default_space_read_user', + password: 'password', + roles: [ROLES.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.name], + description: 'rbac user with read on default space', + }, + DEFAULT_SPACE_WRITE_USER: { + username: 'a_kibana_rbac_default_space_write_user', + password: 'password', + roles: [ROLES.KIBANA_RBAC_DEFAULT_SPACE_WRITE_USER.name], + description: 'rbac user with all on default space', + }, + DEFAULT_SPACE_SO_MANAGEMENT_WRITE_USER: { + username: 'a_kibana_rbac_default_space_so_management_write_user', + password: 'password', + roles: [ROLES.KIBANA_RBAC_DEFAULT_SPACE_SO_MANAGEMENT_WRITE_USER.name], + description: 'rbac user with all on SO management on default space', + }, + DEFAULT_SPACE_SO_TAGGING_READ_USER: { + username: 'a_kibana_rbac_default_space_so_tagging_read_user', + password: 'password', + roles: [ROLES.KIBANA_RBAC_DEFAULT_SPACE_SO_TAGGING_READ_USER.name], + }, + DEFAULT_SPACE_SO_TAGGING_READ_SO_MANAGEMENT_READ_USER: { + username: 'a_kibana_rbac_default_space_so_tagging_read_so_management_read_user', + password: 'password', + roles: [ + ROLES.KIBANA_RBAC_DEFAULT_SPACE_SO_TAGGING_READ_USER.name, + ROLES.KIBANA_RBAC_DEFAULT_SPACE_SO_MANAGEMENT_READ_USER.name, + ], + }, + DEFAULT_SPACE_SO_TAGGING_WRITE_USER: { + username: 'a_kibana_rbac_default_space_so_tagging_write_user', + password: 'password', + roles: [ROLES.KIBANA_RBAC_DEFAULT_SPACE_SO_TAGGING_WRITE_USER.name], + }, + DEFAULT_SPACE_DASHBOARD_READ_USER: { + username: 'a_kibana_rbac_default_space_dashboard_read_user', + password: 'password', + roles: [ROLES.KIBANA_RBAC_DEFAULT_SPACE_DASHBOARD_READ_USER.name], + }, + DEFAULT_SPACE_VISUALIZE_READ_USER: { + username: 'a_kibana_rbac_default_space_visualize_read_user', + password: 'password', + roles: [ROLES.KIBANA_RBAC_DEFAULT_SPACE_VISUALIZE_READ_USER.name], + }, + DEFAULT_SPACE_ADVANCED_SETTINGS_READ_USER: { + username: 'a_kibana_rbac_default_space_advanced_settings_read_user', + password: 'password', + roles: [ROLES.KIBANA_RBAC_DEFAULT_SPACE_ADVANCED_SETTINGS_READ_USER.name], + }, +}; diff --git a/x-pack/test/saved_object_tagging/common/lib/create_users_and_roles.ts b/x-pack/test/saved_object_tagging/common/lib/create_users_and_roles.ts new file mode 100644 index 0000000000000..01243f269d52d --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/lib/create_users_and_roles.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext as CommonFtrProviderContext } from '../../../common/ftr_provider_context'; +import { USERS, ROLES } from './authentication'; +import { User, Role } from './types'; + +export const createUsersAndRoles = async (getService: CommonFtrProviderContext['getService']) => { + const security = getService('security'); + + const createRole = async ({ name, privileges }: Role) => { + return await security.role.create(name, privileges); + }; + + const createUser = async ({ username, password, roles, superuser }: User) => { + // no need to create superuser + if (superuser) { + return; + } + + return await security.user.create(username, { + password, + roles, + full_name: username.replace('_', ' '), + email: `${username}@elastic.co`, + }); + }; + + for (const role of Object.values(ROLES)) { + await createRole(role); + } + + for (const user of Object.values(USERS)) { + await createUser(user); + } +}; diff --git a/x-pack/test/saved_object_tagging/common/lib/index.ts b/x-pack/test/saved_object_tagging/common/lib/index.ts new file mode 100644 index 0000000000000..679459587b2ca --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/lib/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Role, User, ExpectedResponse } from './types'; +export { ROLES, USERS } from './authentication'; +export { createUsersAndRoles } from './create_users_and_roles'; diff --git a/x-pack/test/saved_object_tagging/common/lib/types.ts b/x-pack/test/saved_object_tagging/common/lib/types.ts new file mode 100644 index 0000000000000..dbef1a0878b28 --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/lib/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface User { + username: string; + password: string; + roles: string[]; + superuser?: boolean; + description?: string; +} + +export interface Role { + name: string; + privileges: any; +} + +export interface ExpectedResponse { + httpCode: number; + expectResponse: (body: Record) => void | Promise; +} diff --git a/x-pack/test/saved_object_tagging/functional/config.ts b/x-pack/test/saved_object_tagging/functional/config.ts new file mode 100644 index 0000000000000..3bc6814345c63 --- /dev/null +++ b/x-pack/test/saved_object_tagging/functional/config.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import path from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { services, pageObjects } from './ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const kibanaFunctionalConfig = await readConfigFile( + require.resolve('../../functional/config.js') + ); + + return { + testFiles: [require.resolve('./tests')], + servers: { + ...kibanaFunctionalConfig.get('servers'), + }, + services, + pageObjects, + + esArchiver: { + directory: path.resolve(__dirname, '..', 'common', 'fixtures', 'es_archiver'), + }, + + junit: { + reportName: 'X-Pack Saved Object Tagging Functional Tests', + }, + + esTestCluster: kibanaFunctionalConfig.get('esTestCluster'), + apps: { + ...kibanaFunctionalConfig.get('apps'), + }, + + kbnTestServer: { + ...kibanaFunctionalConfig.get('kbnTestServer'), + serverArgs: [...kibanaFunctionalConfig.get('kbnTestServer.serverArgs')], + }, + }; +} diff --git a/x-pack/test/saved_object_tagging/functional/ftr_provider_context.ts b/x-pack/test/saved_object_tagging/functional/ftr_provider_context.ts new file mode 100644 index 0000000000000..d939d0e893579 --- /dev/null +++ b/x-pack/test/saved_object_tagging/functional/ftr_provider_context.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; +import { services } from '../../functional/services'; +import { pageObjects } from '../../functional/page_objects'; + +export type FtrProviderContext = GenericFtrProviderContext; +export { services, pageObjects }; diff --git a/x-pack/test/saved_object_tagging/functional/tests/create.ts b/x-pack/test/saved_object_tagging/functional/tests/create.ts new file mode 100644 index 0000000000000..b62e9a70b43e8 --- /dev/null +++ b/x-pack/test/saved_object_tagging/functional/tests/create.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'security', 'savedObjects', 'tagManagement']); + + const tagManagementPage = PageObjects.tagManagement; + + describe('create tag', () => { + let tagModal: typeof tagManagementPage['tagModal']; + + before(async () => { + tagModal = tagManagementPage.tagModal; + await esArchiver.load('functional_base'); + await tagManagementPage.navigateTo(); + }); + after(async () => { + await esArchiver.unload('functional_base'); + }); + + afterEach(async () => { + await tagModal.closeIfOpened(); + }); + + it('creates a valid tag', async () => { + await tagModal.openCreate(); + await tagModal.fillForm( + { + name: 'my-new-tag', + description: 'I just added this tag', + color: '#FF00CC', + }, + { submit: true } + ); + await tagModal.waitUntilClosed(); + await tagManagementPage.waitUntilTableIsLoaded(); + + const tags = await tagManagementPage.getDisplayedTagsInfo(); + const newTag = tags.find((tag) => tag.name === 'my-new-tag'); + + expect(newTag).not.to.be(undefined); + expect(newTag!.description).to.eql('I just added this tag'); + }); + + it('show errors when the validation fails', async () => { + await tagModal.openCreate(); + await tagModal.fillForm( + { + name: 'invalid&$%name', + description: 'The name will fails validation', + color: '#FF00CC', + }, + { submit: true } + ); + + expect(await tagModal.isOpened()).to.be(true); + expect(await tagModal.hasError()).to.be(true); + + const errors = await tagModal.getValidationErrors(); + expect(errors.name).not.to.be(undefined); + expect(errors.color).to.be(undefined); + }); + + it('allows to create the tag once the errors are fixed', async () => { + await tagModal.openCreate(); + await tagModal.fillForm( + { + name: 'invalid&$%name', + description: 'The name will fails validation', + color: '#FF00CC', + }, + { submit: true } + ); + + expect(await tagModal.isOpened()).to.be(true); + expect(await tagModal.hasError()).to.be(true); + + await tagModal.fillForm( + { + name: 'valid name', + }, + { submit: true } + ); + + await tagModal.waitUntilClosed(); + await tagManagementPage.waitUntilTableIsLoaded(); + + const tags = await tagManagementPage.getDisplayedTagsInfo(); + const newTag = tags.find((tag) => tag.name === 'valid name'); + + expect(newTag).not.to.be(undefined); + }); + + it('allow to close the modal without creating the tag', async () => { + await tagModal.openCreate(); + await tagModal.fillForm( + { + name: 'canceled-tag', + description: 'I will not add this tag', + color: '#FF00CC', + }, + { submit: false } + ); + await tagModal.clickCancel(); + await tagModal.waitUntilClosed(); + + const tags = await tagManagementPage.getDisplayedTagsInfo(); + const newTag = tags.find((tag) => tag.name === 'canceled-tag'); + + expect(newTag).to.be(undefined); + }); + }); +} diff --git a/x-pack/test/saved_object_tagging/functional/tests/dashboard_integration.ts b/x-pack/test/saved_object_tagging/functional/tests/dashboard_integration.ts new file mode 100644 index 0000000000000..081fa1feb1c33 --- /dev/null +++ b/x-pack/test/saved_object_tagging/functional/tests/dashboard_integration.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const listingTable = getService('listingTable'); + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const PageObjects = getPageObjects(['dashboard', 'tagManagement', 'common']); + + /** + * Select tags in the searchbar's tag filter. + */ + const selectFilterTags = async (...tagNames: string[]) => { + // open the filter dropdown + const filterButton = await find.byCssSelector('.euiFilterGroup .euiFilterButton'); + await filterButton.click(); + // select the tags + for (const tagName of tagNames) { + await testSubjects.click( + `tag-searchbar-option-${PageObjects.tagManagement.testSubjFriendly(tagName)}` + ); + } + // click elsewhere to close the filter dropdown + const searchFilter = await find.byCssSelector('main .euiFieldSearch'); + await searchFilter.click(); + }; + + describe('dashboard integration', () => { + before(async () => { + await esArchiver.load('dashboard'); + await esArchiver.loadIfNeeded('logstash_functional'); + }); + after(async () => { + await esArchiver.unload('dashboard'); + await esArchiver.unload('logstash_functional'); + }); + + describe('listing', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.gotoDashboardLandingPage(); + }); + + it('allows to manually type tag filter query', async () => { + await listingTable.searchForItemWithName('tag:(tag-1)', { escape: false }); + + await listingTable.expectItemsCount('dashboard', 2); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.eql([ + 'dashboard 4 with real data (tag-1)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by selecting a tag in the filter menu', async () => { + await selectFilterTags('tag-3'); + + await listingTable.expectItemsCount('dashboard', 2); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.eql(['dashboard 2 (tag-3)', 'dashboard 3 (tag-1 and tag-3)']); + }); + + it('allows to filter by multiple tags', async () => { + await selectFilterTags('tag-2', 'tag-3'); + + await listingTable.expectItemsCount('dashboard', 3); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.eql([ + 'dashboard 1 (tag-2)', + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + }); + + describe('creating', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.gotoDashboardLandingPage(); + }); + + it('allows to select tags for a new dashboard', async () => { + await PageObjects.dashboard.clickNewDashboard(); + + await PageObjects.dashboard.saveDashboard('my-new-dashboard', { + waitDialogIsClosed: true, + tags: ['tag-1', 'tag-3'], + }); + + await PageObjects.dashboard.gotoDashboardLandingPage(); + await selectFilterTags('tag-1'); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.contain('my-new-dashboard'); + }); + + it('allows to create a tag from the tag selector', async () => { + const { tagModal } = PageObjects.tagManagement; + + await PageObjects.dashboard.clickNewDashboard(); + + await testSubjects.click('dashboardSaveMenuItem'); + await testSubjects.setValue('savedObjectTitle', 'dashboard-with-new-tag'); + + await testSubjects.click('savedObjectTagSelector'); + await testSubjects.click(`tagSelectorOption-action__create`); + + expect(await tagModal.isOpened()).to.be(true); + + await tagModal.fillForm( + { + name: 'my-new-tag', + color: '#FFCC33', + description: '', + }, + { + submit: true, + } + ); + + expect(await tagModal.isOpened()).to.be(false); + + await PageObjects.dashboard.clickSave(); + + await PageObjects.dashboard.gotoDashboardLandingPage(); + await selectFilterTags('my-new-tag'); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.contain('dashboard-with-new-tag'); + }); + }); + + describe('editing', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.gotoDashboardLandingPage(); + }); + + it('allows to select tags for an existing dashboard', async () => { + await listingTable.clickItemLink('dashboard', 'dashboard 4 with real data (tag-1)'); + + await PageObjects.dashboard.switchToEditMode(); + await PageObjects.dashboard.saveDashboard('dashboard 4 with real data (tag-1)', { + waitDialogIsClosed: true, + tags: ['tag-3'], + }); + + await PageObjects.dashboard.gotoDashboardLandingPage(); + await selectFilterTags('tag-3'); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.contain('dashboard 4 with real data (tag-1)'); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_tagging/functional/tests/edit.ts b/x-pack/test/saved_object_tagging/functional/tests/edit.ts new file mode 100644 index 0000000000000..1883d3f23dc9d --- /dev/null +++ b/x-pack/test/saved_object_tagging/functional/tests/edit.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'security', 'savedObjects', 'tagManagement']); + + const tagManagementPage = PageObjects.tagManagement; + + describe('edit tag', () => { + let tagModal: typeof tagManagementPage['tagModal']; + + before(async () => { + tagModal = tagManagementPage.tagModal; + await esArchiver.load('functional_base'); + await tagManagementPage.navigateTo(); + }); + after(async () => { + await esArchiver.unload('functional_base'); + }); + + afterEach(async () => { + await tagModal.closeIfOpened(); + }); + + it('displays the tag attributes in the edition form', async () => { + await tagModal.openEdit('tag-1'); + + const formValues = await tagModal.getFormValues(); + expect(formValues).to.eql({ + name: 'tag-1', + description: 'My first tag!', + color: '#FF00FF', + }); + }); + + it('allow to edit the tag', async () => { + await tagModal.openEdit('tag-1'); + + await tagModal.fillForm( + { + name: 'tag-1-edited', + description: 'This was edited', + color: '#FFCC00', + }, + { submit: true } + ); + await tagModal.waitUntilClosed(); + await tagManagementPage.waitUntilTableIsLoaded(); + + const tags = await tagManagementPage.getDisplayedTagsInfo(); + expect(tags.length).to.be(5); + + const oldTag = tags.find((tag) => tag.name === 'tag-1'); + const newTag = tags.find((tag) => tag.name === 'tag-1-edited'); + + expect(oldTag).to.be(undefined); + + expect(newTag).not.to.be(undefined); + expect(newTag!.description).to.eql('This was edited'); + }); + + it('show errors when the validation fails', async () => { + await tagModal.openEdit('tag-2'); + await tagModal.fillForm( + { + name: 'invalid&$%name', + }, + { submit: true } + ); + + expect(await tagModal.isOpened()).to.be(true); + expect(await tagModal.hasError()).to.be(true); + + const errors = await tagModal.getValidationErrors(); + expect(errors.name).not.to.be(undefined); + expect(errors.color).to.be(undefined); + }); + + it('allows to edit the tag once the errors are fixed', async () => { + await tagModal.openEdit('tag-2'); + await tagModal.fillForm( + { + name: 'invalid&$%name', + description: 'edited description', + color: '#FF00CC', + }, + { submit: true } + ); + + expect(await tagModal.isOpened()).to.be(true); + expect(await tagModal.hasError()).to.be(true); + + await tagModal.fillForm( + { + name: 'edited name', + }, + { submit: true } + ); + + await tagModal.waitUntilClosed(); + await tagManagementPage.waitUntilTableIsLoaded(); + + const tags = await tagManagementPage.getDisplayedTagsInfo(); + const oldTag = tags.find((tag) => tag.name === 'tag-2'); + const newTag = tags.find((tag) => tag.name === 'edited name'); + + expect(oldTag).to.be(undefined); + expect(newTag).not.to.be(undefined); + }); + + it('allow to close the modal without updating the tag', async () => { + await tagModal.openEdit('tag-3'); + await tagModal.fillForm( + { + name: 'canceled-tag', + description: 'I will not add this tag', + color: '#FF00CC', + }, + { submit: false } + ); + await tagModal.clickCancel(); + await tagModal.waitUntilClosed(); + + const tags = await tagManagementPage.getDisplayedTagsInfo(); + const uneditedTag = tags.find((tag) => tag.name === 'tag-3'); + const newTag = tags.find((tag) => tag.name === 'canceled-tag'); + + expect(tags.length).to.be(5); + expect(uneditedTag).not.to.be(undefined); + expect(newTag).to.be(undefined); + }); + }); +} diff --git a/x-pack/test/saved_object_tagging/functional/tests/feature_control.ts b/x-pack/test/saved_object_tagging/functional/tests/feature_control.ts new file mode 100644 index 0000000000000..72beabca59f5c --- /dev/null +++ b/x-pack/test/saved_object_tagging/functional/tests/feature_control.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { USERS, User } from '../../common/lib'; + +interface PrivilegeMap { + view: boolean; + delete: boolean; + create: boolean; + edit: boolean; + viewRelations: boolean; +} + +interface FeatureControlUserSuite { + user: User; + description: string; + privileges: PrivilegeMap; +} + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'security', 'savedObjects', 'tagManagement']); + const tagManagementPage = PageObjects.tagManagement; + + const loginAs = async (user: User) => { + await PageObjects.security.forceLogout(); + await PageObjects.security.login(user.username, user.password, { + expectSpaceSelector: false, + }); + }; + + const addFeatureControlSuite = ({ user, description, privileges }: FeatureControlUserSuite) => { + const testPrefix = (allowed: boolean) => (allowed ? `can` : `can't`); + + describe(description, () => { + before(async () => { + await loginAs(user); + await tagManagementPage.navigateTo(); + }); + + after(async () => { + await PageObjects.security.forceLogout(); + }); + + it(`${testPrefix(privileges.view)} see all tags`, async () => { + const tagNames = await tagManagementPage.getDisplayedTagNames(); + expect(tagNames.length).to.be(privileges.view ? 5 : 0); + }); + + it(`${testPrefix(privileges.delete)} delete tag`, async () => { + expect(await tagManagementPage.isDeleteButtonVisible()).to.be(privileges.delete); + }); + + it(`${testPrefix(privileges.create)} create tag`, async () => { + expect(await tagManagementPage.isCreateButtonVisible()).to.be(privileges.create); + }); + + it(`${testPrefix(privileges.edit)} edit tag`, async () => { + expect(await tagManagementPage.isEditButtonVisible()).to.be(privileges.edit); + }); + + it(`${testPrefix(privileges.viewRelations)} see relations to other objects`, async () => { + expect(await tagManagementPage.isConnectionLinkDisplayed('tag-1')).to.be( + privileges.viewRelations + ); + }); + }); + }; + + describe('feature controls', () => { + before(async () => { + await esArchiver.load('functional_base'); + }); + after(async () => { + await esArchiver.unload('functional_base'); + }); + + addFeatureControlSuite({ + user: USERS.DEFAULT_SPACE_SO_TAGGING_READ_USER, + description: 'tag management read privileges', + privileges: { + view: true, + create: false, + edit: false, + delete: false, + viewRelations: false, + }, + }); + + addFeatureControlSuite({ + user: USERS.DEFAULT_SPACE_SO_TAGGING_WRITE_USER, + description: 'tag management write privileges', + privileges: { + view: true, + create: true, + edit: true, + delete: true, + viewRelations: false, + }, + }); + + addFeatureControlSuite({ + user: USERS.DEFAULT_SPACE_SO_TAGGING_READ_SO_MANAGEMENT_READ_USER, + description: 'tag management read and so management read privileges', + privileges: { + view: true, + create: false, + edit: false, + delete: false, + viewRelations: true, + }, + }); + + addFeatureControlSuite({ + user: USERS.DEFAULT_SPACE_WRITE_USER, + description: 'base write privileges', + privileges: { + view: true, + create: true, + edit: true, + delete: true, + viewRelations: true, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_tagging/functional/tests/index.ts b/x-pack/test/saved_object_tagging/functional/tests/index.ts new file mode 100644 index 0000000000000..43673487ba74f --- /dev/null +++ b/x-pack/test/saved_object_tagging/functional/tests/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; +import { createUsersAndRoles } from '../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function ({ loadTestFile, getService }: FtrProviderContext) { + describe('saved objects tagging - functional tests', function () { + this.tags('ciGroup2'); + + before(async () => { + await createUsersAndRoles(getService); + }); + + loadTestFile(require.resolve('./listing')); + loadTestFile(require.resolve('./create')); + loadTestFile(require.resolve('./edit')); + loadTestFile(require.resolve('./som_integration')); + loadTestFile(require.resolve('./visualize_integration')); + loadTestFile(require.resolve('./dashboard_integration')); + loadTestFile(require.resolve('./feature_control')); + }); +} diff --git a/x-pack/test/saved_object_tagging/functional/tests/listing.ts b/x-pack/test/saved_object_tagging/functional/tests/listing.ts new file mode 100644 index 0000000000000..d90f44e884d14 --- /dev/null +++ b/x-pack/test/saved_object_tagging/functional/tests/listing.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'security', 'savedObjects', 'tagManagement']); + const tagManagementPage = PageObjects.tagManagement; + + describe('table listing', () => { + before(async () => { + await esArchiver.load('functional_base'); + await tagManagementPage.navigateTo(); + }); + after(async () => { + await esArchiver.unload('functional_base'); + }); + + describe('searching', () => { + afterEach(async () => { + await tagManagementPage.searchForTerm(''); + }); + + it('allows to search by name', async () => { + await tagManagementPage.searchForTerm('my-favorite'); + + const displayedTags = await tagManagementPage.getDisplayedTagsInfo(); + expect(displayedTags.length).to.be(1); + expect(displayedTags[0].name).to.be('my-favorite-tag'); + }); + + it('allows to search by description', async () => { + await tagManagementPage.searchForTerm('Another awesome'); + + const displayedTags = await tagManagementPage.getDisplayedTagsInfo(); + expect(displayedTags.length).to.be(1); + expect(displayedTags[0].name).to.be('tag-2'); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_tagging/functional/tests/som_integration.ts b/x-pack/test/saved_object_tagging/functional/tests/som_integration.ts new file mode 100644 index 0000000000000..4897264b0d0c1 --- /dev/null +++ b/x-pack/test/saved_object_tagging/functional/tests/som_integration.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const PageObjects = getPageObjects(['settings', 'tagManagement', 'savedObjects', 'common']); + + /** + * Select tags in the searchbar's tag filter. + * EUI does not allow to specify a testSubj for filters... + */ + const selectTagsInFilter = async (...tagNames: string[]) => { + // open the filter dropdown + // the first class selector before the id is of course useless. Only here to help cleaning that once we got + // testSubjects in EUI filters. + const filterButton = await find.byCssSelector( + '.euiFilterGroup #field_value_selection_1 .euiFilterButton' + ); + await filterButton.click(); + // select the tags + for (const tagName of tagNames) { + await testSubjects.click( + `tag-searchbar-option-${PageObjects.tagManagement.testSubjFriendly(tagName)}` + ); + } + // click elsewhere to close the filter dropdown + await testSubjects.click('savedObjectSearchBar'); + }; + + describe('saved objects management integration', () => { + before(async () => { + await esArchiver.load('so_management'); + }); + after(async () => { + await esArchiver.unload('so_management'); + }); + + describe('navigating from the tag section', () => { + beforeEach(async () => { + await PageObjects.tagManagement.navigateTo(); + }); + + it('access the saved objects management section with pre-applied filter', async () => { + await PageObjects.tagManagement.clickOnConnectionsLink('tag-1'); + + await PageObjects.common.waitUntilUrlIncludes('/app/management/kibana/objects'); + await PageObjects.savedObjects.waitTableIsLoaded(); + + expect(await PageObjects.savedObjects.getCurrentSearchValue()).to.eql('tag:(tag-1)'); + expect(await PageObjects.savedObjects.getRowTitles()).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 3 (tag-1 + tag-3)', + ]); + }); + }); + + describe('saved object management listing', () => { + beforeEach(async () => { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaSavedObjects(); + }); + + it('allows to manually type tag filter query', async () => { + await PageObjects.savedObjects.searchForObject('tag:(tag-2)'); + + expect(await PageObjects.savedObjects.getRowTitles()).to.eql([ + 'Visualization 2 (tag-2)', + 'Visualization 4 (tag-2)', + ]); + }); + + it('allows to filter by selecting a tag in the filter menu', async () => { + await selectTagsInFilter('tag-1'); + + expect(await PageObjects.savedObjects.getRowTitles()).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 3 (tag-1 + tag-3)', + ]); + }); + + it('allows to filter by multiple tags', async () => { + await selectTagsInFilter('tag-2', 'tag-3'); + + expect(await PageObjects.savedObjects.getRowTitles()).to.eql([ + 'Visualization 2 (tag-2)', + 'Visualization 3 (tag-1 + tag-3)', + 'Visualization 4 (tag-2)', + ]); + }); + + it('properly display tags', async () => { + const testRow = await testSubjects.find('savedObjectsTableRow row-vis-area-3'); + const tagCell = await testSubjects.findDescendant('listingTableRowTags', testRow); + const tagContents = await tagCell.findAllByCssSelector('.euiBadge__content'); + const tagNames = await Promise.all(tagContents.map((tag) => tag.getVisibleText())); + + expect(tagNames).to.eql(['tag-1', 'tag-3']); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_tagging/functional/tests/visualize_integration.ts b/x-pack/test/saved_object_tagging/functional/tests/visualize_integration.ts new file mode 100644 index 0000000000000..2ccdd4ecf9690 --- /dev/null +++ b/x-pack/test/saved_object_tagging/functional/tests/visualize_integration.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const listingTable = getService('listingTable'); + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const PageObjects = getPageObjects(['visualize', 'tagManagement', 'visEditor']); + + /** + * Select tags in the searchbar's tag filter. + */ + const selectFilterTags = async (...tagNames: string[]) => { + // open the filter dropdown + const filterButton = await find.byCssSelector('.euiFilterGroup .euiFilterButton'); + await filterButton.click(); + // select the tags + for (const tagName of tagNames) { + await testSubjects.click( + `tag-searchbar-option-${PageObjects.tagManagement.testSubjFriendly(tagName)}` + ); + } + // click elsewhere to close the filter dropdown + const searchFilter = await find.byCssSelector('main .euiFieldSearch'); + await searchFilter.click(); + }; + + const selectSavedObjectTags = async (...tagNames: string[]) => { + await testSubjects.click('savedObjectTagSelector'); + for (const tagName of tagNames) { + await testSubjects.click( + `tagSelectorOption-${PageObjects.tagManagement.testSubjFriendly(tagName)}` + ); + } + await testSubjects.click('savedObjectTitle'); + }; + + describe('visualize integration', () => { + before(async () => { + await esArchiver.load('visualize'); + await esArchiver.loadIfNeeded('logstash_functional'); + }); + after(async () => { + await esArchiver.unload('visualize'); + await esArchiver.unload('logstash_functional'); + }); + + describe('listing', () => { + beforeEach(async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + }); + + it('allows to manually type tag filter query', async () => { + await listingTable.searchForItemWithName('tag:(tag-1)', { escape: false }); + await listingTable.expectItemsCount('visualize', 2); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.eql(['Visualization 1 (tag-1)', 'Visualization 3 (tag-1 + tag-3)']); + }); + + it('allows to filter by selecting a tag in the filter menu', async () => { + await selectFilterTags('tag-1'); + + await listingTable.expectItemsCount('visualize', 2); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.eql(['Visualization 1 (tag-1)', 'Visualization 3 (tag-1 + tag-3)']); + }); + + it('allows to filter by multiple tags', async () => { + await selectFilterTags('tag-2', 'tag-3'); + + await listingTable.expectItemsCount('visualize', 2); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.eql(['Visualization 2 (tag-2)', 'Visualization 3 (tag-1 + tag-3)']); + }); + }); + + describe('creating', () => { + it('allows to assign tags to the new visualization', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + + await PageObjects.visualize.clickMarkdownWidget(); + await PageObjects.visEditor.setMarkdownTxt('Just some markdown'); + await PageObjects.visEditor.clickGo(); + + await PageObjects.visualize.ensureSavePanelOpen(); + await testSubjects.setValue('savedObjectTitle', 'My new markdown viz'); + await selectSavedObjectTags('tag-1'); + + await testSubjects.click('confirmSaveSavedObjectButton'); + await PageObjects.visualize.gotoVisualizationLandingPage(); + + await selectFilterTags('tag-1'); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.contain('My new markdown viz'); + }); + + it('allows to assign tags to the new visualization', async () => { + const { tagModal } = PageObjects.tagManagement; + + await PageObjects.visualize.navigateToNewVisualization(); + + await PageObjects.visualize.clickMarkdownWidget(); + await PageObjects.visEditor.setMarkdownTxt('Just some markdown'); + await PageObjects.visEditor.clickGo(); + + await PageObjects.visualize.ensureSavePanelOpen(); + await testSubjects.setValue('savedObjectTitle', 'vis-with-new-tag'); + + await testSubjects.click('savedObjectTagSelector'); + await testSubjects.click(`tagSelectorOption-action__create`); + + expect(await tagModal.isOpened()).to.be(true); + + await tagModal.fillForm( + { + name: 'my-new-tag', + color: '#FFCC33', + description: '', + }, + { + submit: true, + } + ); + + expect(await tagModal.isOpened()).to.be(false); + + await testSubjects.click('confirmSaveSavedObjectButton'); + await PageObjects.visualize.gotoVisualizationLandingPage(); + + await selectFilterTags('my-new-tag'); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.contain('vis-with-new-tag'); + }); + }); + + describe('editing', () => { + beforeEach(async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + }); + + it('allows to assign tags to an existing visualization', async () => { + await listingTable.clickItemLink('visualize', 'Visualization 1 (tag-1)'); + + await PageObjects.visualize.ensureSavePanelOpen(); + await selectSavedObjectTags('tag-2'); + + await testSubjects.click('confirmSaveSavedObjectButton'); + await PageObjects.visualize.gotoVisualizationLandingPage(); + + await selectFilterTags('tag-2'); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.contain('Visualization 1 (tag-1)'); + }); + }); + }); +}