diff --git a/.buildkite/pipelines/es_snapshots/verify.yml b/.buildkite/pipelines/es_snapshots/verify.yml index 10c996c5acec..9af2e938db49 100755 --- a/.buildkite/pipelines/es_snapshots/verify.yml +++ b/.buildkite/pipelines/es_snapshots/verify.yml @@ -64,7 +64,7 @@ steps: - command: .buildkite/scripts/steps/test/jest_integration.sh label: 'Jest Integration Tests' agents: - queue: jest + queue: n2-4 timeout_in_minutes: 120 key: jest-integration retry: diff --git a/.buildkite/pipelines/hourly.yml b/.buildkite/pipelines/hourly.yml index b78db4698c01..89541023be8e 100644 --- a/.buildkite/pipelines/hourly.yml +++ b/.buildkite/pipelines/hourly.yml @@ -1,3 +1,5 @@ +env: + REPORT_FAILED_TESTS_TO_GITHUB: 'true' steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build @@ -118,14 +120,14 @@ steps: - command: .buildkite/scripts/steps/test/jest_integration.sh label: 'Jest Integration Tests' agents: - queue: jest + queue: n2-4 timeout_in_minutes: 120 key: jest-integration - command: .buildkite/scripts/steps/test/api_integration.sh label: 'API Integration Tests' agents: - queue: jest + queue: n2-2 timeout_in_minutes: 120 key: api-integration diff --git a/.buildkite/scripts/lifecycle/post_command.sh b/.buildkite/scripts/lifecycle/post_command.sh index 14391fdd1e69..23f44a586e97 100755 --- a/.buildkite/scripts/lifecycle/post_command.sh +++ b/.buildkite/scripts/lifecycle/post_command.sh @@ -22,6 +22,5 @@ if [[ "$IS_TEST_EXECUTION_STEP" == "true" ]]; then buildkite-agent artifact upload 'x-pack/test/functional/failure_debug/html/*.html' buildkite-agent artifact upload '.es/**/*.hprof' - # TODO - re-enable when Jenkins is disabled - # node scripts/report_failed_tests --build-url="${BUILDKITE_BUILD_URL}#${BUILDKITE_JOB_ID}" 'target/junit/**/*.xml' + node scripts/report_failed_tests --build-url="${BUILDKITE_BUILD_URL}#${BUILDKITE_JOB_ID}" 'target/junit/**/*.xml' fi diff --git a/Jenkinsfile b/Jenkinsfile index db5ae306e6e2..7dd3d0f41d27 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,5 +1,10 @@ #!/bin/groovy +if (!env.ghprbPullId) { + print "Non-PR builds are now in Buildkite." + return +} + library 'kibana-pipeline-library' kibanaLibrary.load() diff --git a/dev_docs/key_concepts/persistable_state.mdx b/dev_docs/key_concepts/persistable_state.mdx new file mode 100644 index 000000000000..77c7e41ff2e5 --- /dev/null +++ b/dev_docs/key_concepts/persistable_state.mdx @@ -0,0 +1,83 @@ +--- +id: kibDevDocsPersistableStateIntro +slug: /kibana-dev-docs/persistable-state-intro +title: Persistable State +summary: Persitable state is a key concept to understand when building a Kibana plugin. +date: 2021-02-02 +tags: ['kibana','dev', 'contributor', 'api docs'] +--- + + “Persistable state” is developer-defined state that supports being persisted by a plugin other than the one defining it. Persistable State needs to be serializable and the owner can/should provide utilities to migrate it, extract and inject any it may contain, as well as telemetry collection utilities. + +## Exposing state that can be persisted + +Any plugin that exposes state that another plugin might persist should implement interface on their `setup` contract. This will allow plugins persisting the state to easily access migrations and other utilities. + +Example: Data plugin allows you to generate filters. Those filters can be persisted by applications in their saved +objects or in the URL. In order to allow apps to migrate the filters in case the structure changes in the future, the Data plugin implements `PersistableStateService` on . + +note: There is currently no obvious way for a plugin to know which state is safe to persist. The developer must manually look for a matching `PersistableStateService`, or ad-hoc provided migration utilities (as is the case with Rule Type Parameters). +In the future, we hope to make it easier for producers of state to understand when they need to write a migration with changes, and also make it easier for consumers of such state, to understand whether it is safe to persist. + +## Exposing state that can be persisted but is not owned by plugin exposing it (registry) + +Any plugin that owns collection of items (registry) whose state/configuration can be persisted should implement `PersistableStateService` +interface on their `setup` contract and each item in the collection should implement interface. + +Example: Embeddable plugin owns the registry of embeddable factories to which other plugins can register new embeddable factories. Dashboard plugin +stores a bunch of embeddable panels input in its saved object and URL. Embeddable plugin setup contract implements `PersistableStateService` +interface and each `EmbeddableFactory` needs to implement `PersistableStateDefinition` interface. + +Embeddable plugin exposes this interfaces: +``` +// EmbeddableInput implements Serializable + +export interface EmbeddableRegistryDefinition extends PersistableStateDefinition { + id: string; + ... +} + +export interface EmbeddableSetup extends PersistableStateService; +``` + +Note: if your plugin doesn't expose the state (it is the only one storing state), the plugin doesn't need to implement the `PersistableStateService` interface. +If the state your plugin is storing can be provided by other plugins (your plugin owns a registry) items in that registry still need to implement `PersistableStateDefinition` interface. + +## Storing persistable state as part of saved object + +Any plugin that stores any persistable state as part of their saved object should make sure that its saved object migration +and reference extraction and injection methods correctly use the matching `PersistableStateService` implementation for the state they are storing. + +Take a look at [example saved object](https://github.com/elastic/kibana/blob/master/examples/embeddable_examples/server/searchable_list_saved_object.ts#L32) which stores an embeddable state. Note how the `migrations`, `extractReferences` and `injectReferences` are defined. + +## Storing persistable state as part of URL + +When storing persistable state as part of URL you must make sure your URL is versioned. When loading the state `migrateToLatest` method +of `PersistableStateService` should be called, which will migrate the state from its original version to latest. + +note: Currently there is no recommended way on how to store version in url and its up to every application to decide on how to implement that. + +## Available state operations + +### Extraction/Injection of References + +In order to support import and export, and space-sharing capabilities, Saved Objects need to explicitly list any references they contain to other Saved Objects. +To support persisting your state in saved objects owned by another plugin, the and methods of Persistable State interface should be implemented. + + + +[See example embeddable providing extract/inject functions](https://github.com/elastic/kibana/blob/master/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts) + +### Migrations and Backward compatibility + +As your plugin evolves, you may need to change your state in a breaking way. If that happens, you should write a migration to upgrade the state that existed prior to the change. + +. + +[See an example saved object storing embeddable state implementing saved object migration function](https://github.com/elastic/kibana/blob/master/examples/embeddable_examples/server/searchable_list_saved_object.ts) + +[See example embeddable providing migration functions](https://github.com/elastic/kibana/blob/master/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts) + +## Telemetry + +You might want to collect statistics about how your state is used. If that is the case you should implement the telemetry method of Persistable State interface. diff --git a/docs/api/machine-learning.asciidoc b/docs/api/machine-learning.asciidoc new file mode 100644 index 000000000000..265896e6340d --- /dev/null +++ b/docs/api/machine-learning.asciidoc @@ -0,0 +1,11 @@ +[[machine-learning-api]] +== {ml-cap} APIs + +//Manage {kib} saved objects, including dashboards, visualizations, and more. + +The following {ml} API is available: + +* <> +//to retrieve a single {kib} saved object by ID + +include::machine-learning/sync.asciidoc[] diff --git a/docs/api/machine-learning/sync.asciidoc b/docs/api/machine-learning/sync.asciidoc new file mode 100644 index 000000000000..5f19bc17ab2f --- /dev/null +++ b/docs/api/machine-learning/sync.asciidoc @@ -0,0 +1,79 @@ +[[machine-learning-api-sync]] +=== Sync {ml} saved objects API +++++ +Sync {ml} saved objects +++++ + +Synchronizes {kib} saved objects for {ml} jobs. + +[[machine-learning-api-sync-request]] +==== Request + +`GET :/api/ml/saved_objects/sync` + +`GET :/s//api/ml/saved_objects/sync` + + +[[machine-learning-api-sync-path-params]] +==== Path parameters + +`space_id`:: +(Optional, string) An identifier for the space. If `space_id` is not provided in +the URL the default space is used. + +[[machine-learning-api-sync-query-params]] +==== Query parameters + +`simulate`:: +(Optional, boolean) When `true`, simulates the synchronization by only returning +the list actions that _would_ be performed. + +[[machine-learning-api-sync-response-body]] +==== Response body + +`datafeedsAdded`:: +(array) If a saved object for an {anomaly-job} is missing a {dfeed} identifier, +it is added. This list contains the {dfeed} identifiers and indicates whether +the synchronization was successful. + +`datafeedsRemoved`:: +(array) If saved objects exist for {dfeeds} that no longer exist, they are +deleted. This list contains the {dfeed} identifiers and indicates whether the +synchronization was successful. + +`savedObjectsCreated`:: +(array) If saved objects are missing for {ml} jobs, they are created. This +list contains the job identifiers and indicates whether the synchronization was +successful. + +`savedObjectsDeleted`:: +(array) If saved objects exist for jobs that no longer exist, they are deleted. +This list contains the job identifiers and indicates whether the synchronization +was successful. + +[[machine-learning-api-sync-codes]] +==== Response code + +`200`:: + Indicates a successful call. + +[[machine-learning-api-sync-example]] +==== Example + +Retrieve the list of {ml} saved objects that require synchronization: + +[source,sh] +-------------------------------------------------- +$ curl -X GET api/ml/saved_objects/sync?simulate=true +-------------------------------------------------- +// KIBANA + +If there are two jobs and a {dfeed} that need to be synchronized, for example, +the API returns the following: + +[source,sh] +-------------------------------------------------- +{{"savedObjectsCreated":{"myjob1":{"success":true},"myjob2":{"success":true}},"savedObjectsDeleted":{},"datafeedsAdded":{},"datafeedsRemoved":{"myfeed3":{"success":true}}} +-------------------------------------------------- + +To perform the synchronization, re-run the API and omit the `simulate` parameter. \ No newline at end of file diff --git a/docs/settings/fleet-settings.asciidoc b/docs/settings/fleet-settings.asciidoc index 3ae1c9df616b..3411f3930970 100644 --- a/docs/settings/fleet-settings.asciidoc +++ b/docs/settings/fleet-settings.asciidoc @@ -86,6 +86,8 @@ Optional properties are: be changed by updating the {kib} config. `is_default`:: If `true`, this policy is the default agent policy. `is_default_fleet_server`:: If `true`, this policy is the default {fleet-server} agent policy. + `data_output_id`:: ID of the output to send data (Need to be identical to `monitoring_output_id`) + `monitoring_output_id`:: ID of the output to send monitoring data. (Need to be identical to `data_output_id`) `package_policies`:: List of integration policies to add to this policy. `name`::: (required) Name of the integration policy. `package`::: (required) Integration that this policy configures @@ -96,6 +98,20 @@ Optional properties are: integration. Follows the same schema as integration inputs, with the exception that any object in `vars` can be passed `frozen: true` in order to prevent that specific `var` from being edited by the user. + +| `xpack.fleet.outputs` + | List of ouputs that are configured when the {fleet} app starts. +Required properties are: + + `id`:: Unique ID for this output. The ID should be a string. + `name`:: Output name. + `type`:: Type of Output. Currently we only support "elasticsearch". + `hosts`:: Array that contains the list of host for that output. + `config`:: Extra config for that output. + +Optional properties are: + + `is_default`:: If `true`, this output is the default output. |=== Example configuration: diff --git a/docs/user/api.asciidoc b/docs/user/api.asciidoc index 00aa3c545df6..12e200bb0ba2 100644 --- a/docs/user/api.asciidoc +++ b/docs/user/api.asciidoc @@ -96,6 +96,7 @@ include::{kib-repo-dir}/api/alerting.asciidoc[] include::{kib-repo-dir}/api/actions-and-connectors.asciidoc[] include::{kib-repo-dir}/api/dashboard-api.asciidoc[] include::{kib-repo-dir}/api/logstash-configuration-management.asciidoc[] +include::{kib-repo-dir}/api/machine-learning.asciidoc[] include::{kib-repo-dir}/api/url-shortening.asciidoc[] include::{kib-repo-dir}/api/task-manager/health.asciidoc[] include::{kib-repo-dir}/api/upgrade-assistant.asciidoc[] diff --git a/examples/embeddable_examples/kibana.json b/examples/embeddable_examples/kibana.json index d725a5c94a9c..103857804b5d 100644 --- a/examples/embeddable_examples/kibana.json +++ b/examples/embeddable_examples/kibana.json @@ -9,7 +9,7 @@ "githubTeam": "kibana-app-services" }, "description": "Example app that shows how to register custom embeddables", - "requiredPlugins": ["embeddable", "uiActions", "savedObjects", "dashboard"], + "requiredPlugins": ["embeddable", "uiActions", "savedObjects", "dashboard", "kibanaUtils"], "optionalPlugins": [], "extraPublicDirs": ["public/todo", "public/hello_world", "public/todo/todo_ref_embeddable"], "requiredBundles": ["kibanaReact"] diff --git a/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts b/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts index c1ceaaca3e46..61e6bfa56ec4 100644 --- a/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts +++ b/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts @@ -7,6 +7,7 @@ */ import { i18n } from '@kbn/i18n'; +import { EmbeddableStateWithType } from '../../../../src/plugins/embeddable/common'; import { IContainer, EmbeddableInput, @@ -35,6 +36,16 @@ export class SimpleEmbeddableFactoryDefinition '7.3.0': migration730, }; + public extract(state: EmbeddableStateWithType) { + // this embeddable does not store references to other saved objects + return { state, references: [] }; + } + + public inject(state: EmbeddableStateWithType) { + // this embeddable does not store references to other saved objects + return state; + } + /** * In our simple example, we let everyone have permissions to edit this. Most * embeddables should check the UI Capabilities service to be sure of diff --git a/examples/embeddable_examples/server/merge_migration_function_maps.ts b/examples/embeddable_examples/server/merge_migration_function_maps.ts new file mode 100644 index 000000000000..01a46949e6bb --- /dev/null +++ b/examples/embeddable_examples/server/merge_migration_function_maps.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { mergeWith } from 'lodash'; +import type { SerializableRecord } from '@kbn/utility-types'; +import { MigrateFunctionsObject, MigrateFunction } from '../../../src/plugins/kibana_utils/common'; + +export const mergeMigrationFunctionMaps = ( + obj1: MigrateFunctionsObject, + obj2: MigrateFunctionsObject +) => { + const customizer = (objValue: MigrateFunction, srcValue: MigrateFunction) => { + if (!srcValue || !objValue) { + return srcValue || objValue; + } + return (state: SerializableRecord) => objValue(srcValue(state)); + }; + + return mergeWith({ ...obj1 }, obj2, customizer); +}; diff --git a/examples/embeddable_examples/server/searchable_list_saved_object.ts b/examples/embeddable_examples/server/searchable_list_saved_object.ts index ac4656c7c2b7..a3b12a05323f 100644 --- a/examples/embeddable_examples/server/searchable_list_saved_object.ts +++ b/examples/embeddable_examples/server/searchable_list_saved_object.ts @@ -9,9 +9,12 @@ import { mapValues } from 'lodash'; import { SavedObjectsType, SavedObjectUnsanitizedDoc } from 'kibana/server'; import { EmbeddableSetup } from '../../../src/plugins/embeddable/server'; +// NOTE: this should rather be imported from 'plugins/kibana_utils/server' but examples at the moment don't +// allow static imports from plugins so this code was duplicated +import { mergeMigrationFunctionMaps } from './merge_migration_function_maps'; export const searchableListSavedObject = (embeddable: EmbeddableSetup) => { - return { + const searchableListSO: SavedObjectsType = { name: 'searchableList', hidden: false, namespaceType: 'single', @@ -30,14 +33,22 @@ export const searchableListSavedObject = (embeddable: EmbeddableSetup) => { }, }, migrations: () => { - // we assume all the migration will be done by embeddables service and that saved object holds no extra state besides that of searchable list embeddable input\ - // if saved object would hold additional information we would need to merge the response from embeddables.getAllMigrations with our custom migrations. - return mapValues(embeddable.getAllMigrations(), (migrate) => { + // there are no migrations defined for the saved object at the moment, possibly they would be added in the future + const searchableListSavedObjectMigrations = {}; + + // we don't know if embeddables have any migrations defined so we need to fetch them and map the received functions so we pass + // them the correct input and that we correctly map the response + const embeddableMigrations = mapValues(embeddable.getAllMigrations(), (migrate) => { return (state: SavedObjectUnsanitizedDoc) => ({ ...state, attributes: migrate(state.attributes), }); }); + + // we merge our and embeddable migrations and return + return mergeMigrationFunctionMaps(searchableListSavedObjectMigrations, embeddableMigrations); }, - } as SavedObjectsType; + }; + + return searchableListSO; }; diff --git a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts index d4bf625ee2d3..0129614fe658 100644 --- a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts +++ b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts @@ -37,17 +37,24 @@ export function runFailedTestsReporterCli() { } if (updateGithub) { - let branch: string | undefined = ''; + let branch: string = ''; let isPr = false; if (process.env.BUILDKITE === 'true') { - branch = process.env.BUILDKITE_BRANCH; + branch = process.env.BUILDKITE_BRANCH || ''; isPr = process.env.BUILDKITE_PULL_REQUEST === 'true'; + updateGithub = process.env.REPORT_FAILED_TESTS_TO_GITHUB === 'true'; } else { // JOB_NAME is formatted as `elastic+kibana+7.x` in some places and `elastic+kibana+7.x/JOB=kibana-intake,node=immutable` in others const jobNameSplit = (process.env.JOB_NAME || '').split(/\+|\//); - branch = jobNameSplit.length >= 3 ? jobNameSplit[2] : process.env.GIT_BRANCH; + branch = jobNameSplit.length >= 3 ? jobNameSplit[2] : process.env.GIT_BRANCH || ''; isPr = !!process.env.ghprbPullId; + + const isMasterOrVersion = branch === 'master' || branch.match(/^\d+\.(x|\d+)$/); + if (!isMasterOrVersion || isPr) { + log.info('Failure issues only created on master/version branch jobs'); + updateGithub = false; + } } if (!branch) { @@ -55,12 +62,6 @@ export function runFailedTestsReporterCli() { 'Unable to determine originating branch from job name or other environment variables' ); } - - const isMasterOrVersion = branch === 'master' || branch.match(/^\d+\.(x|\d+)$/); - if (!isMasterOrVersion || isPr) { - log.info('Failure issues only created on master/version branch jobs'); - updateGithub = false; - } } const githubApi = new GithubApi({ diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 72fa6c5553f7..a9b19a6e8405 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -241,6 +241,7 @@ export class DocLinksService { kibanaSearchSettings: `${KIBANA_DOCS}advanced-options.html#kibana-search-settings`, visualizationSettings: `${KIBANA_DOCS}advanced-options.html#kibana-visualization-settings`, timelionSettings: `${KIBANA_DOCS}advanced-options.html#kibana-timelion-settings`, + savedObjectsApiList: `${KIBANA_DOCS}saved-objects-api.html#saved-objects-api`, }, ml: { guide: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/index.html`, @@ -387,7 +388,7 @@ export class DocLinksService { }, snapshotRestore: { guide: `${KIBANA_DOCS}snapshot-repositories.html`, - changeIndexSettings: `${ELASTICSEARCH_DOCS}snapshots-restore-snapshot.html#change-index-settings-during-restore`, + changeIndexSettings: `${ELASTICSEARCH_DOCS}index-modules.html`, createSnapshot: `${ELASTICSEARCH_DOCS}snapshots-take-snapshot.html`, getSnapshot: `${ELASTICSEARCH_DOCS}get-snapshot-api.html`, registerSharedFileSystem: `${ELASTICSEARCH_DOCS}snapshots-register-repository.html#snapshots-filesystem-repository`, diff --git a/src/core/server/http/router/socket.test.ts b/src/core/server/http/router/socket.test.ts index 60c91786767a..389c08825d51 100644 --- a/src/core/server/http/router/socket.test.ts +++ b/src/core/server/http/router/socket.test.ts @@ -92,7 +92,7 @@ describe('KibanaSocket', () => { }); const socket = new KibanaSocket(tlsSocket); - expect(socket.renegotiate({})).resolves.toBe(result); + await expect(socket.renegotiate({})).rejects.toBe(result); expect(spy).toBeCalledTimes(1); }); diff --git a/src/core/server/saved_objects/migrations/core/migration_context.test.ts b/src/core/server/saved_objects/migrations/core/migration_context.test.ts index 240b41266abb..27aae5968ba8 100644 --- a/src/core/server/saved_objects/migrations/core/migration_context.test.ts +++ b/src/core/server/saved_objects/migrations/core/migration_context.test.ts @@ -74,4 +74,18 @@ describe('disableUnknownTypeMappingFields', () => { }, }); }); + + it('does not fail if the source mapping does not have `properties` defined', () => { + const missingPropertiesMappings = { + ...sourceMappings, + properties: undefined, + }; + const result = disableUnknownTypeMappingFields( + activeMappings, + // @ts-expect-error `properties` should not be undefined + missingPropertiesMappings + ); + + expect(Object.keys(result.properties)).toEqual(['known_type']); + }); }); diff --git a/src/core/server/saved_objects/migrations/core/migration_context.ts b/src/core/server/saved_objects/migrations/core/migration_context.ts index d7f7aff45a47..96c47bcf38d0 100644 --- a/src/core/server/saved_objects/migrations/core/migration_context.ts +++ b/src/core/server/saved_objects/migrations/core/migration_context.ts @@ -154,7 +154,7 @@ export function disableUnknownTypeMappingFields( ): IndexMapping { const targetTypes = Object.keys(activeMappings.properties); - const disabledTypesProperties = Object.keys(sourceMappings.properties) + const disabledTypesProperties = Object.keys(sourceMappings.properties ?? {}) .filter((sourceType) => { const isObjectType = 'properties' in sourceMappings.properties[sourceType]; // Only Object/Nested datatypes can be excluded from the field count by diff --git a/src/core/server/saved_objects/migrationsv2/README.md b/src/core/server/saved_objects/migrationsv2/README.md index 5121e66052f4..a6b8e01a3dc6 100644 --- a/src/core/server/saved_objects/migrationsv2/README.md +++ b/src/core/server/saved_objects/migrationsv2/README.md @@ -36,7 +36,7 @@ - [REINDEX_SOURCE_TO_TEMP_READ](#reindex_source_to_temp_read) - [Next action](#next-action-11) - [New control state](#new-control-state-11) - - [REINDEX_SOURCE_TO_TEMP_INDEX](#reindex_source_to_temp_index) + - [REINDEX_SOURCE_TO_TEMP_TRANSFORM](#REINDEX_SOURCE_TO_TEMP_TRANSFORM) - [Next action](#next-action-12) - [New control state](#new-control-state-12) - [REINDEX_SOURCE_TO_TEMP_INDEX_BULK](#reindex_source_to_temp_index_bulk) @@ -284,11 +284,11 @@ Read the next batch of outdated documents from the source index by using search ### New control state 1. If the batch contained > 0 documents - → `REINDEX_SOURCE_TO_TEMP_INDEX` + → `REINDEX_SOURCE_TO_TEMP_TRANSFORM` 2. If there are no more documents returned → `REINDEX_SOURCE_TO_TEMP_CLOSE_PIT` -## REINDEX_SOURCE_TO_TEMP_INDEX +## REINDEX_SOURCE_TO_TEMP_TRANSFORM ### Next action `transformRawDocs` @@ -357,7 +357,7 @@ documents. If another instance has a disabled plugin it will reindex that plugin's documents without transforming them. Because this instance doesn't know which plugins were disabled by the instance that performed the -`REINDEX_SOURCE_TO_TEMP_INDEX` step, we need to search for outdated documents +`REINDEX_SOURCE_TO_TEMP_TRANSFORM` step, we need to search for outdated documents and transform them to ensure that everything is up to date. ### New control state diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts index d4ad72491127..3a5e592a8b9b 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts @@ -13,7 +13,7 @@ import type { ElasticsearchClient } from '../../elasticsearch'; import { getErrorMessage, getRequestDebugMeta } from '../../elasticsearch'; import { Model, Next, stateActionMachine } from './state_action_machine'; import { cleanup } from './migrations_state_machine_cleanup'; -import { ReindexSourceToTempIndex, ReindexSourceToTempIndexBulk, State } from './types'; +import { ReindexSourceToTempTransform, ReindexSourceToTempIndexBulk, State } from './types'; import { SavedObjectsRawDoc } from '../serialization'; interface StateTransitionLogMeta extends LogMeta { @@ -115,7 +115,9 @@ export async function migrationStateActionMachine({ const redactedNewState = { ...newState, ...{ - outdatedDocuments: ((newState as ReindexSourceToTempIndex).outdatedDocuments ?? []).map( + outdatedDocuments: ( + (newState as ReindexSourceToTempTransform).outdatedDocuments ?? [] + ).map( (doc) => ({ _id: doc._id, diff --git a/src/core/server/saved_objects/migrationsv2/model/model.test.ts b/src/core/server/saved_objects/migrationsv2/model/model.test.ts index 033a18b48884..3e48a7147bff 100644 --- a/src/core/server/saved_objects/migrationsv2/model/model.test.ts +++ b/src/core/server/saved_objects/migrationsv2/model/model.test.ts @@ -20,7 +20,7 @@ import type { ReindexSourceToTempOpenPit, ReindexSourceToTempRead, ReindexSourceToTempClosePit, - ReindexSourceToTempIndex, + ReindexSourceToTempTransform, RefreshTarget, UpdateTargetMappingsState, UpdateTargetMappingsWaitForTaskState, @@ -962,7 +962,7 @@ describe('migrations v2 model', () => { progress: createInitialProgress(), }; - it('REINDEX_SOURCE_TO_TEMP_READ -> REINDEX_SOURCE_TO_TEMP_INDEX if the index has outdated documents to reindex', () => { + it('REINDEX_SOURCE_TO_TEMP_READ -> REINDEX_SOURCE_TO_TEMP_TRANSFORM if the index has outdated documents to reindex', () => { const outdatedDocuments = [{ _id: '1', _source: { type: 'vis' } }]; const lastHitSortValue = [123456]; const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_READ'> = Either.right({ @@ -970,8 +970,8 @@ describe('migrations v2 model', () => { lastHitSortValue, totalHits: 1, }); - const newState = model(state, res) as ReindexSourceToTempIndex; - expect(newState.controlState).toBe('REINDEX_SOURCE_TO_TEMP_INDEX'); + const newState = model(state, res) as ReindexSourceToTempTransform; + expect(newState.controlState).toBe('REINDEX_SOURCE_TO_TEMP_TRANSFORM'); expect(newState.outdatedDocuments).toBe(outdatedDocuments); expect(newState.lastHitSortValue).toBe(lastHitSortValue); expect(newState.progress.processed).toBe(undefined); @@ -1032,16 +1032,16 @@ describe('migrations v2 model', () => { it('REINDEX_SOURCE_TO_TEMP_CLOSE_PIT -> SET_TEMP_WRITE_BLOCK if action succeeded', () => { const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT'> = Either.right({}); - const newState = model(state, res) as ReindexSourceToTempIndex; + const newState = model(state, res) as ReindexSourceToTempTransform; expect(newState.controlState).toBe('SET_TEMP_WRITE_BLOCK'); expect(newState.sourceIndex).toEqual(state.sourceIndex); }); }); - describe('REINDEX_SOURCE_TO_TEMP_INDEX', () => { - const state: ReindexSourceToTempIndex = { + describe('REINDEX_SOURCE_TO_TEMP_TRANSFORM', () => { + const state: ReindexSourceToTempTransform = { ...baseState, - controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX', + controlState: 'REINDEX_SOURCE_TO_TEMP_TRANSFORM', outdatedDocuments: [], versionIndexReadyActions: Option.none, sourceIndex: Option.some('.kibana') as Option.Some, @@ -1059,8 +1059,8 @@ describe('migrations v2 model', () => { }, ] as SavedObjectsRawDoc[]; - it('REINDEX_SOURCE_TO_TEMP_INDEX -> REINDEX_SOURCE_TO_TEMP_INDEX_BULK if action succeeded', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.right({ + it('REINDEX_SOURCE_TO_TEMP_TRANSFORM -> REINDEX_SOURCE_TO_TEMP_INDEX_BULK if action succeeded', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_TRANSFORM'> = Either.right({ processedDocs, }); const newState = model(state, res) as ReindexSourceToTempIndexBulk; @@ -1071,7 +1071,7 @@ describe('migrations v2 model', () => { }); it('increments the progress.processed counter', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.right({ + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_TRANSFORM'> = Either.right({ processedDocs, }); @@ -1089,8 +1089,8 @@ describe('migrations v2 model', () => { expect(newState.progress.processed).toBe(2); }); - it('REINDEX_SOURCE_TO_TEMP_INDEX -> REINDEX_SOURCE_TO_TEMP_READ if action succeeded but we have carried through previous failures', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.right({ + it('REINDEX_SOURCE_TO_TEMP_TRANSFORM -> REINDEX_SOURCE_TO_TEMP_READ if action succeeded but we have carried through previous failures', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_TRANSFORM'> = Either.right({ processedDocs, }); const testState = { @@ -1098,15 +1098,15 @@ describe('migrations v2 model', () => { corruptDocumentIds: ['a:b'], transformErrors: [], }; - const newState = model(testState, res) as ReindexSourceToTempIndex; + const newState = model(testState, res) as ReindexSourceToTempTransform; expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_READ'); expect(newState.corruptDocumentIds.length).toEqual(1); expect(newState.transformErrors.length).toEqual(0); expect(newState.progress.processed).toBe(0); }); - it('REINDEX_SOURCE_TO_TEMP_INDEX -> REINDEX_SOURCE_TO_TEMP_READ when response is left documents_transform_failed', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.left({ + it('REINDEX_SOURCE_TO_TEMP_TRANSFORM -> REINDEX_SOURCE_TO_TEMP_READ when response is left documents_transform_failed', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_TRANSFORM'> = Either.left({ type: 'documents_transform_failed', corruptDocumentIds: ['a:b'], transformErrors: [], diff --git a/src/core/server/saved_objects/migrationsv2/model/model.ts b/src/core/server/saved_objects/migrationsv2/model/model.ts index 8aa3d7b83b29..5d8862e48df1 100644 --- a/src/core/server/saved_objects/migrationsv2/model/model.ts +++ b/src/core/server/saved_objects/migrationsv2/model/model.ts @@ -446,7 +446,7 @@ export const model = (currentState: State, resW: ResponseType): if (res.right.outdatedDocuments.length > 0) { return { ...stateP, - controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX', + controlState: 'REINDEX_SOURCE_TO_TEMP_TRANSFORM', outdatedDocuments: res.right.outdatedDocuments, lastHitSortValue: res.right.lastHitSortValue, progress, @@ -489,11 +489,11 @@ export const model = (currentState: State, resW: ResponseType): } else { throwBadResponse(stateP, res); } - } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_INDEX') { + } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_TRANSFORM') { // We follow a similar control flow as for // outdated document search -> outdated document transform -> transform documents bulk index // collecting issues along the way rather than failing - // REINDEX_SOURCE_TO_TEMP_INDEX handles the document transforms + // REINDEX_SOURCE_TO_TEMP_TRANSFORM handles the document transforms const res = resW as ExcludeRetryableEsError>; // Increment the processed documents, no matter what the results are. diff --git a/src/core/server/saved_objects/migrationsv2/next.ts b/src/core/server/saved_objects/migrationsv2/next.ts index 3f3714552725..433c0998f756 100644 --- a/src/core/server/saved_objects/migrationsv2/next.ts +++ b/src/core/server/saved_objects/migrationsv2/next.ts @@ -12,7 +12,7 @@ import type { ReindexSourceToTempOpenPit, ReindexSourceToTempRead, ReindexSourceToTempClosePit, - ReindexSourceToTempIndex, + ReindexSourceToTempTransform, MarkVersionIndexReady, InitState, LegacyCreateReindexTargetState, @@ -105,7 +105,7 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra }), REINDEX_SOURCE_TO_TEMP_CLOSE_PIT: (state: ReindexSourceToTempClosePit) => Actions.closePit({ client, pitId: state.sourceIndexPitId }), - REINDEX_SOURCE_TO_TEMP_INDEX: (state: ReindexSourceToTempIndex) => + REINDEX_SOURCE_TO_TEMP_TRANSFORM: (state: ReindexSourceToTempTransform) => Actions.transformDocs({ transformRawDocs, outdatedDocuments: state.outdatedDocuments }), REINDEX_SOURCE_TO_TEMP_INDEX_BULK: (state: ReindexSourceToTempIndexBulk) => Actions.bulkOverwriteTransformedDocuments({ diff --git a/src/core/server/saved_objects/migrationsv2/types.ts b/src/core/server/saved_objects/migrationsv2/types.ts index 49ce12c53aa1..4f6419930c6c 100644 --- a/src/core/server/saved_objects/migrationsv2/types.ts +++ b/src/core/server/saved_objects/migrationsv2/types.ts @@ -233,8 +233,8 @@ export interface ReindexSourceToTempClosePit extends PostInitState { readonly sourceIndexPitId: string; } -export interface ReindexSourceToTempIndex extends PostInitState { - readonly controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX'; +export interface ReindexSourceToTempTransform extends PostInitState { + readonly controlState: 'REINDEX_SOURCE_TO_TEMP_TRANSFORM'; readonly outdatedDocuments: SavedObjectsRawDoc[]; readonly sourceIndexPitId: string; readonly lastHitSortValue: number[] | undefined; @@ -434,7 +434,7 @@ export type State = Readonly< | ReindexSourceToTempOpenPit | ReindexSourceToTempRead | ReindexSourceToTempClosePit - | ReindexSourceToTempIndex + | ReindexSourceToTempTransform | ReindexSourceToTempIndexBulk | SetTempWriteBlock | CloneTempToSource diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx index 1ac9d680915c..c3b407569026 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx @@ -59,9 +59,7 @@ const createDashboardAppStateServices = () => { const defaults = makeDefaultServices(); const indexPatterns = {} as IndexPatternsContract; const defaultIndexPattern = { id: 'foo', fields: [{ name: 'bar' }] } as IIndexPattern; - indexPatterns.ensureDefaultIndexPattern = jest - .fn() - .mockImplementation(() => Promise.resolve(true)); + indexPatterns.ensureDefaultDataView = jest.fn().mockImplementation(() => Promise.resolve(true)); indexPatterns.getDefault = jest .fn() .mockImplementation(() => Promise.resolve(defaultIndexPattern)); diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts index 1b24062ccd9b..be600cb80214 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts @@ -237,27 +237,25 @@ export const useDashboardAppState = ({ .pipe(debounceTime(DashboardConstants.CHANGE_CHECK_DEBOUNCE)) .subscribe((states) => { const [lastSaved, current] = states; - const unsavedChanges = - current.viewMode === ViewMode.EDIT ? diffDashboardState(lastSaved, current) : {}; - - let savedTimeChanged = false; + const unsavedChanges = diffDashboardState(lastSaved, current); + + const savedTimeChanged = + lastSaved.timeRestore && + !areTimeRangesEqual( + { + from: savedDashboard?.timeFrom, + to: savedDashboard?.timeTo, + }, + timefilter.getTime() + ); /** - * changes to the time filter should only be considered 'unsaved changes' when + * changes to the dashboard should only be considered 'unsaved changes' when * editing the dashboard */ - if (current.viewMode === ViewMode.EDIT) { - savedTimeChanged = - lastSaved.timeRestore && - !areTimeRangesEqual( - { - from: savedDashboard?.timeFrom, - to: savedDashboard?.timeTo, - }, - timefilter.getTime() - ); - } - const hasUnsavedChanges = Object.keys(unsavedChanges).length > 0 || savedTimeChanged; + const hasUnsavedChanges = + current.viewMode === ViewMode.EDIT && + (Object.keys(unsavedChanges).length > 0 || savedTimeChanged); setDashboardAppState((s) => ({ ...s, hasUnsavedChanges })); unsavedChanges.viewMode = current.viewMode; // always push view mode into session store. diff --git a/src/plugins/dashboard/public/application/lib/dashboard_session_storage.ts b/src/plugins/dashboard/public/application/lib/dashboard_session_storage.ts index 7d0e60c0609a..a696c8bc15b8 100644 --- a/src/plugins/dashboard/public/application/lib/dashboard_session_storage.ts +++ b/src/plugins/dashboard/public/application/lib/dashboard_session_storage.ts @@ -11,6 +11,7 @@ import { Storage } from '../../services/kibana_utils'; import { NotificationsStart } from '../../services/core'; import { panelStorageErrorStrings } from '../../dashboard_strings'; import { DashboardState } from '../../types'; +import { ViewMode } from '../../services/embeddable'; export const DASHBOARD_PANELS_UNSAVED_ID = 'unsavedDashboard'; const DASHBOARD_PANELS_SESSION_KEY = 'dashboardStateManagerPanels'; @@ -69,6 +70,7 @@ export class DashboardSessionStorage { const dashboardsWithUnsavedChanges: string[] = []; Object.keys(dashboardStatesInSpace).map((dashboardId) => { if ( + dashboardStatesInSpace[dashboardId].viewMode === ViewMode.EDIT && Object.keys(dashboardStatesInSpace[dashboardId]).some( (stateKey) => stateKey !== 'viewMode' ) diff --git a/src/plugins/dashboard/public/application/lib/load_saved_dashboard_state.ts b/src/plugins/dashboard/public/application/lib/load_saved_dashboard_state.ts index 04461a46ad0d..3913608c6bef 100644 --- a/src/plugins/dashboard/public/application/lib/load_saved_dashboard_state.ts +++ b/src/plugins/dashboard/public/application/lib/load_saved_dashboard_state.ts @@ -51,7 +51,7 @@ export const loadSavedDashboardState = async ({ notifications.toasts.addWarning(getDashboard60Warning()); return; } - await indexPatterns.ensureDefaultIndexPattern(); + await indexPatterns.ensureDefaultDataView(); let savedDashboard: DashboardSavedObject | undefined; try { savedDashboard = (await savedDashboards.get(savedDashboardId)) as DashboardSavedObject; diff --git a/src/plugins/dashboard/public/application/listing/dashboard_no_match.tsx b/src/plugins/dashboard/public/application/listing/dashboard_no_match.tsx index 03f7b0e16222..91361836f59d 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_no_match.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_no_match.tsx @@ -23,9 +23,8 @@ export const DashboardNoMatch = ({ history }: { history: RouteComponentProps['hi useEffect(() => { services.restorePreviousUrl(); - const { navigated } = services.urlForwarding.navigateToLegacyKibanaUrl( - history.location.pathname + history.location.pathname + history.location.search ); if (!navigated) { diff --git a/src/plugins/dashboard/server/saved_objects/dashboard.ts b/src/plugins/dashboard/server/saved_objects/dashboard.ts index dfda251e2877..068883c429e6 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard.ts @@ -27,9 +27,6 @@ export const createDashboardSavedObjectType = ({ getTitle(obj) { return obj.attributes.title; }, - getEditUrl(obj) { - return `/management/kibana/objects/savedDashboards/${encodeURIComponent(obj.id)}`; - }, getInAppUrl(obj) { return { path: `/app/dashboards#/view/${encodeURIComponent(obj.id)}`, diff --git a/src/plugins/data/common/data_views/index_pattern.stub.ts b/src/plugins/data/common/data_views/data_view.stub.ts similarity index 59% rename from src/plugins/data/common/data_views/index_pattern.stub.ts rename to src/plugins/data/common/data_views/data_view.stub.ts index 16624087f83b..a3279434c7a0 100644 --- a/src/plugins/data/common/data_views/index_pattern.stub.ts +++ b/src/plugins/data/common/data_views/data_view.stub.ts @@ -7,12 +7,15 @@ */ import { stubFieldSpecMap, stubLogstashFieldSpecMap } from './field.stub'; -import { createStubIndexPattern } from './data_views/index_pattern.stub'; -export { createStubIndexPattern } from './data_views/index_pattern.stub'; +import { createStubDataView } from './data_views/data_view.stub'; +export { + createStubDataView, + createStubDataView as createStubIndexPattern, +} from './data_views/data_view.stub'; import { SavedObject } from '../../../../core/types'; -import { IndexPatternAttributes } from '../types'; +import { DataViewAttributes } from '../types'; -export const stubIndexPattern = createStubIndexPattern({ +export const stubDataView = createStubDataView({ spec: { id: 'logstash-*', fields: stubFieldSpecMap, @@ -21,7 +24,9 @@ export const stubIndexPattern = createStubIndexPattern({ }, }); -export const stubIndexPatternWithoutTimeField = createStubIndexPattern({ +export const stubIndexPattern = stubDataView; + +export const stubDataViewWithoutTimeField = createStubDataView({ spec: { id: 'logstash-*', fields: stubFieldSpecMap, @@ -29,7 +34,9 @@ export const stubIndexPatternWithoutTimeField = createStubIndexPattern({ }, }); -export const stubLogstashIndexPattern = createStubIndexPattern({ +export const stubIndexPatternWithoutTimeField = stubDataViewWithoutTimeField; + +export const stubLogstashDataView = createStubDataView({ spec: { id: 'logstash-*', title: 'logstash-*', @@ -38,9 +45,11 @@ export const stubLogstashIndexPattern = createStubIndexPattern({ }, }); -export function stubbedSavedObjectIndexPattern( +export const stubLogstashIndexPattern = stubLogstashDataView; + +export function stubbedSavedObjectDataView( id: string | null = null -): SavedObject { +): SavedObject { return { id: id ?? '', type: 'index-pattern', @@ -53,3 +62,5 @@ export function stubbedSavedObjectIndexPattern( references: [], }; } + +export const stubbedSavedObjectIndexPattern = stubbedSavedObjectDataView; diff --git a/src/plugins/data/common/data_views/data_views/__snapshots__/index_pattern.test.ts.snap b/src/plugins/data/common/data_views/data_views/__snapshots__/data_view.test.ts.snap similarity index 100% rename from src/plugins/data/common/data_views/data_views/__snapshots__/index_pattern.test.ts.snap rename to src/plugins/data/common/data_views/data_views/__snapshots__/data_view.test.ts.snap diff --git a/src/plugins/data/common/data_views/data_views/__snapshots__/index_patterns.test.ts.snap b/src/plugins/data/common/data_views/data_views/__snapshots__/data_views.test.ts.snap similarity index 100% rename from src/plugins/data/common/data_views/data_views/__snapshots__/index_patterns.test.ts.snap rename to src/plugins/data/common/data_views/data_views/__snapshots__/data_views.test.ts.snap diff --git a/src/plugins/data/common/data_views/data_views/_pattern_cache.ts b/src/plugins/data/common/data_views/data_views/_pattern_cache.ts index f304d0e93d79..19db5b21e593 100644 --- a/src/plugins/data/common/data_views/data_views/_pattern_cache.ts +++ b/src/plugins/data/common/data_views/data_views/_pattern_cache.ts @@ -16,12 +16,12 @@ export interface DataViewCache { } export function createDataViewCache(): DataViewCache { - const vals: Record = {}; + const vals: Record> = {}; const cache: DataViewCache = { get: (id: string) => { return vals[id]; }, - set: (id: string, prom: any) => { + set: (id: string, prom: Promise) => { vals[id] = prom; return prom; }, diff --git a/src/plugins/data/common/data_views/data_views/index_pattern.stub.ts b/src/plugins/data/common/data_views/data_views/data_view.stub.ts similarity index 85% rename from src/plugins/data/common/data_views/data_views/index_pattern.stub.ts rename to src/plugins/data/common/data_views/data_views/data_view.stub.ts index 3b6660c6d93d..5ff2d077812a 100644 --- a/src/plugins/data/common/data_views/data_views/index_pattern.stub.ts +++ b/src/plugins/data/common/data_views/data_views/data_view.stub.ts @@ -6,18 +6,18 @@ * Side Public License, v 1. */ -import { IndexPattern } from './data_view'; +import { DataView } from './data_view'; import { DataViewSpec } from '../types'; import { FieldFormatsStartCommon } from '../../../../field_formats/common'; import { fieldFormatsMock } from '../../../../field_formats/common/mocks'; /** - * Create a custom stub index pattern. Use it in your unit tests where an {@link IndexPattern} expected. + * Create a custom stub index pattern. Use it in your unit tests where an {@link DataView} expected. * @param spec - Serialized index pattern object * @param opts - Specify index pattern options * @param deps - Optionally provide dependencies, you can provide a custom field formats implementation, by default a dummy mock is used * - * @returns - an {@link IndexPattern} instance + * @returns - an {@link DataView} instance * * * @example @@ -32,7 +32,7 @@ import { fieldFormatsMock } from '../../../../field_formats/common/mocks'; * * ``` */ -export const createStubIndexPattern = ({ +export const createStubDataView = ({ spec, opts, deps, @@ -45,12 +45,10 @@ export const createStubIndexPattern = ({ deps?: { fieldFormats?: FieldFormatsStartCommon; }; -}): IndexPattern => { - const indexPattern = new IndexPattern({ +}): DataView => + new DataView({ spec, metaFields: opts?.metaFields ?? ['_id', '_type', '_source'], shortDotsEnable: opts?.shortDotsEnable, fieldFormats: deps?.fieldFormats ?? fieldFormatsMock, }); - return indexPattern; -}; diff --git a/src/plugins/data/common/data_views/data_views/index_pattern.test.ts b/src/plugins/data/common/data_views/data_views/data_view.test.ts similarity index 99% rename from src/plugins/data/common/data_views/data_views/index_pattern.test.ts rename to src/plugins/data/common/data_views/data_views/data_view.test.ts index 5fd1d0d051ac..6aea86a7adae 100644 --- a/src/plugins/data/common/data_views/data_views/index_pattern.test.ts +++ b/src/plugins/data/common/data_views/data_views/data_view.test.ts @@ -18,7 +18,7 @@ import { fieldFormatsMock } from '../../../../field_formats/common/mocks'; import { FieldFormat } from '../../../../field_formats/common'; import { RuntimeField } from '../types'; import { stubLogstashFields } from '../field.stub'; -import { stubbedSavedObjectIndexPattern } from '../index_pattern.stub'; +import { stubbedSavedObjectIndexPattern } from '../data_view.stub'; class MockFieldFormatter {} diff --git a/src/plugins/data/common/data_views/data_views/data_view.ts b/src/plugins/data/common/data_views/data_views/data_view.ts index e08d1e62bae0..c61f5f7f31e3 100644 --- a/src/plugins/data/common/data_views/data_views/data_view.ts +++ b/src/plugins/data/common/data_views/data_views/data_view.ts @@ -158,7 +158,7 @@ export class DataView implements IIndexPattern { }; getComputedFields() { - const scriptFields: any = {}; + const scriptFields: Record = {}; if (!this.fields) { return { storedFields: ['*'], @@ -170,24 +170,22 @@ export class DataView implements IIndexPattern { // Date value returned in "_source" could be in any number of formats // Use a docvalue for each date field to ensure standardized formats when working with date fields - // indexPattern.flattenHit will override "_source" values when the same field is also defined in "fields" - const docvalueFields = reject(this.fields.getByType('date'), 'scripted').map( - (dateField: any) => { - return { - field: dateField.name, - format: - dateField.esTypes && dateField.esTypes.indexOf('date_nanos') !== -1 - ? 'strict_date_time' - : 'date_time', - }; - } - ); + // dataView.flattenHit will override "_source" values when the same field is also defined in "fields" + const docvalueFields = reject(this.fields.getByType('date'), 'scripted').map((dateField) => { + return { + field: dateField.name, + format: + dateField.esTypes && dateField.esTypes.indexOf('date_nanos') !== -1 + ? 'strict_date_time' + : 'date_time', + }; + }); each(this.getScriptedFields(), function (field) { scriptFields[field.name] = { script: { - source: field.script, - lang: field.lang, + source: field.script as string, + lang: field.lang as string, }, }; }); @@ -227,7 +225,7 @@ export class DataView implements IIndexPattern { */ getSourceFiltering() { return { - excludes: (this.sourceFilters && this.sourceFilters.map((filter: any) => filter.value)) || [], + excludes: (this.sourceFilters && this.sourceFilters.map((filter) => filter.value)) || [], }; } @@ -299,8 +297,8 @@ export class DataView implements IIndexPattern { } isTimeNanosBased(): boolean { - const timeField: any = this.getTimeField(); - return timeField && timeField.esTypes && timeField.esTypes.indexOf('date_nanos') !== -1; + const timeField = this.getTimeField(); + return !!(timeField && timeField.esTypes && timeField.esTypes.indexOf('date_nanos') !== -1); } getTimeField() { diff --git a/src/plugins/data/common/data_views/data_views/index_patterns.test.ts b/src/plugins/data/common/data_views/data_views/data_views.test.ts similarity index 99% rename from src/plugins/data/common/data_views/data_views/index_patterns.test.ts rename to src/plugins/data/common/data_views/data_views/data_views.test.ts index 996700b3c911..ef9381f16d93 100644 --- a/src/plugins/data/common/data_views/data_views/index_patterns.test.ts +++ b/src/plugins/data/common/data_views/data_views/data_views.test.ts @@ -11,7 +11,7 @@ import { DataViewsService, DataView } from '.'; import { fieldFormatsMock } from '../../../../field_formats/common/mocks'; import { UiSettingsCommon, SavedObjectsClientCommon, SavedObject } from '../types'; -import { stubbedSavedObjectIndexPattern } from '../index_pattern.stub'; +import { stubbedSavedObjectIndexPattern } from '../data_view.stub'; const createFieldsFetcher = jest.fn().mockImplementation(() => ({ getFieldsForWildcard: jest.fn().mockImplementation(() => { diff --git a/src/plugins/data/common/data_views/data_views/data_views.ts b/src/plugins/data/common/data_views/data_views/data_views.ts index 1284f0043632..f9b193d15477 100644 --- a/src/plugins/data/common/data_views/data_views/data_views.ts +++ b/src/plugins/data/common/data_views/data_views/data_views.ts @@ -77,9 +77,9 @@ export class DataViewsService { private fieldFormats: FieldFormatsStartCommon; private onNotification: OnNotification; private onError: OnError; - private indexPatternCache: ReturnType; + private dataViewCache: ReturnType; - ensureDefaultIndexPattern: EnsureDefaultDataView; + ensureDefaultDataView: EnsureDefaultDataView; constructor({ uiSettings, @@ -96,12 +96,9 @@ export class DataViewsService { this.fieldFormats = fieldFormats; this.onNotification = onNotification; this.onError = onError; - this.ensureDefaultIndexPattern = createEnsureDefaultDataView( - uiSettings, - onRedirectNoIndexPattern - ); + this.ensureDefaultDataView = createEnsureDefaultDataView(uiSettings, onRedirectNoIndexPattern); - this.indexPatternCache = createDataViewCache(); + this.dataViewCache = createDataViewCache(); } /** @@ -190,9 +187,9 @@ export class DataViewsService { clearCache = (id?: string) => { this.savedObjectsCache = null; if (id) { - this.indexPatternCache.clear(id); + this.dataViewCache.clear(id); } else { - this.indexPatternCache.clearAll(); + this.dataViewCache.clearAll(); } }; @@ -289,7 +286,7 @@ export class DataViewsService { indexPattern.fields.replaceAll(fieldsWithSavedAttrs); } catch (err) { if (err instanceof DataViewMissingIndices) { - this.onNotification({ title: (err as any).message, color: 'danger', iconType: 'alert' }); + this.onNotification({ title: err.message, color: 'danger', iconType: 'alert' }); } this.onError(err, { @@ -334,7 +331,7 @@ export class DataViewsService { return this.fieldArrayToMap(updatedFieldList, fieldAttrs); } catch (err) { if (err instanceof DataViewMissingIndices) { - this.onNotification({ title: (err as any).message, color: 'danger', iconType: 'alert' }); + this.onNotification({ title: err.message, color: 'danger', iconType: 'alert' }); return {}; } @@ -475,7 +472,7 @@ export class DataViewsService { } catch (err) { if (err instanceof DataViewMissingIndices) { this.onNotification({ - title: (err as any).message, + title: err.message, color: 'danger', iconType: 'alert', }); @@ -505,12 +502,11 @@ export class DataViewsService { get = async (id: string): Promise => { const indexPatternPromise = - this.indexPatternCache.get(id) || - this.indexPatternCache.set(id, this.getSavedObjectAndInit(id)); + this.dataViewCache.get(id) || this.dataViewCache.set(id, this.getSavedObjectAndInit(id)); // don't cache failed requests indexPatternPromise.catch(() => { - this.indexPatternCache.clear(id); + this.dataViewCache.clear(id); }); return indexPatternPromise; @@ -580,7 +576,7 @@ export class DataViewsService { )) as SavedObject; const createdIndexPattern = await this.initFromSavedObject(response); - this.indexPatternCache.set(createdIndexPattern.id!, Promise.resolve(createdIndexPattern)); + this.dataViewCache.set(createdIndexPattern.id!, Promise.resolve(createdIndexPattern)); if (this.savedObjectsCache) { this.savedObjectsCache.push(response as SavedObject); } @@ -668,7 +664,7 @@ export class DataViewsService { indexPattern.version = samePattern.version; // Clear cache - this.indexPatternCache.clear(indexPattern.id!); + this.dataViewCache.clear(indexPattern.id!); // Try the save again return this.updateSavedObject(indexPattern, saveAttempts, ignoreErrors); @@ -682,7 +678,7 @@ export class DataViewsService { * @param indexPatternId: Id of kibana Index Pattern to delete */ async delete(indexPatternId: string) { - this.indexPatternCache.clear(indexPatternId); + this.dataViewCache.clear(indexPatternId); return this.savedObjectsClient.delete(DATA_VIEW_SAVED_OBJECT_TYPE, indexPatternId); } } diff --git a/src/plugins/data/common/data_views/data_views/flatten_hit.test.ts b/src/plugins/data/common/data_views/data_views/flatten_hit.test.ts index f8e1309a38ff..73232a65b6b7 100644 --- a/src/plugins/data/common/data_views/data_views/flatten_hit.test.ts +++ b/src/plugins/data/common/data_views/data_views/flatten_hit.test.ts @@ -6,11 +6,11 @@ * Side Public License, v 1. */ -import { IndexPattern } from './data_view'; +import { DataView } from './data_view'; import { fieldFormatsMock } from '../../../../field_formats/common/mocks'; import { flattenHitWrapper } from './flatten_hit'; -import { stubbedSavedObjectIndexPattern } from '../index_pattern.stub'; +import { stubbedSavedObjectIndexPattern } from '../data_view.stub'; class MockFieldFormatter {} @@ -24,7 +24,7 @@ function create(id: string) { attributes: { timeFieldName, fields, title }, } = stubbedSavedObjectIndexPattern(id); - return new IndexPattern({ + return new DataView({ spec: { id, type, @@ -41,7 +41,7 @@ function create(id: string) { } describe('flattenHit', () => { - let indexPattern: IndexPattern; + let indexPattern: DataView; // create an indexPattern instance for each test beforeEach(() => { diff --git a/src/plugins/data/common/data_views/data_views/flatten_hit.ts b/src/plugins/data/common/data_views/data_views/flatten_hit.ts index 58a5dff66acc..ddf484affa29 100644 --- a/src/plugins/data/common/data_views/data_views/flatten_hit.ts +++ b/src/plugins/data/common/data_views/data_views/flatten_hit.ts @@ -103,11 +103,11 @@ function decorateFlattenedWrapper(hit: Record, metaFields: Record, deep = false) { const decorateFlattened = decorateFlattenedWrapper(hit, metaFields); const cached = cache.get(hit); - const flattened = cached || flattenHit(indexPattern, hit, deep); + const flattened = cached || flattenHit(dataView, hit, deep); if (!cached) { cache.set(hit, { ...flattened }); } diff --git a/src/plugins/data/common/data_views/data_views/format_hit.ts b/src/plugins/data/common/data_views/data_views/format_hit.ts index b22601375262..39f7fef564eb 100644 --- a/src/plugins/data/common/data_views/data_views/format_hit.ts +++ b/src/plugins/data/common/data_views/data_views/format_hit.ts @@ -15,24 +15,24 @@ const partialFormattedCache = new WeakMap(); // Takes a hit, merges it with any stored/scripted fields, and with the metaFields // returns a formatted version -export function formatHitProvider(indexPattern: DataView, defaultFormat: any) { +export function formatHitProvider(dataView: DataView, defaultFormat: any) { function convert( hit: Record, val: any, fieldName: string, type: FieldFormatsContentType = 'html' ) { - const field = indexPattern.fields.getByName(fieldName); - const format = field ? indexPattern.getFormatterForField(field) : defaultFormat; + const field = dataView.fields.getByName(fieldName); + const format = field ? dataView.getFormatterForField(field) : defaultFormat; - return format.convert(val, type, { field, hit, indexPattern }); + return format.convert(val, type, { field, hit, indexPattern: dataView }); } function formatHit(hit: Record, type: string = 'html') { if (type === 'text') { // formatHit of type text is for react components to get rid of // since it's currently just used at the discover's doc view table, caching is not necessary - const flattened = indexPattern.flattenHit(hit); + const flattened = dataView.flattenHit(hit); const result: Record = {}; for (const [key, value] of Object.entries(flattened)) { result[key] = convert(hit, value, key, type); @@ -53,7 +53,7 @@ export function formatHitProvider(indexPattern: DataView, defaultFormat: any) { const cache: Record = {}; formattedCache.set(hit, cache); - _.forOwn(indexPattern.flattenHit(hit), function (val: any, fieldName?: string) { + _.forOwn(dataView.flattenHit(hit), function (val: any, fieldName?: string) { // sync the formatted and partial cache if (!fieldName) { return; @@ -77,7 +77,7 @@ export function formatHitProvider(indexPattern: DataView, defaultFormat: any) { partialFormattedCache.set(hit, partials); } - const val = fieldName === '_source' ? hit._source : indexPattern.flattenHit(hit)[fieldName]; + const val = fieldName === '_source' ? hit._source : dataView.flattenHit(hit)[fieldName]; return convert(hit, val, fieldName); }; diff --git a/src/plugins/data/common/data_views/errors/duplicate_index_pattern.ts b/src/plugins/data/common/data_views/errors/duplicate_index_pattern.ts index d35b09e39aa7..942c104eee4e 100644 --- a/src/plugins/data/common/data_views/errors/duplicate_index_pattern.ts +++ b/src/plugins/data/common/data_views/errors/duplicate_index_pattern.ts @@ -9,6 +9,6 @@ export class DuplicateDataViewError extends Error { constructor(message: string) { super(message); - this.name = 'DuplicateIndexPatternError'; + this.name = 'DuplicateDataViewError'; } } diff --git a/src/plugins/data/common/data_views/field.stub.ts b/src/plugins/data/common/data_views/field.stub.ts index 03bb0dee33db..7ff51007bcef 100644 --- a/src/plugins/data/common/data_views/field.stub.ts +++ b/src/plugins/data/common/data_views/field.stub.ts @@ -6,10 +6,10 @@ * Side Public License, v 1. */ -import { FieldSpec, IndexPatternField } from '.'; +import { FieldSpec, DataViewField } from '.'; -export const createIndexPatternFieldStub = ({ spec }: { spec: FieldSpec }): IndexPatternField => { - return new IndexPatternField(spec); +export const createIndexPatternFieldStub = ({ spec }: { spec: FieldSpec }): DataViewField => { + return new DataViewField(spec); }; export const stubFieldSpecMap: Record = { @@ -71,7 +71,7 @@ export const stubFieldSpecMap: Record = { }, }; -export const stubFields: IndexPatternField[] = Object.values(stubFieldSpecMap).map((spec) => +export const stubFields: DataViewField[] = Object.values(stubFieldSpecMap).map((spec) => createIndexPatternFieldStub({ spec }) ); @@ -404,6 +404,6 @@ export const stubLogstashFieldSpecMap: Record = { }, }; -export const stubLogstashFields: IndexPatternField[] = Object.values(stubLogstashFieldSpecMap).map( +export const stubLogstashFields: DataViewField[] = Object.values(stubLogstashFieldSpecMap).map( (spec) => createIndexPatternFieldStub({ spec }) ); diff --git a/src/plugins/data/common/data_views/fields/__snapshots__/index_pattern_field.test.ts.snap b/src/plugins/data/common/data_views/fields/__snapshots__/data_view_field.test.ts.snap similarity index 100% rename from src/plugins/data/common/data_views/fields/__snapshots__/index_pattern_field.test.ts.snap rename to src/plugins/data/common/data_views/fields/__snapshots__/data_view_field.test.ts.snap diff --git a/src/plugins/data/common/data_views/fields/index_pattern_field.test.ts b/src/plugins/data/common/data_views/fields/data_view_field.test.ts similarity index 98% rename from src/plugins/data/common/data_views/fields/index_pattern_field.test.ts rename to src/plugins/data/common/data_views/fields/data_view_field.test.ts index 906cb0ad1bad..9107036c15c1 100644 --- a/src/plugins/data/common/data_views/fields/index_pattern_field.test.ts +++ b/src/plugins/data/common/data_views/fields/data_view_field.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { IndexPatternField } from './index_pattern_field'; +import { IndexPatternField } from './data_view_field'; import { IndexPattern } from '..'; import { KBN_FIELD_TYPES } from '../../../common'; import { FieldSpec, RuntimeField } from '../types'; diff --git a/src/plugins/data/common/data_views/fields/index_pattern_field.ts b/src/plugins/data/common/data_views/fields/data_view_field.ts similarity index 100% rename from src/plugins/data/common/data_views/fields/index_pattern_field.ts rename to src/plugins/data/common/data_views/fields/data_view_field.ts diff --git a/src/plugins/data/common/data_views/fields/field_list.ts b/src/plugins/data/common/data_views/fields/field_list.ts index 8dd407e16e4c..e2c850c0c4dd 100644 --- a/src/plugins/data/common/data_views/fields/field_list.ts +++ b/src/plugins/data/common/data_views/fields/field_list.ts @@ -8,7 +8,7 @@ import { findIndex } from 'lodash'; import { IFieldType } from './types'; -import { DataViewField } from './index_pattern_field'; +import { DataViewField } from './data_view_field'; import { FieldSpec, DataViewFieldMap } from '../types'; import { DataView } from '../data_views'; diff --git a/src/plugins/data/common/data_views/fields/index.ts b/src/plugins/data/common/data_views/fields/index.ts index 53c8ed213cda..0ff7397c4f7b 100644 --- a/src/plugins/data/common/data_views/fields/index.ts +++ b/src/plugins/data/common/data_views/fields/index.ts @@ -9,4 +9,4 @@ export * from './types'; export { isFilterable, isNestedField } from './utils'; export * from './field_list'; -export * from './index_pattern_field'; +export * from './data_view_field'; diff --git a/src/plugins/data/common/data_views/lib/get_title.ts b/src/plugins/data/common/data_views/lib/get_title.ts index efebbc302f22..94185eae4689 100644 --- a/src/plugins/data/common/data_views/lib/get_title.ts +++ b/src/plugins/data/common/data_views/lib/get_title.ts @@ -6,17 +6,18 @@ * Side Public License, v 1. */ -import { SavedObjectsClientContract, SimpleSavedObject } from '../../../../../core/public'; +import { SavedObjectsClientContract } from '../../../../../core/public'; import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../constants'; +import { DataViewAttributes } from '../types'; export async function getTitle( client: SavedObjectsClientContract, indexPatternId: string -): Promise> { - const savedObject = (await client.get( +): Promise { + const savedObject = await client.get( DATA_VIEW_SAVED_OBJECT_TYPE, indexPatternId - )) as SimpleSavedObject; + ); if (savedObject.error) { throw new Error(`Unable to get index-pattern title: ${savedObject.error.message}`); diff --git a/src/plugins/data/common/data_views/lib/index.ts b/src/plugins/data/common/data_views/lib/index.ts index ae59c7d41781..0554232e64ca 100644 --- a/src/plugins/data/common/data_views/lib/index.ts +++ b/src/plugins/data/common/data_views/lib/index.ts @@ -8,7 +8,6 @@ export { DataViewMissingIndices } from './errors'; export { getTitle } from './get_title'; -export { isDefault } from './is_default'; export * from './types'; -export { validateDataView } from './validate_index_pattern'; +export { validateDataView } from './validate_data_view'; diff --git a/src/plugins/data/common/data_views/lib/is_default.ts b/src/plugins/data/common/data_views/lib/is_default.ts deleted file mode 100644 index 5a50d2862c58..000000000000 --- a/src/plugins/data/common/data_views/lib/is_default.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { IIndexPattern } from '../..'; - -export const isDefault = (indexPattern: IIndexPattern) => { - // Default index patterns don't have `type` defined. - return !indexPattern.type; -}; diff --git a/src/plugins/data/common/data_views/lib/validate_index_pattern.test.ts b/src/plugins/data/common/data_views/lib/validate_data_view.test.ts similarity index 94% rename from src/plugins/data/common/data_views/lib/validate_index_pattern.test.ts rename to src/plugins/data/common/data_views/lib/validate_data_view.test.ts index ed90da122484..edf20440931e 100644 --- a/src/plugins/data/common/data_views/lib/validate_index_pattern.test.ts +++ b/src/plugins/data/common/data_views/lib/validate_data_view.test.ts @@ -8,7 +8,7 @@ import { CONTAINS_SPACES_KEY, ILLEGAL_CHARACTERS_KEY, ILLEGAL_CHARACTERS_VISIBLE } from './types'; -import { validateDataView } from './validate_index_pattern'; +import { validateDataView } from './validate_data_view'; describe('Index Pattern Utils', () => { describe('Validation', () => { diff --git a/src/plugins/data/common/data_views/lib/validate_index_pattern.ts b/src/plugins/data/common/data_views/lib/validate_data_view.ts similarity index 92% rename from src/plugins/data/common/data_views/lib/validate_index_pattern.ts rename to src/plugins/data/common/data_views/lib/validate_data_view.ts index 454d0bc1a0c6..f86ba28e7cde 100644 --- a/src/plugins/data/common/data_views/lib/validate_index_pattern.ts +++ b/src/plugins/data/common/data_views/lib/validate_data_view.ts @@ -24,7 +24,7 @@ function findIllegalCharacters(indexPattern: string): string[] { } export function validateDataView(indexPattern: string) { - const errors: Record = {}; + const errors: { [ILLEGAL_CHARACTERS_KEY]?: string[]; [CONTAINS_SPACES_KEY]?: boolean } = {}; const illegalCharacters = findIllegalCharacters(indexPattern); diff --git a/src/plugins/data/common/data_views/mocks.ts b/src/plugins/data/common/data_views/mocks.ts index 6e82118f7b8b..9585b6e60f92 100644 --- a/src/plugins/data/common/data_views/mocks.ts +++ b/src/plugins/data/common/data_views/mocks.ts @@ -7,4 +7,4 @@ */ export * from './fields/fields.mocks'; -export * from './data_views/index_pattern.stub'; +export * from './data_views/data_view.stub'; diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts index 215d3ce13f55..7ec176d7ab11 100644 --- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts @@ -377,6 +377,86 @@ describe('Terms Agg Other bucket helper', () => { } }); + test('correctly builds query for nested terms agg with one disabled', () => { + const oneDisabledNestedTerms = { + aggs: [ + { + id: '2', + type: BUCKET_TYPES.TERMS, + enabled: false, + params: { + field: { + name: 'machine.os.raw', + indexPattern, + filterable: true, + }, + size: 2, + otherBucket: false, + missingBucket: true, + }, + }, + { + id: '1', + type: BUCKET_TYPES.TERMS, + params: { + field: { + name: 'geo.src', + indexPattern, + filterable: true, + }, + size: 2, + otherBucket: true, + missingBucket: false, + }, + }, + ], + }; + const aggConfigs = getAggConfigs(oneDisabledNestedTerms.aggs); + const agg = buildOtherBucketAgg( + aggConfigs, + aggConfigs.aggs[1] as IBucketAggConfig, + singleTermResponse + ); + const expectedResponse = { + 'other-filter': { + aggs: undefined, + filters: { + filters: { + '': { + bool: { + filter: [ + { + exists: { + field: 'geo.src', + }, + }, + ], + must: [], + must_not: [ + { + match_phrase: { + 'geo.src': 'ios', + }, + }, + { + match_phrase: { + 'geo.src': 'win xp', + }, + }, + ], + should: [], + }, + }, + }, + }, + }, + }; + expect(agg).toBeDefined(); + if (agg) { + expect(agg()).toEqual(expectedResponse); + } + }); + test('does not build query if sum_other_doc_count is 0 (exhaustive terms)', () => { const aggConfigs = getAggConfigs(nestedTerm.aggs); expect( diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts index 39fba23a4221..436cc5614ac8 100644 --- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts +++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts @@ -129,7 +129,9 @@ export const buildOtherBucketAgg = ( aggWithOtherBucket: IBucketAggConfig, response: any ) => { - const bucketAggs = aggConfigs.aggs.filter((agg) => agg.type.type === AggGroupNames.Buckets); + const bucketAggs = aggConfigs.aggs.filter( + (agg) => agg.type.type === AggGroupNames.Buckets && agg.enabled + ); const index = bucketAggs.findIndex((agg) => agg.id === aggWithOtherBucket.id); const aggs = aggConfigs.toDsl(); const indexPattern = aggWithOtherBucket.aggConfigs.indexPattern; diff --git a/src/plugins/data/common/stubs.ts b/src/plugins/data/common/stubs.ts index 36bd3357e709..5cddcf397f44 100644 --- a/src/plugins/data/common/stubs.ts +++ b/src/plugins/data/common/stubs.ts @@ -7,5 +7,5 @@ */ export * from './data_views/field.stub'; -export * from './data_views/index_pattern.stub'; +export * from './data_views/data_view.stub'; export * from './es_query/stubs'; diff --git a/src/plugins/data/public/data_views/data_views/index_pattern.stub.ts b/src/plugins/data/public/data_views/data_views/data_view.stub.ts similarity index 87% rename from src/plugins/data/public/data_views/data_views/index_pattern.stub.ts rename to src/plugins/data/public/data_views/data_views/data_view.stub.ts index 49d31def9238..b3d8448064c6 100644 --- a/src/plugins/data/public/data_views/data_views/index_pattern.stub.ts +++ b/src/plugins/data/public/data_views/data_views/data_view.stub.ts @@ -10,15 +10,15 @@ import { CoreSetup } from 'kibana/public'; import { FieldFormatsStartCommon } from '../../../../field_formats/common'; import { getFieldFormatsRegistry } from '../../../../field_formats/public/mocks'; import * as commonStubs from '../../../common/stubs'; -import { IndexPattern, IndexPatternSpec } from '../../../common'; +import { DataView, DataViewSpec } from '../../../common'; import { coreMock } from '../../../../../core/public/mocks'; /** - * Create a custom stub index pattern. Use it in your unit tests where an {@link IndexPattern} expected. + * Create a custom stub index pattern. Use it in your unit tests where an {@link DataView} expected. * @param spec - Serialized index pattern object * @param opts - Specify index pattern options * @param deps - Optionally provide dependencies, you can provide a custom field formats implementation, by default client side registry with real formatters implementation is used * - * @returns - an {@link IndexPattern} instance + * @returns - an {@link DataView} instance * * @remark - This is a client side version, a browser-agnostic version is available in {@link commonStubs | common}. * The main difference is that client side version by default uses client side field formats service, where common version uses a dummy field formats mock. @@ -35,12 +35,12 @@ import { coreMock } from '../../../../../core/public/mocks'; * * ``` */ -export const createStubIndexPattern = ({ +export const createStubDataView = ({ spec, opts, deps, }: { - spec: IndexPatternSpec; + spec: DataViewSpec; opts?: { shortDotsEnable?: boolean; metaFields?: string[]; @@ -49,8 +49,8 @@ export const createStubIndexPattern = ({ fieldFormats?: FieldFormatsStartCommon; core?: CoreSetup; }; -}): IndexPattern => { - return commonStubs.createStubIndexPattern({ +}): DataView => { + return commonStubs.createStubDataView({ spec, opts, deps: { diff --git a/src/plugins/data/public/data_views/data_views/index_patterns_api_client.test.mock.ts b/src/plugins/data/public/data_views/data_views/data_views_api_client.test.mock.ts similarity index 100% rename from src/plugins/data/public/data_views/data_views/index_patterns_api_client.test.mock.ts rename to src/plugins/data/public/data_views/data_views/data_views_api_client.test.mock.ts diff --git a/src/plugins/data/public/data_views/data_views/index_patterns_api_client.test.ts b/src/plugins/data/public/data_views/data_views/data_views_api_client.test.ts similarity index 83% rename from src/plugins/data/public/data_views/data_views/index_patterns_api_client.test.ts rename to src/plugins/data/public/data_views/data_views/data_views_api_client.test.ts index a6742852533a..09ee001c218b 100644 --- a/src/plugins/data/public/data_views/data_views/index_patterns_api_client.test.ts +++ b/src/plugins/data/public/data_views/data_views/data_views_api_client.test.ts @@ -6,16 +6,16 @@ * Side Public License, v 1. */ -import { http } from './index_patterns_api_client.test.mock'; -import { IndexPatternsApiClient } from './index_patterns_api_client'; +import { http } from './data_views_api_client.test.mock'; +import { DataViewsApiClient } from './data_views_api_client'; describe('IndexPatternsApiClient', () => { let fetchSpy: jest.SpyInstance; - let indexPatternsApiClient: IndexPatternsApiClient; + let indexPatternsApiClient: DataViewsApiClient; beforeEach(() => { fetchSpy = jest.spyOn(http, 'fetch').mockImplementation(() => Promise.resolve({})); - indexPatternsApiClient = new IndexPatternsApiClient(http); + indexPatternsApiClient = new DataViewsApiClient(http); }); test('uses the right URI to fetch fields for time patterns', async function () { diff --git a/src/plugins/data/public/data_views/data_views/index_patterns_api_client.ts b/src/plugins/data/public/data_views/data_views/data_views_api_client.ts similarity index 95% rename from src/plugins/data/public/data_views/data_views/index_patterns_api_client.ts rename to src/plugins/data/public/data_views/data_views/data_views_api_client.ts index 295cd99e7e01..d11ec7cfa003 100644 --- a/src/plugins/data/public/data_views/data_views/index_patterns_api_client.ts +++ b/src/plugins/data/public/data_views/data_views/data_views_api_client.ts @@ -10,13 +10,13 @@ import { HttpSetup } from 'src/core/public'; import { DataViewMissingIndices } from '../../../common/data_views/lib'; import { GetFieldsOptions, - IIndexPatternsApiClient, + IDataViewsApiClient, GetFieldsOptionsTimePattern, } from '../../../common/data_views/types'; const API_BASE_URL: string = `/api/index_patterns/`; -export class IndexPatternsApiClient implements IIndexPatternsApiClient { +export class DataViewsApiClient implements IDataViewsApiClient { private http: HttpSetup; constructor(http: HttpSetup) { diff --git a/src/plugins/data/public/data_views/data_views/index.ts b/src/plugins/data/public/data_views/data_views/index.ts index 4b3193344289..e0d18d47f39d 100644 --- a/src/plugins/data/public/data_views/data_views/index.ts +++ b/src/plugins/data/public/data_views/data_views/index.ts @@ -8,4 +8,4 @@ export * from '../../../common/data_views/data_views'; export * from './redirect_no_index_pattern'; -export * from './index_patterns_api_client'; +export * from './data_views_api_client'; diff --git a/src/plugins/data/public/data_views/index.ts b/src/plugins/data/public/data_views/index.ts index 02e36d893fa6..0125b173989f 100644 --- a/src/plugins/data/public/data_views/index.ts +++ b/src/plugins/data/public/data_views/index.ts @@ -12,7 +12,6 @@ export { ILLEGAL_CHARACTERS_VISIBLE, ILLEGAL_CHARACTERS, validateDataView, - isDefault, } from '../../common/data_views/lib'; export { flattenHitWrapper, formatHitProvider, onRedirectNoIndexPattern } from './data_views'; @@ -22,7 +21,7 @@ export { IndexPatternsService, IndexPatternsContract, IndexPattern, - IndexPatternsApiClient, + DataViewsApiClient, DataViewsService, DataViewsContract, DataView, diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 6480a0a34034..e1f5b98baca9 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -45,7 +45,6 @@ import { CONTAINS_SPACES_KEY, ILLEGAL_CHARACTERS_VISIBLE, ILLEGAL_CHARACTERS, - isDefault, validateDataView, flattenHitWrapper, } from './data_views'; @@ -58,7 +57,6 @@ export const indexPatterns = { CONTAINS_SPACES_KEY, ILLEGAL_CHARACTERS_VISIBLE, ILLEGAL_CHARACTERS, - isDefault, isFilterable, isNestedField, validate: validateDataView, diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 63f32e50f61a..aa766f78a5ec 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -22,9 +22,9 @@ import { SearchService } from './search/search_service'; import { QueryService } from './query'; import { createIndexPatternSelect } from './ui/index_pattern_select'; import { - IndexPatternsService, + DataViewsService, onRedirectNoIndexPattern, - IndexPatternsApiClient, + DataViewsApiClient, UiSettingsPublicToCommon, } from './data_views'; import { @@ -145,10 +145,10 @@ export class DataPublicPlugin setOverlays(overlays); setUiSettings(uiSettings); - const indexPatterns = new IndexPatternsService({ + const indexPatterns = new DataViewsService({ uiSettings: new UiSettingsPublicToCommon(uiSettings), savedObjectsClient: new SavedObjectsClientPublicToCommon(savedObjects.client), - apiClient: new IndexPatternsApiClient(http), + apiClient: new DataViewsApiClient(http), fieldFormats, onNotification: (toastInputFields) => { notifications.toasts.add(toastInputFields); diff --git a/src/plugins/data/public/stubs.ts b/src/plugins/data/public/stubs.ts index 8e790a2991b0..3d160a56bd8c 100644 --- a/src/plugins/data/public/stubs.ts +++ b/src/plugins/data/public/stubs.ts @@ -7,4 +7,4 @@ */ export * from '../common/stubs'; -export { createStubIndexPattern } from './data_views/data_views/index_pattern.stub'; +export { createStubDataView } from './data_views/data_views/data_view.stub'; diff --git a/src/plugins/data/server/data_views/saved_objects_client_wrapper.test.ts b/src/plugins/data/server/data_views/saved_objects_client_wrapper.test.ts index 39cf74b90610..bbe857894b3f 100644 --- a/src/plugins/data/server/data_views/saved_objects_client_wrapper.test.ts +++ b/src/plugins/data/server/data_views/saved_objects_client_wrapper.test.ts @@ -12,7 +12,7 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { DataViewSavedObjectConflictError } from '../../common/data_views'; describe('SavedObjectsClientPublicToCommon', () => { - const soClient = ({ resolve: jest.fn() } as unknown) as SavedObjectsClientContract; + const soClient = { resolve: jest.fn() } as unknown as SavedObjectsClientContract; test('get saved object - exactMatch', async () => { const mockedSavedObject = { diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx index 7e3d7ff10b3a..c2d09f31e3e0 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx @@ -23,11 +23,7 @@ import { METRIC_TYPE } from '@kbn/analytics'; import classNames from 'classnames'; import { DiscoverNoResults } from '../no_results'; import { LoadingSpinner } from '../loading_spinner/loading_spinner'; -import { - esFilters, - IndexPatternField, - indexPatterns as indexPatternsUtils, -} from '../../../../../../../data/public'; +import { esFilters, IndexPatternField } from '../../../../../../../data/public'; import { DiscoverSidebarResponsive } from '../sidebar'; import { DiscoverLayoutProps } from './types'; import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../../../common'; @@ -79,7 +75,7 @@ export function DiscoverLayout({ }, [dataState.fetchStatus]); const timeField = useMemo(() => { - return indexPatternsUtils.isDefault(indexPattern) ? indexPattern.timeFieldName : undefined; + return indexPattern.type !== 'rollup' ? indexPattern.timeFieldName : undefined; }, [indexPattern]); const [isSidebarClosed, setIsSidebarClosed] = useState(false); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap b/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap index 3ad902ed22fe..ebb06e0b2ecd 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap @@ -106,7 +106,7 @@ exports[`Discover IndexPattern Management renders correctly 1`] = ` } } selectedIndexPattern={ - IndexPattern { + DataView { "allowNoIndex": false, "deleteFieldFormat": [Function], "fieldAttrs": Object {}, diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts index 58a7242974ba..ba4cd8c3cd52 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts @@ -112,7 +112,7 @@ export const getTopNavLinks = ({ const sharingData = await getSharingData( searchSource, state.appStateContainer.getState(), - services.uiSettings + services ); services.share.toggleShareContextMenu({ diff --git a/src/plugins/discover/public/application/apps/main/discover_main_route.tsx b/src/plugins/discover/public/application/apps/main/discover_main_route.tsx index 53f95f38c96b..5141908e44ad 100644 --- a/src/plugins/discover/public/application/apps/main/discover_main_route.tsx +++ b/src/plugins/discover/public/application/apps/main/discover_main_route.tsx @@ -59,7 +59,7 @@ export function DiscoverMainRoute({ services, history }: DiscoverMainProps) { const savedSearchId = id; async function loadDefaultOrCurrentIndexPattern(usedSavedSearch: SavedSearch) { - await data.indexPatterns.ensureDefaultIndexPattern(); + await data.indexPatterns.ensureDefaultDataView(); const { appStateContainer } = getState({ history, uiSettings: config }); const { index } = appStateContainer.getState(); const ip = await loadIndexPattern(index || '', data.indexPatterns, config); diff --git a/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.test.ts b/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.test.ts index 25d0ca5d66eb..e7205c3f9bc6 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.test.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.test.ts @@ -7,32 +7,37 @@ */ import { Capabilities, IUiSettingsClient } from 'kibana/public'; -import { IndexPattern } from 'src/plugins/data/public'; +import type { IndexPattern } from 'src/plugins/data/public'; +import type { DiscoverServices } from '../../../../build_services'; +import { dataPluginMock } from '../../../../../../data/public/mocks'; import { createSearchSourceMock } from '../../../../../../data/common/search/search_source/mocks'; import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../../../common'; import { indexPatternMock } from '../../../../__mocks__/index_pattern'; import { getSharingData, showPublicUrlSwitch } from './get_sharing_data'; describe('getSharingData', () => { - let mockConfig: IUiSettingsClient; + let services: DiscoverServices; beforeEach(() => { - mockConfig = { - get: (key: string) => { - if (key === SORT_DEFAULT_ORDER_SETTING) { - return 'desc'; - } - if (key === DOC_HIDE_TIME_COLUMN_SETTING) { + services = { + data: dataPluginMock.createStartContract(), + uiSettings: { + get: (key: string) => { + if (key === SORT_DEFAULT_ORDER_SETTING) { + return 'desc'; + } + if (key === DOC_HIDE_TIME_COLUMN_SETTING) { + return false; + } return false; - } - return false; + }, }, - } as unknown as IUiSettingsClient; + } as DiscoverServices; }); test('returns valid data for sharing', async () => { const searchSourceMock = createSearchSourceMock({ index: indexPatternMock }); - const result = await getSharingData(searchSourceMock, { columns: [] }, mockConfig); + const result = await getSharingData(searchSourceMock, { columns: [] }, services); expect(result).toMatchInlineSnapshot(` Object { "columns": Array [], @@ -53,7 +58,7 @@ describe('getSharingData', () => { const result = await getSharingData( searchSourceMock, { columns: ['column_a', 'column_b'] }, - mockConfig + services ); expect(result).toMatchInlineSnapshot(` Object { @@ -90,7 +95,7 @@ describe('getSharingData', () => { 'cool-field-6', ], }, - mockConfig + services ); expect(result).toMatchInlineSnapshot(` Object { @@ -116,7 +121,7 @@ describe('getSharingData', () => { }); test('fields conditionally do not have prepended timeField', async () => { - mockConfig = { + services.uiSettings = { get: (key: string) => { if (key === DOC_HIDE_TIME_COLUMN_SETTING) { return true; @@ -141,7 +146,7 @@ describe('getSharingData', () => { 'cool-field-6', ], }, - mockConfig + services ); expect(result).toMatchInlineSnapshot(` Object { diff --git a/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts b/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts index 65001f49f4d6..420ff0fa11ee 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts @@ -6,8 +6,10 @@ * Side Public License, v 1. */ -import type { Capabilities, IUiSettingsClient } from 'kibana/public'; -import { ISearchSource } from '../../../../../../data/common'; +import type { Capabilities } from 'kibana/public'; +import type { IUiSettingsClient } from 'src/core/public'; +import type { DataPublicPluginStart } from 'src/plugins/data/public'; +import type { ISearchSource } from 'src/plugins/data/common'; import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../../../common'; import type { SavedSearch, SortOrder } from '../../../../saved_searches/types'; import { getSortForSearchSource } from '../components/doc_table'; @@ -19,8 +21,9 @@ import { AppState } from '../services/discover_state'; export async function getSharingData( currentSearchSource: ISearchSource, state: AppState | SavedSearch, - config: IUiSettingsClient + services: { uiSettings: IUiSettingsClient; data: DataPublicPluginStart } ) { + const { uiSettings: config, data } = services; const searchSource = currentSearchSource.createCopy(); const index = searchSource.getField('index')!; @@ -28,6 +31,8 @@ export async function getSharingData( 'sort', getSortForSearchSource(state.sort as SortOrder[], index, config.get(SORT_DEFAULT_ORDER_SETTING)) ); + // When sharing externally we preserve relative time values + searchSource.setField('filter', data.query.timefilter.timefilter.createRelativeFilter(index)); searchSource.removeField('highlight'); searchSource.removeField('highlightAll'); searchSource.removeField('aggs'); diff --git a/src/plugins/discover/public/application/apps/main/utils/update_search_source.ts b/src/plugins/discover/public/application/apps/main/utils/update_search_source.ts index 74e63c399743..4dfcbc7b7971 100644 --- a/src/plugins/discover/public/application/apps/main/utils/update_search_source.ts +++ b/src/plugins/discover/public/application/apps/main/utils/update_search_source.ts @@ -10,7 +10,6 @@ import { SORT_DEFAULT_ORDER_SETTING } from '../../../../../common'; import { IndexPattern, ISearchSource } from '../../../../../../data/common'; import { SortOrder } from '../../../../saved_searches/types'; import { DiscoverServices } from '../../../../build_services'; -import { indexPatterns as indexPatternsUtils } from '../../../../../../data/public'; import { getSortForSearchSource } from '../components/doc_table'; /** @@ -52,12 +51,9 @@ export function updateSearchSource( // document-like response. .setPreferredSearchStrategyId('default'); - // this is not the default index pattern, it determines that it's not of type rollup - if (indexPatternsUtils.isDefault(indexPattern)) { - searchSource.setField( - 'filter', - data.query.timefilter.timefilter.createRelativeFilter(indexPattern) - ); + if (indexPattern.type !== 'rollup') { + // Set the date range filter fields from timeFilter using the absolute format. Search sessions requires that it be converted from a relative range + searchSource.setField('filter', data.query.timefilter.timefilter.createFilter(indexPattern)); } if (useNewFieldsApi) { diff --git a/src/plugins/discover/public/application/services/use_es_doc_search.ts b/src/plugins/discover/public/application/services/use_es_doc_search.ts index a2f0cd6f8442..c5216c483fd1 100644 --- a/src/plugins/discover/public/application/services/use_es_doc_search.ts +++ b/src/plugins/discover/public/application/services/use_es_doc_search.ts @@ -37,7 +37,7 @@ export function buildSearchBody( }, }, stored_fields: computedFields.storedFields, - script_fields: computedFields.scriptFields, + script_fields: computedFields.scriptFields as Record, version: true, }, }; diff --git a/src/plugins/discover/server/saved_objects/search.ts b/src/plugins/discover/server/saved_objects/search.ts index 070f0253f17e..46284f3cf33b 100644 --- a/src/plugins/discover/server/saved_objects/search.ts +++ b/src/plugins/discover/server/saved_objects/search.ts @@ -20,9 +20,6 @@ export const searchSavedObjectType: SavedObjectsType = { getTitle(obj) { return obj.attributes.title; }, - getEditUrl(obj) { - return `/management/kibana/objects/savedSearches/${encodeURIComponent(obj.id)}`; - }, getInAppUrl(obj) { return { path: `/app/discover#/view/${encodeURIComponent(obj.id)}`, diff --git a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/index_pattern_field.ts b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/index_pattern_field.ts index 5aadefa6005f..1b6667fce41a 100644 --- a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/index_pattern_field.ts +++ b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/index_pattern_field.ts @@ -37,7 +37,7 @@ export const indexPatternField = // Validate illegal characters const errors = indexPatterns.validate(value); - if (errors[indexPatterns.ILLEGAL_CHARACTERS_KEY]) { + if (errors.ILLEGAL_CHARACTERS) { return { code: 'ERR_FIELD_FORMAT', formatType: 'INDEX_PATTERN', @@ -45,8 +45,8 @@ export const indexPatternField = defaultMessage: 'The index pattern contains the invalid {characterListLength, plural, one {character} other {characters}} { characterList }.', values: { - characterList: errors[indexPatterns.ILLEGAL_CHARACTERS_KEY].join(' '), - characterListLength: errors[indexPatterns.ILLEGAL_CHARACTERS_KEY].length, + characterList: errors.ILLEGAL_CHARACTERS.join(' '), + characterListLength: errors.ILLEGAL_CHARACTERS.length, }, }), }; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index bc38b63730b2..53eb49e1013b 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -276,6 +276,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'long', _meta: { description: 'Non-default value of setting.' }, }, + 'metrics:allowStringIndices': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'query:allowLeadingWildcards': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 24a20b458c78..b76ef14e62b8 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -91,6 +91,7 @@ export interface UsageStats { 'savedObjects:listingLimit': number; 'query:queryString:options': string; 'metrics:max_buckets': number; + 'metrics:allowStringIndices': boolean; 'query:allowLeadingWildcards': boolean; metaFields: string[]; 'indexPattern:placeholder': string; diff --git a/src/plugins/kibana_utils/server/index.ts b/src/plugins/kibana_utils/server/index.ts index 483c5aa92b45..42847042be15 100644 --- a/src/plugins/kibana_utils/server/index.ts +++ b/src/plugins/kibana_utils/server/index.ts @@ -15,6 +15,7 @@ export { Get, Set, url, + mergeMigrationFunctionMaps, } from '../common'; export { KbnServerError, reportServerError, getKbnServerError } from './report_server_error'; 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 2f45ee211c8c..bd8d69d6b693 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 @@ -20,7 +20,7 @@ import { coreMock } from '../../../../core/public/mocks'; import { dataPluginMock, createSearchSourceMock } from '../../../../plugins/data/public/mocks'; import { createStubIndexPattern } from '../../../../plugins/data/common/stubs'; import { SavedObjectAttributes, SimpleSavedObject } from 'kibana/public'; -import { IndexPattern } from '../../../data/common'; +import { DataView } from '../../../data/common'; import { savedObjectsDecoratorRegistryMock } from './decorators/registry.mock'; describe('Saved Object', () => { @@ -725,7 +725,7 @@ describe('Saved Object', () => { type: 'dashboard', afterESResp: afterESRespCallback, searchSource: true, - indexPattern: { id: indexPatternId } as IndexPattern, + indexPattern: { id: indexPatternId } as DataView, }; stubESResponse( @@ -752,7 +752,7 @@ describe('Saved Object', () => { return savedObject.init!().then(() => { expect(afterESRespCallback).toHaveBeenCalled(); const index = savedObject.searchSource!.getField('index'); - expect(index instanceof IndexPattern).toBe(true); + expect(index instanceof DataView).toBe(true); expect(index!.id).toEqual(indexPatternId); }); }); @@ -765,7 +765,7 @@ describe('Saved Object', () => { type: 'dashboard', afterESResp: afterESRespCallback, searchSource: false, - indexPattern: { id: indexPatternId } as IndexPattern, + indexPattern: { id: indexPatternId } as DataView, }; stubESResponse(getMockedDocResponse(indexPatternId)); 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 43cd5c93aa80..aa7797fbc95b 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 @@ -62,12 +62,11 @@ export const mountManagementSection = async ({ - + }> diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/__snapshots__/saved_object_view.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/object_view/__snapshots__/saved_object_view.test.tsx.snap new file mode 100644 index 000000000000..8e3a954677b8 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/object_view/__snapshots__/saved_object_view.test.tsx.snap @@ -0,0 +1,64 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SavedObjectEdition should render normally 1`] = ` + + + +
+ + + + + + +`; diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/header.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/header.test.tsx.snap index c72e000e95e7..26ff23b50d87 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/header.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/header.test.tsx.snap @@ -11,17 +11,7 @@ exports[`Intro component renders correctly 1`] = ` > - } + pageTitle="Inspect saved object" rightSideItems={ Array [ @@ -48,13 +38,9 @@ exports[`Intro component renders correctly 1`] = ` size="s" > , ] @@ -64,17 +50,7 @@ exports[`Intro component renders correctly 1`] = ` className="euiPageHeader euiPageHeader--bottomBorder euiPageHeader--responsive euiPageHeader--center" > - } + pageTitle="Inspect saved object" responsive={true} rightSideItems={ Array [ @@ -89,7 +65,7 @@ exports[`Intro component renders correctly 1`] = ` id="savedObjectsManagement.view.viewItemButtonLabel" values={ Object { - "title": "search", + "title": "saved object", } } /> @@ -102,13 +78,9 @@ exports[`Intro component renders correctly 1`] = ` size="s" > , ] @@ -136,17 +108,7 @@ exports[`Intro component renders correctly 1`] = `

- - Edit search - + Inspect saved object

@@ -234,11 +196,11 @@ exports[`Intro component renders correctly 1`] = ` id="savedObjectsManagement.view.viewItemButtonLabel" values={ Object { - "title": "search", + "title": "saved object", } } > - View search + View saved object
@@ -316,15 +278,11 @@ exports[`Intro component renders correctly 1`] = ` className="euiButton__text" > - Delete search + Delete diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/inspect.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/inspect.test.tsx.snap new file mode 100644 index 000000000000..f35030ad736c --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/inspect.test.tsx.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Inspect component renders correctly 1`] = ` + +`; diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/intro.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/intro.test.tsx.snap deleted file mode 100644 index 9c9349b0524c..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/intro.test.tsx.snap +++ /dev/null @@ -1,80 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Intro component renders correctly 1`] = ` - - - } - > -
-
- - - - Proceed with caution! - - -
- -
- -
-
- - Modifying objects is for advanced users only. Object properties are not validated and invalid objects could cause errors, data loss, or worse. Unless someone with intimate knowledge of the code told you to be in here, you probably shouldn’t be. - -
-
-
-
-
-
-
-
-`; diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap index 4227351f8e94..c55583679f26 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap @@ -1,353 +1,541 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`NotFoundErrors component renders correctly for index-pattern type 1`] = ` - + } > - - } +
- - + +
+ +
-
- -
-
- - The index pattern associated with this object no longer exists. - -
-
- + + The index pattern associated with this object no longer exists. + +
+
+ + Saved objects APIs + , + } + } + > + If you know what this error means, you can use the + - If you know what this error means, go ahead and fix it — otherwise click the delete button above. - -
+ + Saved objects APIs + + + + + + + (opens in a new tab or window) + + + + + + to fix it — otherwise click the delete button above. +
-
-
- -
- - +
+ + + + +
`; exports[`NotFoundErrors component renders correctly for index-pattern-field type 1`] = ` - + } > - - } +
- - + +
+ +
-
- -
-
- - A field associated with this object no longer exists in the index pattern. - -
-
- + + A field associated with this object no longer exists in the index pattern. + +
+
+ + Saved objects APIs + , + } + } + > + If you know what this error means, you can use the + - If you know what this error means, go ahead and fix it — otherwise click the delete button above. - -
+ + Saved objects APIs + + + + + + + (opens in a new tab or window) + + + + + + to fix it — otherwise click the delete button above. +
-
-
- -
- - +
+ + + + +
`; exports[`NotFoundErrors component renders correctly for search type 1`] = ` - + } > - - } +
- - + +
+ +
-
- -
-
- - The saved search associated with this object no longer exists. - -
-
- + + The saved search associated with this object no longer exists. + +
+
+ + Saved objects APIs + , + } + } + > + If you know what this error means, you can use the + - If you know what this error means, go ahead and fix it — otherwise click the delete button above. - -
+ + Saved objects APIs + + + + + + + (opens in a new tab or window) + + + + + + to fix it — otherwise click the delete button above. +
-
-
- -
- - +
+ + + + +
`; exports[`NotFoundErrors component renders correctly for unknown type 1`] = ` - + } > - - } +
- - + +
+ +
-
- -
-
-
- +
+ + Saved objects APIs + , + } + } + > + If you know what this error means, you can use the + - If you know what this error means, go ahead and fix it — otherwise click the delete button above. - -
+ + Saved objects APIs + + + + + + + (opens in a new tab or window) + + + + + + to fix it — otherwise click the delete button above. +
- -
- -
- - +
+ +
+
+
+
`; diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/field.test.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/field.test.tsx deleted file mode 100644 index 1fd70c65fd9c..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/field.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { mount } from 'enzyme'; -import { I18nProvider } from '@kbn/i18n/react'; -import { Field } from './field'; -import { FieldState, FieldType } from '../../types'; - -describe('Field component', () => { - const mountField = (props: { - type: FieldType; - name: string; - value: any; - disabled: boolean; - state?: FieldState; - onChange: (name: string, state: FieldState) => void; - }) => - mount( - - - - ).find('Field'); - - const defaultProps = { - type: 'text' as FieldType, - name: 'field', - value: '', - disabled: false, - state: undefined, - onChange: (name: string, state: FieldState) => undefined, - }; - - it('uses the field name as the label', () => { - let mounted = mountField({ ...defaultProps, name: 'some.name' }); - expect(mounted.find('EuiFormLabel').text()).toMatchInlineSnapshot(`"some.name"`); - - mounted = mountField({ ...defaultProps, name: 'someother.name' }); - expect(mounted.find('EuiFormLabel').text()).toMatchInlineSnapshot(`"someother.name"`); - }); - - it('renders a EuiCodeEditor for json type', () => { - const mounted = mountField({ ...defaultProps, type: 'json' }); - expect(mounted.exists('EuiCodeEditor')).toEqual(true); - }); - - it('renders a EuiCodeEditor for array type', () => { - const mounted = mountField({ ...defaultProps, type: 'array' }); - expect(mounted.exists('EuiCodeEditor')).toEqual(true); - }); - - it('renders a EuiSwitch for boolean type', () => { - const mounted = mountField({ ...defaultProps, type: 'boolean' }); - expect(mounted.exists('EuiSwitch')).toEqual(true); - }); - - it('display correct label for boolean type depending on value', () => { - let mounted = mountField({ ...defaultProps, type: 'boolean', value: true }); - expect(mounted.find('EuiSwitch').text()).toMatchInlineSnapshot(`"On"`); - - mounted = mountField({ ...defaultProps, type: 'boolean', value: false }); - expect(mounted.find('EuiSwitch').text()).toMatchInlineSnapshot(`"Off"`); - }); - - it('renders a EuiFieldNumber for number type', () => { - const mounted = mountField({ ...defaultProps, type: 'number' }); - expect(mounted.exists('EuiFieldNumber')).toEqual(true); - }); - - it('renders a EuiFieldText for text type', () => { - const mounted = mountField({ ...defaultProps, type: 'text' }); - expect(mounted.exists('EuiFieldText')).toEqual(true); - }); - - it('renders a EuiFieldText as fallback', () => { - const mounted = mountField({ ...defaultProps, type: 'unknown-type' as any }); - expect(mounted.exists('EuiFieldText')).toEqual(true); - }); -}); diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/field.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/field.tsx deleted file mode 100644 index 2273527dd63f..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/field.tsx +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { PureComponent } from 'react'; -import { EuiFieldNumber, EuiFieldText, EuiFormRow, EuiSwitch, EuiCodeEditor } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { FieldState, FieldType } from '../../types'; - -interface FieldProps { - type: FieldType; - name: string; - value: any; - disabled: boolean; - state?: FieldState; - onChange: (name: string, state: FieldState) => void; -} - -export class Field extends PureComponent { - render() { - const { name } = this.props; - - return ( - - {this.renderField()} - - ); - } - - onCodeEditorChange(targetValue: any) { - const { name, onChange } = this.props; - let invalid = false; - try { - JSON.parse(targetValue); - } catch (e) { - invalid = true; - } - onChange(name, { - value: targetValue, - invalid, - }); - } - - onFieldChange(targetValue: any) { - const { name, type, onChange } = this.props; - - let newParsedValue = targetValue; - let invalid = false; - if (type === 'number') { - try { - newParsedValue = Number(newParsedValue); - } catch (e) { - invalid = true; - } - } - onChange(name, { - value: newParsedValue, - invalid, - }); - } - - renderField() { - const { type, name, state, disabled } = this.props; - const currentValue = state?.value ?? this.props.value; - - switch (type) { - case 'number': - return ( - this.onFieldChange(e.target.value)} - disabled={disabled} - data-test-subj={`savedObjects-editField-${name}`} - /> - ); - case 'boolean': - return ( - - ) : ( - - ) - } - checked={!!currentValue} - onChange={(e) => this.onFieldChange(e.target.checked)} - disabled={disabled} - data-test-subj={`savedObjects-editField-${name}`} - /> - ); - case 'json': - case 'array': - return ( -
- this.onCodeEditorChange(value)} - width="100%" - height="auto" - minLines={6} - maxLines={30} - isReadOnly={disabled} - setOptions={{ - showLineNumbers: true, - tabSize: 2, - useSoftTabs: true, - }} - editorProps={{ - $blockScrolling: Infinity, - }} - showGutter={true} - /> -
- ); - default: - return ( - this.onFieldChange(e.target.value)} - disabled={disabled} - data-test-subj={`savedObjects-editField-${name}`} - /> - ); - } - } - - private get fieldId() { - const { name } = this.props; - return `savedObjects-editField-${name}`; - } -} diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/form.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/form.tsx deleted file mode 100644 index 8e33e0fbdc5e..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/form.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { Component } from 'react'; -import { - EuiForm, - EuiFlexGroup, - EuiFlexItem, - EuiButton, - EuiButtonEmpty, - EuiSpacer, -} from '@elastic/eui'; -import { set } from '@elastic/safer-lodash-set'; -import { cloneDeep } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { SavedObjectsClientContract } from '../../../../../../core/public'; -import { SavedObjectLoader } from '../../../../../saved_objects/public'; -import { Field } from './field'; -import { ObjectField, FieldState, SubmittedFormData } from '../../types'; -import { createFieldList } from '../../../lib'; -import { SavedObjectWithMetadata } from '../../../types'; - -interface FormProps { - object: SavedObjectWithMetadata; - service: SavedObjectLoader; - savedObjectsClient: SavedObjectsClientContract; - editionEnabled: boolean; - onSave: (form: SubmittedFormData) => Promise; -} - -interface FormState { - fields: ObjectField[]; - fieldStates: Record; - submitting: boolean; -} - -export class Form extends Component { - constructor(props: FormProps) { - super(props); - this.state = { - fields: [], - fieldStates: {}, - submitting: false, - }; - } - - componentDidMount() { - const { object, service } = this.props; - - const fields = createFieldList(object, service); - - this.setState({ - fields, - }); - } - - render() { - const { editionEnabled, service } = this.props; - const { fields, fieldStates, submitting } = this.state; - const isValid = this.isFormValid(); - return ( - - {fields.map((field) => ( - - ))} - - - {editionEnabled && ( - - - - - - )} - - - - - - - - - ); - } - - handleFieldChange = (name: string, newState: FieldState) => { - this.setState({ - fieldStates: { - ...this.state.fieldStates, - [name]: newState, - }, - }); - }; - - isFormValid() { - const { fieldStates } = this.state; - return !Object.values(fieldStates).some((state) => state.invalid === true); - } - - onCancel = () => { - window.history.back(); - }; - - onSubmit = async () => { - const { object, onSave } = this.props; - const { fields, fieldStates } = this.state; - - if (!this.isFormValid()) { - return; - } - - this.setState({ - submitting: true, - }); - - const source = cloneDeep(object.attributes as any); - fields.forEach((field) => { - let value = fieldStates[field.name]?.value ?? field.value; - - if (field.type === 'array' && typeof value === 'string') { - value = JSON.parse(value); - } - - set(source, field.name, value); - }); - - // we extract the `references` field that does not belong to attributes - const { references, ...attributes } = source; - - await onSave({ attributes, references }); - - this.setState({ - submitting: false, - }); - }; -} diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/header.test.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/header.test.tsx index dbbd2485096f..796632f9747a 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/header.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/header.test.tsx @@ -19,6 +19,7 @@ describe('Intro component', () => { type: string; viewUrl: string; onDeleteClick: () => void; + title?: string; }) => mount( @@ -42,32 +43,11 @@ describe('Intro component', () => { expect(mounted).toMatchSnapshot(); }); - it('displays correct title depending on canEdit', () => { - let mounted = mountHeader({ - ...defaultProps, - canEdit: true, - }); - expect(mounted.find('h1').text()).toMatchInlineSnapshot(`"Edit search"`); - - mounted = mountHeader({ - ...defaultProps, - canEdit: false, - }); - expect(mounted.find('h1').text()).toMatchInlineSnapshot(`"View search"`); - }); - - it('displays correct title depending on type', () => { - let mounted = mountHeader({ - ...defaultProps, - type: 'some-type', - }); - expect(mounted.find('h1').text()).toMatchInlineSnapshot(`"Edit some-type"`); - - mounted = mountHeader({ - ...defaultProps, - type: 'another-type', - }); - expect(mounted.find('h1').text()).toMatchInlineSnapshot(`"Edit another-type"`); + it('displays correct title if one is provided', () => { + let mounted = mountHeader({ ...defaultProps, title: 'my saved search' }); + expect(mounted.find('h1').text()).toMatchInlineSnapshot(`"Inspect my saved search"`); + mounted = mountHeader({ ...defaultProps, title: 'my other saved search' }); + expect(mounted.find('h1').text()).toMatchInlineSnapshot(`"Inspect my other saved search"`); }); it('only displays delete button if canDelete is true', () => { diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/header.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/header.tsx index 9a13a1d232cb..10374b839ca4 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/header.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/header.tsx @@ -9,43 +9,24 @@ import React from 'react'; import { EuiButton, EuiPageHeader } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; interface HeaderProps { - canEdit: boolean; canDelete: boolean; canViewInApp: boolean; - type: string; viewUrl: string; onDeleteClick: () => void; + title?: string; } -const renderConditionalTitle = (canEdit: boolean, type: string) => - canEdit ? ( - - ) : ( - - ); - -export const Header = ({ - canEdit, - canDelete, - canViewInApp, - type, - viewUrl, - onDeleteClick, -}: HeaderProps) => { +export const Header = ({ canDelete, canViewInApp, viewUrl, onDeleteClick, title }: HeaderProps) => { return ( ), @@ -71,8 +52,7 @@ export const Header = ({ > ), diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/index.ts b/src/plugins/saved_objects_management/public/management_section/object_view/components/index.ts index ffffd589d5ef..55322afb3fab 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/index.ts +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/index.ts @@ -7,6 +7,5 @@ */ export { Header } from './header'; +export { Inspect } from './inspect'; export { NotFoundErrors } from './not_found_errors'; -export { Intro } from './intro'; -export { Form } from './form'; diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/inspect.test.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/inspect.test.tsx new file mode 100644 index 000000000000..433728baf6fc --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/inspect.test.tsx @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { ShallowWrapper } from 'enzyme'; +import { shallowWithI18nProvider } from '@kbn/test/jest'; +import { Inspect, InspectProps } from './inspect'; +import { SavedObjectWithMetadata } from '../../../../common'; + +describe('Inspect component', () => { + let defaultProps: { object: SavedObjectWithMetadata }; + const shallowRender = (overrides: Partial = {}) => { + return shallowWithI18nProvider( + + ) as unknown as ShallowWrapper; + }; + beforeEach(() => { + defaultProps = { + object: { + id: '1', + type: 'index-pattern', + attributes: { + title: `MyIndexPattern*`, + }, + meta: { + title: `MyIndexPattern*`, + icon: 'indexPatternApp', + editUrl: '#/management/kibana/indexPatterns/patterns/1', + inAppUrl: { + path: '/management/kibana/indexPatterns/patterns/1', + uiCapabilitiesPath: 'management.kibana.indexPatterns', + }, + }, + references: [], + }, + }; + }); + + it('renders correctly', async () => { + const component = shallowRender(); + await new Promise((resolve) => process.nextTick(resolve)); + component.update(); + const codeEditorComponent = component.find('CodeEditor'); + expect(codeEditorComponent).toMatchSnapshot(); + }); + + it("does not include `meta` in the value that's rendered", async () => { + const component = shallowRender(); + await new Promise((resolve) => process.nextTick(resolve)); + component.update(); + const codeEditorComponent = component.find('CodeEditor'); + // find could return nothing + const editorValue = codeEditorComponent + ? (codeEditorComponent.prop('value') as unknown as string) + : ''; + // we assert against the expected object props rather than asserting that 'meta' is removed + expect(Object.keys(JSON.parse(editorValue))).toEqual([ + 'id', + 'type', + 'attributes', + 'references', + ]); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/inspect.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/inspect.tsx new file mode 100644 index 000000000000..58d6da8ce935 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/inspect.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { FC, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { XJsonLang } from '@kbn/monaco'; +import { omit } from 'lodash'; +import { EuiButtonEmpty, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { CodeEditor } from '../../../../../kibana_react/public'; +import { SavedObjectWithMetadata } from '../../../../common'; + +export interface InspectProps { + object: SavedObjectWithMetadata; +} +const codeEditorAriaLabel = (title: string) => + i18n.translate('savedObjectsManagement.view.inspectCodeEditorAriaLabel', { + defaultMessage: 'inspect { title }', + values: { + title, + }, + }); +const copyToClipboardLabel = i18n.translate('savedObjectsManagement.view.copyToClipboardLabel', { + defaultMessage: 'Copy to clipboard', +}); + +export const Inspect: FC = ({ object }) => { + const title = object.meta.title || 'saved object'; + + const objectAsJsonString = useMemo(() => JSON.stringify(omit(object, 'meta'), null, 2), [object]); + + return ( + + +
+ + {(copy) => ( + + {copyToClipboardLabel} + + )} + + +
+ +
+
+ ); +}; diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/intro.test.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/intro.test.tsx deleted file mode 100644 index 0b869743f03c..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/intro.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { mount } from 'enzyme'; -import { I18nProvider } from '@kbn/i18n/react'; -import { Intro } from './intro'; - -describe('Intro component', () => { - it('renders correctly', () => { - const mounted = mount( - - - - ); - expect(mounted.find('Intro')).toMatchSnapshot(); - }); -}); diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/intro.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/intro.tsx deleted file mode 100644 index 0431208d34ad..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/intro.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { EuiCallOut } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export const Intro = () => { - return ( - - } - iconType="alert" - color="warning" - > -
- -
-
- ); -}; diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.test.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.test.tsx index 767cc1ac59f4..5eab44cb416e 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.test.tsx @@ -10,44 +10,49 @@ import React from 'react'; import { mount } from 'enzyme'; import { I18nProvider } from '@kbn/i18n/react'; import { NotFoundErrors } from './not_found_errors'; +import { docLinksServiceMock } from '../../../../../../core/public/mocks'; describe('NotFoundErrors component', () => { const mountError = (type: string) => mount( - + ).find('NotFoundErrors'); it('renders correctly for search type', () => { const mounted = mountError('search'); - expect(mounted).toMatchSnapshot(); + const callOut = mounted.find('EuiCallOut'); + expect(callOut).toMatchSnapshot(); expect(mounted.text()).toMatchInlineSnapshot( - `"There is a problem with this saved objectThe saved search associated with this object no longer exists.If you know what this error means, go ahead and fix it — otherwise click the delete button above."` + `"There is a problem with this saved objectThe saved search associated with this object no longer exists.If you know what this error means, you can use the Saved objects APIs(opens in a new tab or window) to fix it — otherwise click the delete button above."` ); }); it('renders correctly for index-pattern type', () => { const mounted = mountError('index-pattern'); - expect(mounted).toMatchSnapshot(); + const callOut = mounted.find('EuiCallOut'); + expect(callOut).toMatchSnapshot(); expect(mounted.text()).toMatchInlineSnapshot( - `"There is a problem with this saved objectThe index pattern associated with this object no longer exists.If you know what this error means, go ahead and fix it — otherwise click the delete button above."` + `"There is a problem with this saved objectThe index pattern associated with this object no longer exists.If you know what this error means, you can use the Saved objects APIs(opens in a new tab or window) to fix it — otherwise click the delete button above."` ); }); it('renders correctly for index-pattern-field type', () => { const mounted = mountError('index-pattern-field'); - expect(mounted).toMatchSnapshot(); + const callOut = mounted.find('EuiCallOut'); + expect(callOut).toMatchSnapshot(); expect(mounted.text()).toMatchInlineSnapshot( - `"There is a problem with this saved objectA field associated with this object no longer exists in the index pattern.If you know what this error means, go ahead and fix it — otherwise click the delete button above."` + `"There is a problem with this saved objectA field associated with this object no longer exists in the index pattern.If you know what this error means, you can use the Saved objects APIs(opens in a new tab or window) to fix it — otherwise click the delete button above."` ); }); it('renders correctly for unknown type', () => { const mounted = mountError('unknown'); - expect(mounted).toMatchSnapshot(); + const callOut = mounted.find('EuiCallOut'); + expect(callOut).toMatchSnapshot(); expect(mounted.text()).toMatchInlineSnapshot( - `"There is a problem with this saved objectIf you know what this error means, go ahead and fix it — otherwise click the delete button above."` + `"There is a problem with this saved objectIf you know what this error means, you can use the Saved objects APIs(opens in a new tab or window) to fix it — otherwise click the delete button above."` ); }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx index 2bce7b387a7e..e3a349b1f4aa 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx @@ -8,13 +8,23 @@ import React from 'react'; import { EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiLink } from '@elastic/eui'; +import { DocLinksStart } from '../../../../../../core/public'; interface NotFoundErrors { type: string; + docLinks: DocLinksStart['links']; } +const savedObjectsApisLinkText = i18n.translate( + 'savedObjectsManagement.view.howToFixErrorDescriptionLinkText', + { + defaultMessage: 'Saved objects APIs', + } +); -export const NotFoundErrors = ({ type }: NotFoundErrors) => { +export const NotFoundErrors = ({ type, docLinks }: NotFoundErrors) => { const getMessage = () => { switch (type) { case 'search': @@ -58,7 +68,18 @@ export const NotFoundErrors = ({ type }: NotFoundErrors) => {
+ {savedObjectsApisLinkText} + + ), + }} />
diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.scss b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.scss new file mode 100644 index 000000000000..656f93468db9 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.scss @@ -0,0 +1,3 @@ +.savedObjectsManagementObjectView { + height: 100%; +} diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.test.mocks.ts b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.test.mocks.ts new file mode 100644 index 000000000000..724395510069 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.test.mocks.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +jest.doMock('lodash', () => { + const original = jest.requireActual('lodash'); + return { + ...original, + get: (func: Function) => { + function get(this: any, args: any[]) { + return func.apply(this, args); + } + return get; + }, + }; +}); + +export const bulkGetObjectsMock = jest.fn(); +jest.doMock('../../lib/bulk_get_objects', () => ({ + bulkGetObjects: bulkGetObjectsMock, +})); diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.test.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.test.tsx new file mode 100644 index 000000000000..13a806361e54 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.test.tsx @@ -0,0 +1,329 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { bulkGetObjectsMock } from './saved_object_view.test.mocks'; + +import React from 'react'; +import { ShallowWrapper } from 'enzyme'; +import { shallowWithI18nProvider } from '@kbn/test/jest'; + +import { + httpServiceMock, + overlayServiceMock, + notificationServiceMock, + savedObjectsServiceMock, + applicationServiceMock, + uiSettingsServiceMock, + scopedHistoryMock, + docLinksServiceMock, +} from '../../../../../core/public/mocks'; + +import { + SavedObjectEdition, + SavedObjectEditionProps, + SavedObjectEditionState, +} from './saved_object_view'; + +const resolvePromises = () => new Promise((resolve) => process.nextTick(resolve)); + +describe('SavedObjectEdition', () => { + let defaultProps: SavedObjectEditionProps; + let http: ReturnType; + let overlays: ReturnType; + let notifications: ReturnType; + let savedObjects: ReturnType; + let uiSettings: ReturnType; + let history: ReturnType; + let applications: ReturnType; + let docLinks: ReturnType; + + const shallowRender = (overrides: Partial = {}) => { + return shallowWithI18nProvider( + + ) as unknown as ShallowWrapper< + SavedObjectEditionProps, + SavedObjectEditionState, + SavedObjectEdition + >; + }; + + beforeEach(() => { + http = httpServiceMock.createStartContract(); + overlays = overlayServiceMock.createStartContract(); + notifications = notificationServiceMock.createStartContract(); + savedObjects = savedObjectsServiceMock.createStartContract(); + uiSettings = uiSettingsServiceMock.createStartContract(); + history = scopedHistoryMock.create(); + docLinks = docLinksServiceMock.createStartContract(); + applications = applicationServiceMock.createStartContract(); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: false, + }, + }; + + http.post.mockResolvedValue([]); + + defaultProps = { + id: '1', + savedObjectType: 'dashboard', + http, + capabilities: applications.capabilities, + overlays, + notifications, + savedObjectsClient: savedObjects.client, + history, + uiSettings, + docLinks: docLinks.links, + }; + + bulkGetObjectsMock.mockImplementation(() => [{}]); + }); + + it('should render normally', async () => { + bulkGetObjectsMock.mockImplementation(() => + Promise.resolve([ + { + id: '1', + type: 'dashboard', + attributes: { + title: `MyDashboard*`, + }, + meta: { + title: `MyDashboard*`, + icon: 'dashboardApp', + inAppUrl: { + path: '/app/dashboards#/view/1', + uiCapabilitiesPath: 'management.kibana.dashboard', + }, + }, + }, + ]) + ); + const component = shallowRender(); + // Ensure all promises resolve + await resolvePromises(); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); + + it('should add danger toast when bulk get fails', async () => { + bulkGetObjectsMock.mockImplementation(() => + Promise.resolve([ + { + error: { + message: 'Not found', + }, + }, + ]) + ); + const component = shallowRender({ notFoundType: 'does_not_exist' }); + + await resolvePromises(); + + component.update(); + + expect(notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + }); + + it('should add danger toast when bulk get throws', async () => { + bulkGetObjectsMock.mockImplementation(() => Promise.reject(new Error('fail'))); + const component = shallowRender({ notFoundType: 'does_not_exist' }); + + await resolvePromises(); + + component.update(); + + expect(notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + }); + + it('should pass the correct props to the child components', async () => { + const savedObjectItem = { + id: '1', + type: 'index-pattern', + attributes: { + title: `MyIndexPattern*`, + }, + meta: { + title: `MyIndexPattern*`, + icon: 'indexPatternApp', + editUrl: '#/management/kibana/indexPatterns/patterns/1', + inAppUrl: { + path: '/management/kibana/indexPatterns/patterns/1', + uiCapabilitiesPath: 'management.kibana.indexPatterns', + }, + hiddenType: false, + }, + }; + bulkGetObjectsMock.mockImplementation(() => Promise.resolve([savedObjectItem])); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: true, + }, + }; + const component = shallowRender({ + capabilities: applications.capabilities, + }); + + await resolvePromises(); + + component.update(); + const headerComponent = component.find('Header'); + expect(headerComponent.prop('canViewInApp')).toBe(true); + expect(headerComponent.prop('canDelete')).toBe(true); + expect(headerComponent.prop('viewUrl')).toEqual('/management/kibana/indexPatterns/patterns/1'); + const inspectComponent = component.find('Inspect'); + expect(inspectComponent.prop('object')).toEqual(savedObjectItem); + }); + + it("does not render Inspect if there isn't an object", async () => { + bulkGetObjectsMock.mockImplementation(() => Promise.resolve([])); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: true, + }, + }; + const component = shallowRender({ + capabilities: applications.capabilities, + }); + + await resolvePromises(); + + component.update(); + const inspectComponent = component.find('Inspect'); + expect(inspectComponent).toEqual({}); + }); + + describe('delete', () => { + const savedObjectItem = { + id: '1', + type: 'index-pattern', + attributes: { + title: `MyIndexPattern*`, + }, + meta: { + title: `MyIndexPattern*`, + icon: 'indexPatternApp', + editUrl: '#/management/kibana/indexPatterns/patterns/1', + inAppUrl: { + path: '/management/kibana/indexPatterns/patterns/1', + uiCapabilitiesPath: 'management.kibana.indexPatterns', + }, + hiddenType: false, + }, + }; + + it('should display a confirmation message on deleting the saved object', async () => { + bulkGetObjectsMock.mockImplementation(() => Promise.resolve([savedObjectItem])); + const mockSavedObjectsClient = { + ...defaultProps.savedObjectsClient, + delete: jest.fn().mockImplementation(() => ({})), + }; + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: true, + }, + }; + overlays.openConfirm.mockResolvedValue(false); + const component = shallowRender({ + capabilities: applications.capabilities, + savedObjectsClient: mockSavedObjectsClient, + overlays, + }); + + await resolvePromises(); + + component.update(); + component.instance().delete(); + expect(overlays.openConfirm).toHaveBeenCalledWith( + 'This action permanently removes the object from Kibana.', + { + buttonColor: 'danger', + confirmButtonText: 'Delete', + title: `Delete '${savedObjectItem.meta.title}'?`, + } + ); + }); + + it('should route back if action is confirm and user accepted', async () => { + bulkGetObjectsMock.mockImplementation(() => Promise.resolve([savedObjectItem])); + const mockSavedObjectsClient = { + ...defaultProps.savedObjectsClient, + delete: jest.fn().mockImplementation(() => ({})), + }; + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: true, + }, + }; + overlays.openConfirm.mockResolvedValue(true); + const component = shallowRender({ + capabilities: applications.capabilities, + savedObjectsClient: mockSavedObjectsClient, + overlays, + }); + + await resolvePromises(); + + component.update(); + component.instance().delete(); + expect(overlays.openConfirm).toHaveBeenCalledTimes(1); + expect(history.location.pathname).toEqual('/'); + }); + + it('should not enable delete if the saved object is hidden', async () => { + bulkGetObjectsMock.mockImplementation(() => + Promise.resolve([{ ...savedObjectItem, meta: { hiddenType: true } }]) + ); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: true, + }, + }; + const component = shallowRender({ + capabilities: applications.capabilities, + }); + + await resolvePromises(); + + component.update(); + expect(component.find('Header').prop('canDelete')).toBe(false); + }); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx index 079a1c07da19..64b6e27309dd 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx @@ -8,7 +8,9 @@ import React, { Component } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiSpacer } from '@elastic/eui'; +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { get } from 'lodash'; +import { KibanaContextProvider } from '../../../../kibana_react/public'; import { Capabilities, SavedObjectsClientContract, @@ -16,27 +18,27 @@ import { NotificationsStart, ScopedHistory, HttpSetup, + IUiSettingsClient, + DocLinksStart, } from '../../../../../core/public'; -import { ISavedObjectsManagementServiceRegistry } from '../../services'; -import { Header, NotFoundErrors, Intro, Form } from './components'; -import { canViewInApp, bulkGetObjects } from '../../lib'; -import { SubmittedFormData } from '../types'; +import { Header, Inspect, NotFoundErrors } from './components'; +import { bulkGetObjects } from '../../lib/bulk_get_objects'; import { SavedObjectWithMetadata } from '../../types'; - -interface SavedObjectEditionProps { +import './saved_object_view.scss'; +export interface SavedObjectEditionProps { id: string; + savedObjectType: string; http: HttpSetup; - serviceName: string; - serviceRegistry: ISavedObjectsManagementServiceRegistry; capabilities: Capabilities; overlays: OverlayStart; notifications: NotificationsStart; notFoundType?: string; savedObjectsClient: SavedObjectsClientContract; history: ScopedHistory; + uiSettings: IUiSettingsClient; + docLinks: DocLinksStart['links']; } - -interface SavedObjectEditionState { +export interface SavedObjectEditionState { type: string; object?: SavedObjectWithMetadata; } @@ -45,7 +47,6 @@ const unableFindSavedObjectNotificationMessage = i18n.translate( 'savedObjectsManagement.objectView.unableFindSavedObjectNotificationMessage', { defaultMessage: 'Unable to find saved object' } ); - export class SavedObjectEdition extends Component< SavedObjectEditionProps, SavedObjectEditionState @@ -53,8 +54,7 @@ export class SavedObjectEdition extends Component< constructor(props: SavedObjectEditionProps) { super(props); - const { serviceRegistry, serviceName } = props; - const type = serviceRegistry.get(serviceName)!.service.type; + const { savedObjectType: type } = props; this.state = { object: undefined, @@ -85,54 +85,46 @@ export class SavedObjectEdition extends Component< }); } + canViewInApp(capabilities: Capabilities, obj?: SavedObjectWithMetadata) { + return obj && obj.meta.inAppUrl + ? get(capabilities, obj?.meta.inAppUrl?.uiCapabilitiesPath, false) && + Boolean(obj?.meta.inAppUrl?.path) + : false; + } + render() { - const { capabilities, notFoundType, serviceRegistry, http, serviceName, savedObjectsClient } = - this.props; - const { type } = this.state; + const { capabilities, notFoundType, http, uiSettings, docLinks } = this.props; const { object } = this.state; - const { edit: canEdit, delete: canDelete } = capabilities.savedObjectsManagement as Record< - string, - boolean - >; - const canView = canViewInApp(capabilities, type) && Boolean(object?.meta.inAppUrl?.path); - const service = serviceRegistry.get(serviceName)!.service; - + const { delete: canDelete } = capabilities.savedObjectsManagement as Record; + const canView = this.canViewInApp(capabilities, object); return ( -
-
this.delete()} - viewUrl={http.basePath.prepend(object?.meta.inAppUrl?.path || '')} - /> - - {notFoundType && ( - <> - - - - )} - {canEdit && ( - <> - - - - )} - {object && ( - <> - -
+ + +
this.delete()} + viewUrl={http.basePath.prepend(object?.meta.inAppUrl?.path || '')} + title={object?.meta.title} /> - - )} -
+ + {notFoundType && ( + + + + )} + {object && ( + + + + )} + + ); } @@ -167,15 +159,6 @@ export class SavedObjectEdition extends Component< } } - saveChanges = async ({ attributes, references }: SubmittedFormData) => { - const { savedObjectsClient, notifications } = this.props; - const { object, type } = this.state; - - await savedObjectsClient.update(object!.type, object!.id, attributes, { references }); - notifications.toasts.addSuccess(`Updated '${attributes.title}' ${type} object`); - this.redirectToListing(); - }; - redirectToListing() { this.props.history.push('/'); } diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index 46ea319ebc16..327a9635462c 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -192,7 +192,6 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "2", "meta": Object { - "editUrl": "/management/kibana/objects/savedSearches/2", "icon": "search", "inAppUrl": Object { "path": "/discover/2", @@ -205,7 +204,6 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "3", "meta": Object { - "editUrl": "/management/kibana/objects/savedDashboards/3", "icon": "dashboardApp", "inAppUrl": Object { "path": "/dashboard/3", @@ -218,7 +216,6 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "4", "meta": Object { - "editUrl": "/management/kibana/objects/savedVisualizations/4", "icon": "visualizeApp", "inAppUrl": Object { "path": "/edit/4", diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index bb426c91e827..8325e7dc886e 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -146,7 +146,6 @@ exports[`Table prevents saved objects from being deleted 1`] = ` Object { "actions": Array [ Object { - "available": [Function], "data-test-subj": "savedObjectsTableAction-inspect", "description": "Inspect this saved object", "icon": "inspect", @@ -362,7 +361,6 @@ exports[`Table should render normally 1`] = ` Object { "actions": Array [ Object { - "available": [Function], "data-test-subj": "savedObjectsTableAction-inspect", "description": "Inspect this saved object", "icon": "inspect", diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx index 30d172b89256..acdab1db4037 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx @@ -11,7 +11,6 @@ import { importFileMock, resolveImportErrorsMock } from './flyout.test.mocks'; import React from 'react'; import { shallowWithI18nProvider } from '@kbn/test/jest'; import { coreMock, httpServiceMock } from '../../../../../../core/public/mocks'; -import { serviceRegistryMock } from '../../../services/service_registry.mock'; import { Flyout, FlyoutProps, FlyoutState } from './flyout'; import { ShallowWrapper } from 'enzyme'; import { dataPluginMock } from '../../../../../data/public/mocks'; @@ -49,7 +48,6 @@ describe('Flyout', () => { } as any, http, allowedTypes: ['search', 'index-pattern', 'visualization'], - serviceRegistry: serviceRegistryMock.create(), search, basePath, }; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx index 26de8c5f8b25..607b3aeeac27 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx @@ -43,7 +43,6 @@ import { processImportResponse, ProcessedImportResponse, } from '../../../lib'; -import { ISavedObjectsManagementServiceRegistry } from '../../../services'; import { FailedImportConflict, RetryDecision } from '../../../lib/resolve_import_errors'; import { OverwriteModal } from './overwrite_modal'; import { ImportModeControl, ImportMode } from './import_mode_control'; @@ -53,7 +52,6 @@ const CREATE_NEW_COPIES_DEFAULT = false; const OVERWRITE_ALL_DEFAULT = true; export interface FlyoutProps { - serviceRegistry: ISavedObjectsManagementServiceRegistry; allowedTypes: string[]; close: () => void; done: () => void; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx index 8b07351f6c2c..c24faf4e1268 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import './import_summary.scss'; import _ from 'lodash'; import React, { Fragment, FC, useMemo } from 'react'; import { @@ -30,6 +29,7 @@ import type { IBasePath, } from 'kibana/public'; import { getDefaultTitle, getSavedObjectLabel, FailedImport } from '../../../lib'; +import './import_summary.scss'; const DEFAULT_ICON = 'apps'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx index f9171c7928db..8eb48ac91da6 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx @@ -298,7 +298,7 @@ export class Relationships extends Component goInspectObject(object), - available: (object: SavedObjectWithMetadata) => !!object.meta.editUrl, + available: (object: SavedObjectWithMetadata) => !!(object.type && object.id), }, ], }, diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx index 4e4bd51c4bb8..0645c0955f7a 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -243,7 +243,6 @@ export class Table extends PureComponent { type: 'icon', icon: 'inspect', onClick: (object) => goInspectObject(object), - available: (object) => !!object.meta.editUrl, 'data-test-subj': 'savedObjectsTableAction-inspect', }, { diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 21a629097cbb..025a7a320327 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -28,7 +28,6 @@ import { applicationServiceMock, } from '../../../../../core/public/mocks'; import { dataPluginMock } from '../../../../data/public/mocks'; -import { serviceRegistryMock } from '../../services/service_registry.mock'; import { actionServiceMock } from '../../services/action_service.mock'; import { columnServiceMock } from '../../services/column_service.mock'; import { @@ -122,7 +121,6 @@ describe('SavedObjectsTable', () => { defaultProps = { allowedTypes, - serviceRegistry: serviceRegistryMock.create(), actionRegistry: actionServiceMock.createStart(), columnRegistry: columnServiceMock.createStart(), savedObjectsClient: savedObjects.client, @@ -159,7 +157,6 @@ describe('SavedObjectsTable', () => { meta: { title: `MySearch`, icon: 'search', - editUrl: '/management/kibana/objects/savedSearches/2', inAppUrl: { path: '/discover/2', uiCapabilitiesPath: 'discover.show', @@ -172,7 +169,6 @@ describe('SavedObjectsTable', () => { meta: { title: `MyDashboard`, icon: 'dashboardApp', - editUrl: '/management/kibana/objects/savedDashboards/3', inAppUrl: { path: '/dashboard/3', uiCapabilitiesPath: 'dashboard.show', @@ -185,7 +181,6 @@ describe('SavedObjectsTable', () => { meta: { title: `MyViz`, icon: 'visualizeApp', - editUrl: '/management/kibana/objects/savedVisualizations/4', inAppUrl: { path: '/edit/4', uiCapabilitiesPath: 'visualize.show', 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 d4067cc21c2b..5001b52e819c 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 @@ -37,7 +37,6 @@ import { } from '../../lib'; import { SavedObjectWithMetadata } from '../../types'; import { - ISavedObjectsManagementServiceRegistry, SavedObjectsManagementActionServiceStart, SavedObjectsManagementColumnServiceStart, } from '../../services'; @@ -58,7 +57,6 @@ interface ExportAllOption { export interface SavedObjectsTableProps { allowedTypes: string[]; - serviceRegistry: ISavedObjectsManagementServiceRegistry; actionRegistry: SavedObjectsManagementActionServiceStart; columnRegistry: SavedObjectsManagementColumnServiceStart; savedObjectsClient: SavedObjectsClientContract; @@ -540,7 +538,6 @@ export class SavedObjectsTable extends Component void; history: ScopedHistory; }) => { - const { service: serviceName, id } = useParams<{ service: string; id: string }>(); + const { type, id } = useParams<{ type: string; id: string }>(); const capabilities = coreStart.application.capabilities; + const dockLinks = coreStart.docLinks.links; const { search } = useLocation(); const query = parse(search); - const service = serviceRegistry.get(serviceName); useEffect(() => { setBreadcrumbs([ @@ -42,27 +40,31 @@ const SavedObjectsEditionPage = ({ href: '/', }, { - text: i18n.translate('savedObjectsManagement.breadcrumb.edit', { - defaultMessage: 'Edit {savedObjectType}', - values: { savedObjectType: service?.service.type ?? 'object' }, + text: i18n.translate('savedObjectsManagement.breadcrumb.inspect', { + defaultMessage: 'Inspect {savedObjectType}', + values: { savedObjectType: type }, }), }, ]); - }, [setBreadcrumbs, service]); + }, [setBreadcrumbs, type]); return ( - + ); diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx index f22f0333ec22..dccf33efc531 100644 --- a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx +++ b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx @@ -75,13 +75,11 @@ const SavedObjectsTablePage = ({ spacesApi ? spacesApi.ui.components.getSpacesContextProvider : getEmptyFunctionComponent, [spacesApi] ); - return ( { - const { editUrl } = savedObject.meta; - if (editUrl) { - return coreStart.application.navigateToUrl( - coreStart.http.basePath.prepend(`/app${editUrl}`) - ); - } + const savedObjectEditUrl = savedObject.meta.editUrl + ? `/app${savedObject.meta.editUrl}` + : `/app/management/kibana/objects/${savedObject.type}/${savedObject.id}`; + coreStart.application.navigateToUrl(coreStart.http.basePath.prepend(savedObjectEditUrl)); }} canGoInApp={(savedObject) => { const { inAppUrl } = savedObject.meta; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 0a81da0cdceb..2363c0ca103a 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7428,6 +7428,12 @@ "description": "Non-default value of setting." } }, + "metrics:allowStringIndices": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "query:allowLeadingWildcards": { "type": "boolean", "_meta": { diff --git a/src/plugins/vis_types/timelion/public/helpers/timelion_request_handler.ts b/src/plugins/vis_types/timelion/public/helpers/timelion_request_handler.ts index e44d74cfd72a..e9a076b4dc83 100644 --- a/src/plugins/vis_types/timelion/public/helpers/timelion_request_handler.ts +++ b/src/plugins/vis_types/timelion/public/helpers/timelion_request_handler.ts @@ -120,7 +120,7 @@ export function getTimelionRequestHandler({ const err = new Error( `${i18n.translate('timelion.requestHandlerErrorTitle', { defaultMessage: 'Timelion request error', - })}: ${e.body.title} ${e.body.message}` + })}:${e.body.title ? ' ' + e.body.title : ''} ${e.body.message}` ); err.stack = e.stack; throw err; diff --git a/src/plugins/vis_types/timelion/server/handlers/chain_runner.js b/src/plugins/vis_types/timelion/server/handlers/chain_runner.js index b7bdbcdcb57a..3710d015f3f6 100644 --- a/src/plugins/vis_types/timelion/server/handlers/chain_runner.js +++ b/src/plugins/vis_types/timelion/server/handlers/chain_runner.js @@ -25,7 +25,15 @@ export default function chainRunner(tlConfig) { let sheet; function throwWithCell(cell, exception) { - throw new Error(' in cell #' + (cell + 1) + ': ' + exception.message); + throw new Error( + i18n.translate('timelion.serverSideErrors.errorInCell', { + defaultMessage: ' in cell #{number}: {message}', + values: { + number: cell + 1, + message: exception.message, + }, + }) + ); } // Invokes a modifier function, resolving arguments into series as needed diff --git a/src/plugins/vis_types/timelion/server/routes/run.ts b/src/plugins/vis_types/timelion/server/routes/run.ts index b3ab3c61c15d..b8c0ce4ea659 100644 --- a/src/plugins/vis_types/timelion/server/routes/run.ts +++ b/src/plugins/vis_types/timelion/server/routes/run.ts @@ -94,15 +94,18 @@ export function runRoute( allowedGraphiteUrls: configManager.getGraphiteUrls(), esShardTimeout: configManager.getEsShardTimeout(), }); - const chainRunner = chainRunnerFn(tlConfig); - const sheet = await Bluebird.all(chainRunner.processRequest(request.body)); - - return response.ok({ - body: { - sheet, - stats: chainRunner.getStats(), - }, - }); + try { + const chainRunner = chainRunnerFn(tlConfig); + const sheet = await Bluebird.all(chainRunner.processRequest(request.body)); + return response.ok({ + body: { + sheet, + stats: chainRunner.getStats(), + }, + }); + } catch (e) { + return response.badRequest({ body: { message: e.message } }); + } }) ); } diff --git a/src/plugins/vis_types/timeseries/common/constants.ts b/src/plugins/vis_types/timeseries/common/constants.ts index bddbf095e895..4f15cea7faad 100644 --- a/src/plugins/vis_types/timeseries/common/constants.ts +++ b/src/plugins/vis_types/timeseries/common/constants.ts @@ -6,7 +6,10 @@ * Side Public License, v 1. */ -export const MAX_BUCKETS_SETTING = 'metrics:max_buckets'; +export const UI_SETTINGS = { + MAX_BUCKETS_SETTING: 'metrics:max_buckets', + ALLOW_STRING_INDICES: 'metrics:allowStringIndices', +}; export const INDEXES_SEPARATOR = ','; export const AUTO_INTERVAL = 'auto'; export const ROUTES = { diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx index 5f5506ce4a33..43ef091da251 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx @@ -17,9 +17,17 @@ import { EuiSpacer, EuiSwitch, EuiText, + EuiLink, } from '@elastic/eui'; import type { PopoverProps } from './types'; +import { getCoreStart, getUISettings } from '../../../../services'; +import { UI_SETTINGS } from '../../../../../common/constants'; + +const allowStringIndicesMessage = i18n.translate( + 'visTypeTimeseries.indexPatternSelect.switchModePopover.allowStringIndices', + { defaultMessage: 'Allow string indices in TSVB' } +); export const SwitchModePopover = ({ onModeChange, useKibanaIndices }: PopoverProps) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -30,6 +38,39 @@ export const SwitchModePopover = ({ onModeChange, useKibanaIndices }: PopoverPro onModeChange(!useKibanaIndices); }, [onModeChange, useKibanaIndices]); + const { application } = getCoreStart(); + const canEditAdvancedSettings = application.capabilities.advancedSettings.save; + + const handleAllowStringIndicesLinkClick = useCallback( + () => + application.navigateToApp('management', { + path: `/kibana/settings?query=${UI_SETTINGS.ALLOW_STRING_INDICES}`, + }), + [application] + ); + + const stringIndicesAllowed = getUISettings().get(UI_SETTINGS.ALLOW_STRING_INDICES); + const isSwitchDisabled = useKibanaIndices && !stringIndicesAllowed; + + let allowStringIndicesLabel; + if (!stringIndicesAllowed) { + allowStringIndicesLabel = ( + + {allowStringIndicesMessage} + + ) : ( + {allowStringIndicesMessage} + ), + }} + /> + ); + } + return ( } isOpen={isPopoverOpen} closePopover={closePopover} style={{ height: 'auto' }} + initialFocus={false} > -
+
{i18n.translate('visTypeTimeseries.indexPatternSelect.switchModePopover.title', { defaultMessage: 'Index pattern selection mode', @@ -59,7 +104,10 @@ export const SwitchModePopover = ({ onModeChange, useKibanaIndices }: PopoverPro @@ -68,10 +116,11 @@ export const SwitchModePopover = ({ onModeChange, useKibanaIndices }: PopoverPro label={i18n.translate( 'visTypeTimeseries.indexPatternSelect.switchModePopover.useKibanaIndices', { - defaultMessage: 'Use only Kibana index patterns', + defaultMessage: 'Use only index patterns', } )} onChange={switchMode} + disabled={isSwitchDisabled} data-test-subj="switchIndexPatternSelectionMode" />
diff --git a/src/plugins/vis_types/timeseries/public/application/components/use_index_patter_mode_callout.tsx b/src/plugins/vis_types/timeseries/public/application/components/use_index_patter_mode_callout.tsx index 6191df2ecce5..9684b7b7ff35 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/use_index_patter_mode_callout.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/use_index_patter_mode_callout.tsx @@ -43,7 +43,7 @@ export const UseIndexPatternModeCallout = () => {

diff --git a/src/plugins/vis_types/timeseries/server/lib/get_vis_data.ts b/src/plugins/vis_types/timeseries/server/lib/get_vis_data.ts index bc4fbf9159a0..a76132e0fbd2 100644 --- a/src/plugins/vis_types/timeseries/server/lib/get_vis_data.ts +++ b/src/plugins/vis_types/timeseries/server/lib/get_vis_data.ts @@ -20,8 +20,8 @@ import { getSeriesData } from './vis_data/get_series_data'; import { getTableData } from './vis_data/get_table_data'; import { getEsQueryConfig } from './vis_data/helpers/get_es_query_uisettings'; import { getCachedIndexPatternFetcher } from './search_strategies/lib/cached_index_pattern_fetcher'; -import { MAX_BUCKETS_SETTING } from '../../common/constants'; import { getIntervalAndTimefield } from './vis_data/get_interval_and_timefield'; +import { UI_SETTINGS } from '../../common/constants'; export async function getVisData( requestContext: VisTypeTimeseriesRequestHandlerContext, @@ -57,7 +57,7 @@ export async function getVisData( index = await cachedIndexPatternFetcher(index.indexPatternString, true); } - const maxBuckets = await uiSettings.get(MAX_BUCKETS_SETTING); + const maxBuckets = await uiSettings.get(UI_SETTINGS.MAX_BUCKETS_SETTING); const { min, max } = request.body.timerange; return getIntervalAndTimefield( diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts index 0fa92b5f061f..ff1c3c0ac71e 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts @@ -15,7 +15,7 @@ import type { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesRequest, } from '../../../types'; -import { MAX_BUCKETS_SETTING } from '../../../../common/constants'; +import { UI_SETTINGS } from '../../../../common/constants'; export class DefaultSearchStrategy extends AbstractSearchStrategy { async checkForViability( @@ -29,7 +29,7 @@ export class DefaultSearchStrategy extends AbstractSearchStrategy { capabilities: new DefaultSearchCapabilities({ panel: req.body.panels ? req.body.panels[0] : null, timezone: req.body.timerange?.timezone, - maxBucketsLimit: await uiSettings.get(MAX_BUCKETS_SETTING), + maxBucketsLimit: await uiSettings.get(UI_SETTINGS.MAX_BUCKETS_SETTING), }), }; } diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts index 903e7f239f82..e3ede5777422 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts @@ -20,7 +20,7 @@ import type { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, } from '../../../types'; -import { MAX_BUCKETS_SETTING } from '../../../../common/constants'; +import { UI_SETTINGS } from '../../../../common/constants'; const getRollupIndices = (rollupData: { [key: string]: any }) => Object.keys(rollupData); const isIndexPatternContainsWildcard = (indexPattern: string) => indexPattern.includes('*'); @@ -75,7 +75,7 @@ export class RollupSearchStrategy extends AbstractSearchStrategy { capabilities = new RollupSearchCapabilities( { - maxBucketsLimit: await uiSettings.get(MAX_BUCKETS_SETTING), + maxBucketsLimit: await uiSettings.get(UI_SETTINGS.MAX_BUCKETS_SETTING), panel: req.body.panels ? req.body.panels[0] : null, }, fieldsCapabilities, diff --git a/src/plugins/vis_types/timeseries/server/ui_settings.ts b/src/plugins/vis_types/timeseries/server/ui_settings.ts index e61635058cee..2adbc31482f0 100644 --- a/src/plugins/vis_types/timeseries/server/ui_settings.ts +++ b/src/plugins/vis_types/timeseries/server/ui_settings.ts @@ -10,11 +10,10 @@ import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import { UiSettingsParams } from 'kibana/server'; - -import { MAX_BUCKETS_SETTING } from '../common/constants'; +import { UI_SETTINGS } from '../common/constants'; export const getUiSettings: () => Record = () => ({ - [MAX_BUCKETS_SETTING]: { + [UI_SETTINGS.MAX_BUCKETS_SETTING]: { name: i18n.translate('visTypeTimeseries.advancedSettings.maxBucketsTitle', { defaultMessage: 'TSVB buckets limit', }), @@ -25,4 +24,16 @@ export const getUiSettings: () => Record = () => ({ }), schema: schema.number(), }, + [UI_SETTINGS.ALLOW_STRING_INDICES]: { + name: i18n.translate('visTypeTimeseries.advancedSettings.allowStringIndicesTitle', { + defaultMessage: 'Allow string indices in TSVB', + }), + value: false, + requiresPageReload: true, + description: i18n.translate('visTypeTimeseries.advancedSettings.allowStringIndicesText', { + defaultMessage: + 'Enables you to use index patterns and Elasticsearch indices in TSVB visualizations.', + }), + schema: schema.boolean(), + }, }); diff --git a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.ts b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.ts index cf5bf15d1505..777806d90d9a 100644 --- a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.ts +++ b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.ts @@ -72,7 +72,7 @@ export class VegaMapView extends VegaBaseView { const { zoom, maxZoom, minZoom } = validateZoomSettings( this._parser.mapConfig, defaults, - this.onWarn + this.onWarn.bind(this) ); const { signals } = this._vegaStateRestorer.restore() || {}; diff --git a/src/plugins/visualizations/server/saved_objects/visualization.ts b/src/plugins/visualizations/server/saved_objects/visualization.ts index 880e277294fc..53027d5d5046 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization.ts @@ -20,9 +20,6 @@ export const visualizationSavedObjectType: SavedObjectsType = { getTitle(obj) { return obj.attributes.title; }, - getEditUrl(obj) { - return `/management/kibana/objects/savedVisualizations/${encodeURIComponent(obj.id)}`; - }, getInAppUrl(obj) { return { path: `/app/visualize#/edit/${encodeURIComponent(obj.id)}`, diff --git a/src/plugins/visualize/public/application/components/visualize_no_match.tsx b/src/plugins/visualize/public/application/components/visualize_no_match.tsx index 3b735eb23671..ad993af43008 100644 --- a/src/plugins/visualize/public/application/components/visualize_no_match.tsx +++ b/src/plugins/visualize/public/application/components/visualize_no_match.tsx @@ -24,7 +24,7 @@ export const VisualizeNoMatch = () => { services.restorePreviousUrl(); const { navigated } = services.urlForwarding.navigateToLegacyKibanaUrl( - services.history.location.pathname + services.history.location.pathname + services.history.location.search ); if (!navigated) { diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index aef131ce8d53..b128c0920974 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -162,7 +162,7 @@ export class VisualizePlugin pluginsStart.data.indexPatterns.clearCache(); // make sure a default index pattern exists // if not, the page will be redirected to management and visualize won't be rendered - await pluginsStart.data.indexPatterns.ensureDefaultIndexPattern(); + await pluginsStart.data.indexPatterns.ensureDefaultDataView(); appMounted(); diff --git a/test/accessibility/apps/dashboard.ts b/test/accessibility/apps/dashboard.ts index 5a3ec9d8fc86..c8a7ac566b55 100644 --- a/test/accessibility/apps/dashboard.ts +++ b/test/accessibility/apps/dashboard.ts @@ -15,7 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const listingTable = getService('listingTable'); - describe('Dashboard', () => { + describe.skip('Dashboard', () => { const dashboardName = 'Dashboard Listing A11y'; const clonedDashboardName = 'Dashboard Listing A11y Copy'; diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index f3f4b56cdccf..9a5f94f9d8b9 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -180,8 +180,6 @@ export default function ({ getService }: FtrProviderContext) { icon: 'discoverApp', title: 'OneRecord', hiddenType: false, - editUrl: - '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -200,8 +198,6 @@ export default function ({ getService }: FtrProviderContext) { icon: 'dashboardApp', title: 'Dashboard', hiddenType: false, - editUrl: - '/management/kibana/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', @@ -220,8 +216,6 @@ export default function ({ getService }: FtrProviderContext) { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', hiddenType: false, - editUrl: - '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -232,8 +226,6 @@ export default function ({ getService }: FtrProviderContext) { icon: 'visualizeApp', title: 'Visualization', hiddenType: false, - editUrl: - '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts index 5fbd5cad8ec8..8ee5005348bc 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -21,7 +21,7 @@ export default function ({ getService }: FtrProviderContext) { meta: schema.object({ title: schema.string(), icon: schema.string(), - editUrl: schema.string(), + editUrl: schema.maybe(schema.string()), inAppUrl: schema.object({ path: schema.string(), uiCapabilitiesPath: schema.string(), @@ -103,8 +103,6 @@ export default function ({ getService }: FtrProviderContext) { meta: { title: 'VisualizationFromSavedSearch', icon: 'visualizeApp', - editUrl: - '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -147,8 +145,6 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -192,8 +188,6 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -209,8 +203,6 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -234,8 +226,6 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -251,8 +241,6 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -296,8 +284,6 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -313,8 +299,6 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'dashboardApp', title: 'Dashboard', - editUrl: - '/management/kibana/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', @@ -340,8 +324,6 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -385,8 +367,6 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -402,8 +382,6 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -429,8 +407,6 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -475,8 +451,6 @@ export default function ({ getService }: FtrProviderContext) { { id: 'add810b0-3224-11e8-a572-ffca06da1357', meta: { - editUrl: - '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', diff --git a/test/functional/apps/dashboard/bwc_shared_urls.ts b/test/functional/apps/dashboard/bwc_shared_urls.ts index d40cf03327fd..569cd8e2a67d 100644 --- a/test/functional/apps/dashboard/bwc_shared_urls.ts +++ b/test/functional/apps/dashboard/bwc_shared_urls.ts @@ -86,6 +86,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('6.0 urls', () => { + let savedDashboardId: string; + it('loads an unsaved dashboard', async function () { const url = `${kibanaLegacyBaseUrl}#/dashboard?${urlQuery}`; log.debug(`Navigating to ${url}`); @@ -106,8 +108,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { storeTimeWithDashboard: true, }); - const id = await PageObjects.dashboard.getDashboardIdFromCurrentUrl(); - const url = `${kibanaLegacyBaseUrl}#/dashboard/${id}`; + savedDashboardId = await PageObjects.dashboard.getDashboardIdFromCurrentUrl(); + const url = `${kibanaLegacyBaseUrl}#/dashboard/${savedDashboardId}`; log.debug(`Navigating to ${url}`); await browser.get(url, true); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -121,6 +123,22 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardExpect.selectedLegendColorCount('#F9D9F9', 5); }); + it('loads a saved dashboard with query via dashboard_no_match', async function () { + await PageObjects.dashboard.gotoDashboardLandingPage(); + const currentUrl = await browser.getCurrentUrl(); + const dashboardBaseUrl = currentUrl.substring(0, currentUrl.indexOf('/app/dashboards')); + const url = `${dashboardBaseUrl}/app/dashboards#/dashboard/${savedDashboardId}?_a=(query:(language:kuery,query:'boop'))`; + log.debug(`Navigating to ${url}`); + await browser.get(url); + await PageObjects.header.waitUntilLoadingHasFinished(); + + const query = await queryBar.getQueryString(); + expect(query).to.equal('boop'); + + await dashboardExpect.panelCount(2); + await PageObjects.dashboard.waitForRenderComplete(); + }); + it('uiState in url takes precedence over saved dashboard state', async function () { const id = await PageObjects.dashboard.getDashboardIdFromCurrentUrl(); const updatedQuery = urlQuery.replace(/F9D9F9/g, '000000'); diff --git a/test/functional/apps/dashboard/dashboard_unsaved_state.ts b/test/functional/apps/dashboard/dashboard_unsaved_state.ts index 6b71dd34b76f..8043c8bf8cc3 100644 --- a/test/functional/apps/dashboard/dashboard_unsaved_state.ts +++ b/test/functional/apps/dashboard/dashboard_unsaved_state.ts @@ -12,6 +12,9 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); + const browser = getService('browser'); + const queryBar = getService('queryBar'); + const filterBar = getService('filterBar'); const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); @@ -19,9 +22,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { let originalPanelCount = 0; let unsavedPanelCount = 0; + const testQuery = 'Test Query'; - // FLAKY: https://github.com/elastic/kibana/issues/91191 - describe.skip('dashboard unsaved panels', () => { + describe('dashboard unsaved state', () => { before(async () => { await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); await kibanaServer.uiSettings.replace({ @@ -31,79 +34,123 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.preserveCrossAppState(); await PageObjects.dashboard.loadSavedDashboard('few panels'); await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.waitForRenderComplete(); originalPanelCount = await PageObjects.dashboard.getPanelCount(); }); - it('does not show unsaved changes badge when there are no unsaved changes', async () => { - await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); - }); + describe('view mode state', () => { + before(async () => { + await queryBar.setQuery(testQuery); + await filterBar.addFilter('bytes', 'exists'); + await queryBar.submitQuery(); + }); - it('shows the unsaved changes badge after adding panels', async () => { - await PageObjects.dashboard.switchToEditMode(); - // add an area chart by value - await dashboardAddPanel.clickEditorMenuButton(); - await dashboardAddPanel.clickAggBasedVisualizations(); - await PageObjects.visualize.clickAreaChart(); - await PageObjects.visualize.clickNewSearch(); - await PageObjects.visualize.saveVisualizationAndReturn(); + const validateQueryAndFilter = async () => { + const query = await queryBar.getQueryString(); + expect(query).to.eql(testQuery); + const filterCount = await filterBar.getFilterCount(); + expect(filterCount).to.eql(1); + }; + + it('persists after navigating to the listing page and back', async () => { + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + await PageObjects.dashboard.waitForRenderComplete(); + await validateQueryAndFilter(); + }); - // add a metric by reference - await dashboardAddPanel.addVisualization('Rendering-Test: metric'); + it('persists after navigating to Visualize and back', async () => { + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.visualize.gotoVisualizationLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.common.navigateToApp('dashboards'); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + await PageObjects.dashboard.waitForRenderComplete(); + await validateQueryAndFilter(); + }); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('dashboardUnsavedChangesBadge'); - }); + it('persists after a hard refresh', async () => { + await browser.refresh(); + const alert = await browser.getAlert(); + await alert?.accept(); + await PageObjects.dashboard.waitForRenderComplete(); + await validateQueryAndFilter(); + }); - it('has correct number of panels', async () => { - unsavedPanelCount = await PageObjects.dashboard.getPanelCount(); - expect(unsavedPanelCount).to.eql(originalPanelCount + 2); + after(async () => { + // discard changes made in view mode + await PageObjects.dashboard.switchToEditMode(); + await PageObjects.dashboard.clickCancelOutOfEditMode(); + }); }); - it('retains unsaved panel count after navigating to listing page and back', async () => { - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.dashboard.gotoDashboardLandingPage(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.dashboard.loadSavedDashboard('few panels'); - await PageObjects.dashboard.switchToEditMode(); - const currentPanelCount = await PageObjects.dashboard.getPanelCount(); - expect(currentPanelCount).to.eql(unsavedPanelCount); - }); + describe('edit mode state', () => { + const addPanels = async () => { + // add an area chart by value + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); + await PageObjects.visualize.clickAreaChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.saveVisualizationAndReturn(); + + // add a metric by reference + await dashboardAddPanel.addVisualization('Rendering-Test: metric'); + }; + + it('does not show unsaved changes badge when there are no unsaved changes', async () => { + await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); + }); - it('retains unsaved panel count after navigating to another app and back', async () => { - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.visualize.gotoVisualizationLandingPage(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.common.navigateToApp('dashboards'); - await PageObjects.dashboard.loadSavedDashboard('few panels'); - await PageObjects.dashboard.switchToEditMode(); - const currentPanelCount = await PageObjects.dashboard.getPanelCount(); - expect(currentPanelCount).to.eql(unsavedPanelCount); - }); + it('shows the unsaved changes badge after adding panels', async () => { + await PageObjects.dashboard.switchToEditMode(); + await addPanels(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('dashboardUnsavedChangesBadge'); + }); - it('resets to original panel count upon entering view mode', async () => { - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.dashboard.clickCancelOutOfEditMode(); - await PageObjects.header.waitUntilLoadingHasFinished(); - const currentPanelCount = await PageObjects.dashboard.getPanelCount(); - expect(currentPanelCount).to.eql(originalPanelCount); - }); + it('has correct number of panels', async () => { + unsavedPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(unsavedPanelCount).to.eql(originalPanelCount + 2); + }); - it('shows unsaved changes badge in view mode if changes have not been discarded', async () => { - await testSubjects.existOrFail('dashboardUnsavedChangesBadge'); - }); + it('retains unsaved panel count after navigating to listing page and back', async () => { + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + const currentPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(currentPanelCount).to.eql(unsavedPanelCount); + }); - it('retains unsaved panel count after returning to edit mode', async () => { - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.dashboard.switchToEditMode(); - await PageObjects.header.waitUntilLoadingHasFinished(); - const currentPanelCount = await PageObjects.dashboard.getPanelCount(); - expect(currentPanelCount).to.eql(unsavedPanelCount); - }); + it('retains unsaved panel count after navigating to another app and back', async () => { + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.visualize.gotoVisualizationLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.common.navigateToApp('dashboards'); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + const currentPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(currentPanelCount).to.eql(unsavedPanelCount); + }); - it('does not show unsaved changes badge after saving', async () => { - await PageObjects.dashboard.saveDashboard('Unsaved State Test'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); + it('resets to original panel count after discarding changes', async () => { + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.clickCancelOutOfEditMode(); + await PageObjects.header.waitUntilLoadingHasFinished(); + const currentPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(currentPanelCount).to.eql(originalPanelCount); + expect(PageObjects.dashboard.getIsInViewMode()).to.eql(true); + }); + + it('does not show unsaved changes badge after saving', async () => { + await PageObjects.dashboard.switchToEditMode(); + await addPanels(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.saveDashboard('Unsaved State Test'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); + }); }); }); } diff --git a/test/functional/apps/discover/_runtime_fields_editor.ts b/test/functional/apps/discover/_runtime_fields_editor.ts index 642743d3a037..4757807cb7ac 100644 --- a/test/functional/apps/discover/_runtime_fields_editor.ts +++ b/test/functional/apps/discover/_runtime_fields_editor.ts @@ -31,7 +31,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await fieldEditor.save(); }; - describe('discover integration with runtime fields editor', function describeIndexTests() { + // Failing: https://github.com/elastic/kibana/issues/111922 + describe.skip('discover integration with runtime fields editor', function describeIndexTests() { before(async function () { await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); diff --git a/test/functional/apps/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts deleted file mode 100644 index f4bf45c0b7f7..000000000000 --- a/test/functional/apps/saved_objects_management/edit_saved_object.ts +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -export default function ({ getPageObjects, getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); - const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['common', 'settings', 'savedObjects']); - const browser = getService('browser'); - const find = getService('find'); - - const setFieldValue = async (fieldName: string, value: string) => { - return testSubjects.setValue(`savedObjects-editField-${fieldName}`, value); - }; - - const getFieldValue = async (fieldName: string) => { - return testSubjects.getAttribute(`savedObjects-editField-${fieldName}`, 'value'); - }; - - const setAceEditorFieldValue = async (fieldName: string, fieldValue: string) => { - const editorId = `savedObjects-editField-${fieldName}-aceEditor`; - await find.clickByCssSelector(`#${editorId}`); - return browser.execute( - (editor: string, value: string) => { - return (window as any).ace.edit(editor).setValue(value); - }, - editorId, - fieldValue - ); - }; - - const getAceEditorFieldValue = async (fieldName: string) => { - const editorId = `savedObjects-editField-${fieldName}-aceEditor`; - await find.clickByCssSelector(`#${editorId}`); - return browser.execute((editor: string) => { - return (window as any).ace.edit(editor).getValue() as string; - }, editorId); - }; - - const focusAndClickButton = async (buttonSubject: string) => { - const button = await testSubjects.find(buttonSubject); - await button.scrollIntoViewIfNecessary(); - await delay(10); - await button.focus(); - await delay(10); - await button.click(); - // Allow some time for the transition/animations to occur before assuming the click is done - await delay(10); - }; - - describe('saved objects edition page', () => { - beforeEach(async () => { - await esArchiver.load( - 'test/functional/fixtures/es_archiver/saved_objects_management/edit_saved_object' - ); - }); - - afterEach(async () => { - await esArchiver.unload( - 'test/functional/fixtures/es_archiver/saved_objects_management/edit_saved_object' - ); - }); - - it('allows to update the saved object when submitting', async () => { - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); - - let objects = await PageObjects.savedObjects.getRowTitles(); - expect(objects.includes('A Dashboard')).to.be(true); - - await PageObjects.common.navigateToUrl( - 'management', - 'kibana/objects/savedDashboards/i-exist', - { - shouldUseHashForSubUrl: false, - } - ); - - await testSubjects.existOrFail('savedObjectEditSave'); - - expect(await getFieldValue('title')).to.eql('A Dashboard'); - - await setFieldValue('title', 'Edited Dashboard'); - await setFieldValue('description', 'Some description'); - - await focusAndClickButton('savedObjectEditSave'); - - objects = await PageObjects.savedObjects.getRowTitles(); - expect(objects.includes('A Dashboard')).to.be(false); - expect(objects.includes('Edited Dashboard')).to.be(true); - - await PageObjects.common.navigateToUrl( - 'management', - 'kibana/objects/savedDashboards/i-exist', - { - shouldUseHashForSubUrl: false, - } - ); - - expect(await getFieldValue('title')).to.eql('Edited Dashboard'); - expect(await getFieldValue('description')).to.eql('Some description'); - }); - - it('allows to delete a saved object', async () => { - await PageObjects.common.navigateToUrl( - 'management', - 'kibana/objects/savedDashboards/i-exist', - { - shouldUseHashForSubUrl: false, - } - ); - - await focusAndClickButton('savedObjectEditDelete'); - await PageObjects.common.clickConfirmOnModal(); - - const objects = await PageObjects.savedObjects.getRowTitles(); - expect(objects.includes('A Dashboard')).to.be(false); - }); - - it('preserves the object references when saving', async () => { - const testVisualizationUrl = - 'kibana/objects/savedVisualizations/75c3e060-1e7c-11e9-8488-65449e65d0ed'; - const visualizationRefs = [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: 'logstash-*', - }, - ]; - - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); - - const objects = await PageObjects.savedObjects.getRowTitles(); - expect(objects.includes('A Pie')).to.be(true); - - await PageObjects.common.navigateToUrl('management', testVisualizationUrl, { - shouldUseHashForSubUrl: false, - }); - - await testSubjects.existOrFail('savedObjectEditSave'); - - let displayedReferencesValue = await getAceEditorFieldValue('references'); - - expect(JSON.parse(displayedReferencesValue)).to.eql(visualizationRefs); - - await focusAndClickButton('savedObjectEditSave'); - - await PageObjects.savedObjects.getRowTitles(); - - await PageObjects.common.navigateToUrl('management', testVisualizationUrl, { - shouldUseHashForSubUrl: false, - }); - - // Parsing to avoid random keys ordering issues in raw string comparison - expect(JSON.parse(await getAceEditorFieldValue('references'))).to.eql(visualizationRefs); - - await setAceEditorFieldValue('references', JSON.stringify([], undefined, 2)); - - await focusAndClickButton('savedObjectEditSave'); - - await PageObjects.savedObjects.getRowTitles(); - - await PageObjects.common.navigateToUrl('management', testVisualizationUrl, { - shouldUseHashForSubUrl: false, - }); - - displayedReferencesValue = await getAceEditorFieldValue('references'); - - expect(JSON.parse(displayedReferencesValue)).to.eql([]); - }); - }); -} diff --git a/test/functional/apps/saved_objects_management/index.ts b/test/functional/apps/saved_objects_management/index.ts index 0b367b284e74..12e0cc8863f1 100644 --- a/test/functional/apps/saved_objects_management/index.ts +++ b/test/functional/apps/saved_objects_management/index.ts @@ -11,7 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function savedObjectsManagementApp({ loadTestFile }: FtrProviderContext) { describe('saved objects management', function savedObjectsManagementAppTestSuite() { this.tags('ciGroup7'); - loadTestFile(require.resolve('./edit_saved_object')); + loadTestFile(require.resolve('./inspect_saved_objects')); loadTestFile(require.resolve('./show_relationships')); }); } diff --git a/test/functional/apps/saved_objects_management/inspect_saved_objects.ts b/test/functional/apps/saved_objects_management/inspect_saved_objects.ts new file mode 100644 index 000000000000..839c262acffa --- /dev/null +++ b/test/functional/apps/saved_objects_management/inspect_saved_objects.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common', 'settings', 'savedObjects']); + const find = getService('find'); + + const focusAndClickButton = async (buttonSubject: string) => { + const button = await testSubjects.find(buttonSubject); + await button.scrollIntoViewIfNecessary(); + await delay(10); + await button.focus(); + await delay(10); + await button.click(); + // Allow some time for the transition/animations to occur before assuming the click is done + await delay(10); + }; + const textIncludesAll = (text: string, items: string[]) => { + const bools = items.map((item) => !!text.includes(item)); + return bools.every((currBool) => currBool === true); + }; + + describe('saved objects edition page', () => { + beforeEach(async () => { + await esArchiver.load( + 'test/functional/fixtures/es_archiver/saved_objects_management/edit_saved_object' + ); + }); + + afterEach(async () => { + await esArchiver.unload( + 'test/functional/fixtures/es_archiver/saved_objects_management/edit_saved_object' + ); + }); + + it('allows to view the saved object', async () => { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaSavedObjects(); + const objects = await PageObjects.savedObjects.getRowTitles(); + expect(objects.includes('A Dashboard')).to.be(true); + await PageObjects.common.navigateToUrl('management', 'kibana/objects/dashboard/i-exist', { + shouldUseHashForSubUrl: false, + }); + const inspectContainer = await find.byClassName('kibanaCodeEditor'); + const visibleContainerText = await inspectContainer.getVisibleText(); + // ensure that something renders visibly + expect( + textIncludesAll(visibleContainerText, [ + 'A Dashboard', + 'title', + 'id', + 'type', + 'attributes', + 'references', + ]) + ).to.be(true); + }); + + it('allows to delete a saved object', async () => { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaSavedObjects(); + let objects = await PageObjects.savedObjects.getRowTitles(); + expect(objects.includes('A Dashboard')).to.be(true); + await PageObjects.savedObjects.clickInspectByTitle('A Dashboard'); + await PageObjects.common.navigateToUrl('management', 'kibana/objects/dashboard/i-exist', { + shouldUseHashForSubUrl: false, + }); + await focusAndClickButton('savedObjectEditDelete'); + await PageObjects.common.clickConfirmOnModal(); + + objects = await PageObjects.savedObjects.getRowTitles(); + expect(objects.includes('A Dashboard')).to.be(false); + }); + }); +} diff --git a/test/functional/apps/visualize/_timelion.ts b/test/functional/apps/visualize/_timelion.ts index ea8cb8b13ba4..85dbf7cc5ca9 100644 --- a/test/functional/apps/visualize/_timelion.ts +++ b/test/functional/apps/visualize/_timelion.ts @@ -277,17 +277,20 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('should show field suggestions for split argument when index pattern set', async () => { await monacoEditor.setCodeEditorValue(''); await monacoEditor.typeCodeEditorValue( - '.es(index=logstash-*, timefield=@timestamp ,split=', + '.es(index=logstash-*, timefield=@timestamp, split=', 'timelionCodeEditor' ); + // wait for split fields to load + await common.sleep(300); const suggestions = await timelion.getSuggestionItemsText(); + expect(suggestions.length).not.to.eql(0); expect(suggestions[0].includes('@message.raw')).to.eql(true); }); it('should show field suggestions for metric argument when index pattern set', async () => { await monacoEditor.typeCodeEditorValue( - '.es(index=logstash-*, timefield=@timestamp ,metric=avg:', + '.es(index=logstash-*, timefield=@timestamp, metric=avg:', 'timelionCodeEditor' ); const suggestions = await timelion.getSuggestionItemsText(); diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index 6a5c062268c2..c530b00364fd 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -16,6 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const inspector = getService('inspector'); const retry = getService('retry'); const security = getService('security'); + const kibanaServer = getService('kibanaServer'); const { timePicker, visChart, visualBuilder, visualize, settings } = getPageObjects([ 'timePicker', @@ -95,6 +96,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await visualBuilder.setFieldForAggregation('machine.ram'); const kibanaIndexPatternModeValue = await visualBuilder.getMetricValue(); + await kibanaServer.uiSettings.update({ 'metrics:allowStringIndices': true }); + await browser.refresh(); await visualBuilder.clickPanelOptions('metric'); await visualBuilder.switchIndexPatternSelectionMode(false); const stringIndexPatternModeValue = await visualBuilder.getMetricValue(); diff --git a/test/functional/apps/visualize/_tsvb_time_series.ts b/test/functional/apps/visualize/_tsvb_time_series.ts index 21bee2d16442..09dc61e9f5f6 100644 --- a/test/functional/apps/visualize/_tsvb_time_series.ts +++ b/test/functional/apps/visualize/_tsvb_time_series.ts @@ -433,6 +433,49 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { after(async () => await visualBuilder.toggleNewChartsLibraryWithDebug(false)); }); + + describe('index pattern selection mode', () => { + it('should disable switch for Kibana index patterns mode by default', async () => { + await visualBuilder.clickPanelOptions('timeSeries'); + const isEnabled = await visualBuilder.checkIndexPatternSelectionModeSwitchIsEnabled(); + expect(isEnabled).to.be(false); + }); + + describe('metrics:allowStringIndices = true', () => { + before(async () => { + await kibanaServer.uiSettings.update({ 'metrics:allowStringIndices': true }); + await browser.refresh(); + }); + + beforeEach(async () => await visualBuilder.clickPanelOptions('timeSeries')); + + it('should not disable switch for Kibana index patterns mode', async () => { + await visualBuilder.switchIndexPatternSelectionMode(true); + + const isEnabled = await visualBuilder.checkIndexPatternSelectionModeSwitchIsEnabled(); + expect(isEnabled).to.be(true); + }); + + it('should disable switch after selecting Kibana index patterns mode and metrics:allowStringIndices = false', async () => { + await visualBuilder.switchIndexPatternSelectionMode(false); + await kibanaServer.uiSettings.update({ 'metrics:allowStringIndices': false }); + await browser.refresh(); + await visualBuilder.clickPanelOptions('timeSeries'); + + let isEnabled = await visualBuilder.checkIndexPatternSelectionModeSwitchIsEnabled(); + expect(isEnabled).to.be(true); + + await visualBuilder.switchIndexPatternSelectionMode(true); + isEnabled = await visualBuilder.checkIndexPatternSelectionModeSwitchIsEnabled(); + expect(isEnabled).to.be(false); + }); + + after( + async () => + await kibanaServer.uiSettings.update({ 'metrics:allowStringIndices': false }) + ); + }); + }); }); }); } diff --git a/test/functional/apps/visualize/index.ts b/test/functional/apps/visualize/index.ts index 878c7b88341a..3bc4da016390 100644 --- a/test/functional/apps/visualize/index.ts +++ b/test/functional/apps/visualize/index.ts @@ -85,11 +85,16 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_add_to_dashboard.ts')); }); + describe('visualize ciGroup8', function () { + this.tags('ciGroup8'); + + loadTestFile(require.resolve('./_tsvb_chart')); + }); + describe('visualize ciGroup11', function () { this.tags('ciGroup11'); loadTestFile(require.resolve('./_tag_cloud')); - loadTestFile(require.resolve('./_tsvb_chart')); loadTestFile(require.resolve('./_tsvb_time_series')); loadTestFile(require.resolve('./_tsvb_markdown')); loadTestFile(require.resolve('./_tsvb_table')); diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index c324de1231b7..a9116591f8f7 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -502,12 +502,32 @@ export class VisualBuilderPageObject extends FtrService { return await annotationTooltipDetails.getVisibleText(); } + public async toggleIndexPatternSelectionModePopover(shouldOpen: boolean) { + const isPopoverOpened = await this.testSubjects.exists( + 'switchIndexPatternSelectionModePopoverContent' + ); + if ((shouldOpen && !isPopoverOpened) || (!shouldOpen && isPopoverOpened)) { + await this.testSubjects.click('switchIndexPatternSelectionModePopoverButton'); + } + } + public async switchIndexPatternSelectionMode(useKibanaIndices: boolean) { - await this.testSubjects.click('switchIndexPatternSelectionModePopover'); + await this.toggleIndexPatternSelectionModePopover(true); await this.testSubjects.setEuiSwitch( 'switchIndexPatternSelectionMode', useKibanaIndices ? 'check' : 'uncheck' ); + await this.toggleIndexPatternSelectionModePopover(false); + } + + public async checkIndexPatternSelectionModeSwitchIsEnabled() { + await this.toggleIndexPatternSelectionModePopover(true); + let isEnabled; + await this.testSubjects.retry.tryForTime(2000, async () => { + isEnabled = await this.testSubjects.isEnabled('switchIndexPatternSelectionMode'); + }); + await this.toggleIndexPatternSelectionModePopover(false); + return isEnabled; } public async setIndexPatternValue(value: string, useKibanaIndices?: boolean) { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx index 505fd5c1020b..bbc77bcabca5 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, EuiTitle, EuiFlexItem } from '@elastic/eui'; import { RumOverview } from '../RumDashboard'; @@ -18,6 +18,7 @@ import { UserPercentile } from './UserPercentile'; import { useBreakpoints } from '../../../hooks/use_breakpoints'; import { KibanaPageTemplateProps } from '../../../../../../../src/plugins/kibana_react/public'; import { useHasRumData } from './hooks/useHasRumData'; +import { EmptyStateLoading } from './empty_state_loading'; export const UX_LABEL = i18n.translate('xpack.apm.ux.title', { defaultMessage: 'Dashboard', @@ -29,7 +30,7 @@ export function RumHome() { const { isSmall, isXXL } = useBreakpoints(); - const { data: rumHasData } = useHasRumData(); + const { data: rumHasData, status } = useHasRumData(); const envStyle = isSmall ? {} : { maxWidth: 500 }; @@ -58,31 +59,38 @@ export function RumHome() { } : undefined; + const isLoading = status === 'loading'; + return ( - - , -

- -
, - , - , - ], - } - : { children: } - } - > - - - + + + , +
+ +
, + , + , + ], + } + : { children: } + } + > + {isLoading && } +
+ +
+
+
+
); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/empty_state_loading.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/empty_state_loading.tsx new file mode 100644 index 000000000000..b02672721ce8 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/empty_state_loading.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiEmptyPrompt, + EuiLoadingSpinner, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { Fragment } from 'react'; + +export function EmptyStateLoading() { + return ( + + + + +

+ {i18n.translate('xpack.apm.emptyState.loadingMessage', { + defaultMessage: 'Loading…', + })} +

+
+ + } + /> + ); +} diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts index 37a491cdad4c..05a053307b29 100644 --- a/x-pack/plugins/cases/common/api/cases/case.ts +++ b/x-pack/plugins/cases/common/api/cases/case.ts @@ -87,8 +87,11 @@ const CaseBasicRt = rt.type({ owner: rt.string, }); -export const CaseExternalServiceBasicRt = rt.type({ - connector_id: rt.union([rt.string, rt.null]), +/** + * This represents the push to service UserAction. It lacks the connector_id because that is stored in a different field + * within the user action object in the API response. + */ +export const CaseUserActionExternalServiceRt = rt.type({ connector_name: rt.string, external_id: rt.string, external_title: rt.string, @@ -97,7 +100,14 @@ export const CaseExternalServiceBasicRt = rt.type({ pushed_by: UserRT, }); -const CaseFullExternalServiceRt = rt.union([CaseExternalServiceBasicRt, rt.null]); +export const CaseExternalServiceBasicRt = rt.intersection([ + rt.type({ + connector_id: rt.union([rt.string, rt.null]), + }), + CaseUserActionExternalServiceRt, +]); + +export const CaseFullExternalServiceRt = rt.union([CaseExternalServiceBasicRt, rt.null]); export const CaseAttributesRt = rt.intersection([ CaseBasicRt, diff --git a/x-pack/plugins/cases/common/api/cases/user_actions.ts b/x-pack/plugins/cases/common/api/cases/user_actions.ts index 03912c550d77..e86ce5248a6f 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions.ts @@ -34,7 +34,6 @@ const UserActionRt = rt.union([ rt.literal('push-to-service'), ]); -// TO DO change state to status const CaseUserActionBasicRT = rt.type({ action_field: UserActionFieldRt, action: UserActionRt, @@ -51,6 +50,8 @@ const CaseUserActionResponseRT = rt.intersection([ action_id: rt.string, case_id: rt.string, comment_id: rt.union([rt.string, rt.null]), + new_val_connector_id: rt.union([rt.string, rt.null]), + old_val_connector_id: rt.union([rt.string, rt.null]), }), rt.partial({ sub_case_id: rt.string }), ]); diff --git a/x-pack/plugins/cases/common/api/connectors/index.ts b/x-pack/plugins/cases/common/api/connectors/index.ts index 77af90b5d08c..2b3483b4f618 100644 --- a/x-pack/plugins/cases/common/api/connectors/index.ts +++ b/x-pack/plugins/cases/common/api/connectors/index.ts @@ -84,14 +84,22 @@ export const ConnectorTypeFieldsRt = rt.union([ ConnectorSwimlaneTypeFieldsRt, ]); +/** + * This type represents the connector's format when it is encoded within a user action. + */ +export const CaseUserActionConnectorRt = rt.intersection([ + rt.type({ name: rt.string }), + ConnectorTypeFieldsRt, +]); + export const CaseConnectorRt = rt.intersection([ rt.type({ id: rt.string, - name: rt.string, }), - ConnectorTypeFieldsRt, + CaseUserActionConnectorRt, ]); +export type CaseUserActionConnector = rt.TypeOf; export type CaseConnector = rt.TypeOf; export type ConnectorTypeFields = rt.TypeOf; export type ConnectorJiraTypeFields = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/index.ts b/x-pack/plugins/cases/common/index.ts index 5305318cc9aa..d38b1a779981 100644 --- a/x-pack/plugins/cases/common/index.ts +++ b/x-pack/plugins/cases/common/index.ts @@ -12,3 +12,4 @@ export * from './constants'; export * from './api'; export * from './ui/types'; export * from './utils/connectors_api'; +export * from './utils/user_actions'; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index bf4ec0da6ee5..c89c3eb08263 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -66,7 +66,9 @@ export interface CaseUserActions { caseId: string; commentId: string | null; newValue: string | null; + newValConnectorId: string | null; oldValue: string | null; + oldValConnectorId: string | null; } export interface CaseExternalService { diff --git a/x-pack/plugins/cases/common/utils/user_actions.ts b/x-pack/plugins/cases/common/utils/user_actions.ts new file mode 100644 index 000000000000..7de0d7066eae --- /dev/null +++ b/x-pack/plugins/cases/common/utils/user_actions.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function isCreateConnector(action?: string, actionFields?: string[]): boolean { + return action === 'create' && actionFields != null && actionFields.includes('connector'); +} + +export function isUpdateConnector(action?: string, actionFields?: string[]): boolean { + return action === 'update' && actionFields != null && actionFields.includes('connector'); +} + +export function isPush(action?: string, actionFields?: string[]): boolean { + return action === 'push-to-service' && actionFields != null && actionFields.includes('pushed'); +} diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/index.ts b/x-pack/plugins/cases/public/common/user_actions/index.ts similarity index 72% rename from x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/index.ts rename to x-pack/plugins/cases/public/common/user_actions/index.ts index e4c8858321e1..507455f7102a 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/index.ts +++ b/x-pack/plugins/cases/public/common/user_actions/index.ts @@ -5,5 +5,4 @@ * 2.0. */ -export { timelinesMigrations } from './timelines'; -export { notesMigrations } from './notes'; +export * from './parsers'; diff --git a/x-pack/plugins/cases/public/common/user_actions/parsers.test.ts b/x-pack/plugins/cases/public/common/user_actions/parsers.test.ts new file mode 100644 index 000000000000..c6d13cc41686 --- /dev/null +++ b/x-pack/plugins/cases/public/common/user_actions/parsers.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConnectorTypes, noneConnectorId } from '../../../common'; +import { parseStringAsConnector, parseStringAsExternalService } from './parsers'; + +describe('user actions utility functions', () => { + describe('parseStringAsConnector', () => { + it('return null if the data is null', () => { + expect(parseStringAsConnector('', null)).toBeNull(); + }); + + it('return null if the data is not a json object', () => { + expect(parseStringAsConnector('', 'blah')).toBeNull(); + }); + + it('return null if the data is not a valid connector', () => { + expect(parseStringAsConnector('', JSON.stringify({ a: '1' }))).toBeNull(); + }); + + it('return null if id is null but the data is a connector other than none', () => { + expect( + parseStringAsConnector( + null, + JSON.stringify({ type: ConnectorTypes.jira, name: '', fields: null }) + ) + ).toBeNull(); + }); + + it('return the id as the none connector if the data is the none connector', () => { + expect( + parseStringAsConnector( + null, + JSON.stringify({ type: ConnectorTypes.none, name: '', fields: null }) + ) + ).toEqual({ id: noneConnectorId, type: ConnectorTypes.none, name: '', fields: null }); + }); + + it('returns a decoded connector with the specified id', () => { + expect( + parseStringAsConnector( + 'a', + JSON.stringify({ type: ConnectorTypes.jira, name: 'hi', fields: null }) + ) + ).toEqual({ id: 'a', type: ConnectorTypes.jira, name: 'hi', fields: null }); + }); + }); + + describe('parseStringAsExternalService', () => { + it('returns null when the data is null', () => { + expect(parseStringAsExternalService('', null)).toBeNull(); + }); + + it('returns null when the data is not valid json', () => { + expect(parseStringAsExternalService('', 'blah')).toBeNull(); + }); + + it('returns null when the data is not a valid external service object', () => { + expect(parseStringAsExternalService('', JSON.stringify({ a: '1' }))).toBeNull(); + }); + + it('returns the decoded external service with the connector_id field added', () => { + const externalServiceInfo = { + connector_name: 'name', + external_id: '1', + external_title: 'title', + external_url: 'abc', + pushed_at: '1', + pushed_by: { + username: 'a', + email: 'a@a.com', + full_name: 'a', + }, + }; + + expect(parseStringAsExternalService('500', JSON.stringify(externalServiceInfo))).toEqual({ + ...externalServiceInfo, + connector_id: '500', + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/common/user_actions/parsers.ts b/x-pack/plugins/cases/public/common/user_actions/parsers.ts new file mode 100644 index 000000000000..dfea22443aa5 --- /dev/null +++ b/x-pack/plugins/cases/public/common/user_actions/parsers.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CaseUserActionConnectorRt, + CaseConnector, + ConnectorTypes, + noneConnectorId, + CaseFullExternalService, + CaseUserActionExternalServiceRt, +} from '../../../common'; + +export const parseStringAsConnector = ( + id: string | null, + encodedData: string | null +): CaseConnector | null => { + if (encodedData == null) { + return null; + } + + const decodedConnector = parseString(encodedData); + + if (!CaseUserActionConnectorRt.is(decodedConnector)) { + return null; + } + + if (id == null && decodedConnector.type === ConnectorTypes.none) { + return { + ...decodedConnector, + id: noneConnectorId, + }; + } else if (id == null) { + return null; + } else { + // id does not equal null or undefined and the connector type does not equal none + // so return the connector with its id + return { + ...decodedConnector, + id, + }; + } +}; + +const parseString = (params: string | null): unknown | null => { + if (params == null) { + return null; + } + + try { + return JSON.parse(params); + } catch { + return null; + } +}; + +export const parseStringAsExternalService = ( + id: string | null, + encodedData: string | null +): CaseFullExternalService => { + if (encodedData == null) { + return null; + } + + const decodedExternalService = parseString(encodedData); + if (!CaseUserActionExternalServiceRt.is(decodedExternalService)) { + return null; + } + + return { + ...decodedExternalService, + connector_id: id, + }; +}; diff --git a/x-pack/plugins/cases/public/components/edit_connector/helpers.test.ts b/x-pack/plugins/cases/public/components/edit_connector/helpers.test.ts new file mode 100644 index 000000000000..e20d6b37258b --- /dev/null +++ b/x-pack/plugins/cases/public/components/edit_connector/helpers.test.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseUserActionConnector, ConnectorTypes } from '../../../common'; +import { CaseUserActions } from '../../containers/types'; +import { getConnectorFieldsFromUserActions } from './helpers'; + +describe('helpers', () => { + describe('getConnectorFieldsFromUserActions', () => { + it('returns null when it cannot find the connector id', () => { + expect(getConnectorFieldsFromUserActions('a', [])).toBeNull(); + }); + + it('returns null when the value fields are not valid encoded fields', () => { + expect( + getConnectorFieldsFromUserActions('a', [createUserAction({ newValue: 'a', oldValue: 'a' })]) + ).toBeNull(); + }); + + it('returns null when it cannot find the connector id in a non empty array', () => { + expect( + getConnectorFieldsFromUserActions('a', [ + createUserAction({ + newValue: JSON.stringify({ a: '1' }), + oldValue: JSON.stringify({ a: '1' }), + }), + ]) + ).toBeNull(); + }); + + it('returns the fields when it finds the connector id in the new value', () => { + expect( + getConnectorFieldsFromUserActions('a', [ + createUserAction({ + newValue: createEncodedJiraConnector(), + oldValue: JSON.stringify({ a: '1' }), + newValConnectorId: 'a', + }), + ]) + ).toEqual(defaultJiraFields); + }); + + it('returns the fields when it finds the connector id in the new value and the old value is null', () => { + expect( + getConnectorFieldsFromUserActions('a', [ + createUserAction({ + newValue: createEncodedJiraConnector(), + newValConnectorId: 'a', + }), + ]) + ).toEqual(defaultJiraFields); + }); + + it('returns the fields when it finds the connector id in the old value', () => { + const expectedFields = { ...defaultJiraFields, issueType: '5' }; + + expect( + getConnectorFieldsFromUserActions('id-to-find', [ + createUserAction({ + newValue: createEncodedJiraConnector(), + oldValue: createEncodedJiraConnector({ + fields: expectedFields, + }), + newValConnectorId: 'b', + oldValConnectorId: 'id-to-find', + }), + ]) + ).toEqual(expectedFields); + }); + + it('returns the fields when it finds the connector id in the second user action', () => { + const expectedFields = { ...defaultJiraFields, issueType: '5' }; + + expect( + getConnectorFieldsFromUserActions('id-to-find', [ + createUserAction({ + newValue: createEncodedJiraConnector(), + oldValue: createEncodedJiraConnector(), + newValConnectorId: 'b', + oldValConnectorId: 'a', + }), + createUserAction({ + newValue: createEncodedJiraConnector(), + oldValue: createEncodedJiraConnector({ fields: expectedFields }), + newValConnectorId: 'b', + oldValConnectorId: 'id-to-find', + }), + ]) + ).toEqual(expectedFields); + }); + + it('ignores a parse failure and finds the right user action', () => { + expect( + getConnectorFieldsFromUserActions('none', [ + createUserAction({ + newValue: 'b', + newValConnectorId: null, + }), + createUserAction({ + newValue: createEncodedJiraConnector({ + type: ConnectorTypes.none, + name: '', + fields: null, + }), + newValConnectorId: null, + }), + ]) + ).toBeNull(); + }); + + it('returns null when the id matches but the encoded value is null', () => { + expect( + getConnectorFieldsFromUserActions('b', [ + createUserAction({ + newValue: null, + newValConnectorId: 'b', + }), + ]) + ).toBeNull(); + }); + + it('returns null when the action fields is not of length 1', () => { + expect( + getConnectorFieldsFromUserActions('id-to-find', [ + createUserAction({ + newValue: JSON.stringify({ a: '1', fields: { hello: '1' } }), + oldValue: JSON.stringify({ a: '1', fields: { hi: '2' } }), + newValConnectorId: 'b', + oldValConnectorId: 'id-to-find', + actionField: ['connector', 'connector'], + }), + ]) + ).toBeNull(); + }); + + it('matches the none connector the searched for id is none', () => { + expect( + getConnectorFieldsFromUserActions('none', [ + createUserAction({ + newValue: createEncodedJiraConnector({ + type: ConnectorTypes.none, + name: '', + fields: null, + }), + newValConnectorId: null, + }), + ]) + ).toBeNull(); + }); + }); +}); + +function createUserAction(fields: Partial): CaseUserActions { + return { + action: 'update', + actionAt: '', + actionBy: {}, + actionField: ['connector'], + actionId: '', + caseId: '', + commentId: '', + newValConnectorId: null, + oldValConnectorId: null, + newValue: null, + oldValue: null, + ...fields, + }; +} + +function createEncodedJiraConnector(fields?: Partial): string { + return JSON.stringify({ + type: ConnectorTypes.jira, + name: 'name', + fields: defaultJiraFields, + ...fields, + }); +} + +const defaultJiraFields = { + issueType: '1', + parent: null, + priority: null, +}; diff --git a/x-pack/plugins/cases/public/components/edit_connector/helpers.ts b/x-pack/plugins/cases/public/components/edit_connector/helpers.ts index 36eb3f58c8aa..b97035c458ac 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/helpers.ts +++ b/x-pack/plugins/cases/public/components/edit_connector/helpers.ts @@ -5,23 +5,33 @@ * 2.0. */ +import { ConnectorTypeFields } from '../../../common'; import { CaseUserActions } from '../../containers/types'; +import { parseStringAsConnector } from '../../common/user_actions'; -export const getConnectorFieldsFromUserActions = (id: string, userActions: CaseUserActions[]) => { +export const getConnectorFieldsFromUserActions = ( + id: string, + userActions: CaseUserActions[] +): ConnectorTypeFields['fields'] => { try { for (const action of [...userActions].reverse()) { if (action.actionField.length === 1 && action.actionField[0] === 'connector') { - if (action.oldValue && action.newValue) { - const oldValue = JSON.parse(action.oldValue); - const newValue = JSON.parse(action.newValue); + const parsedNewConnector = parseStringAsConnector( + action.newValConnectorId, + action.newValue + ); - if (newValue.id === id) { - return newValue.fields; - } + if (parsedNewConnector && id === parsedNewConnector.id) { + return parsedNewConnector.fields; + } + + const parsedOldConnector = parseStringAsConnector( + action.oldValConnectorId, + action.oldValue + ); - if (oldValue.id === id) { - return oldValue.fields; - } + if (parsedOldConnector && id === parsedOldConnector.id) { + return parsedOldConnector.fields; } } } diff --git a/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx index b49a010cff38..841f0d36bbf1 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { CaseStatuses } from '../../../common'; +import { CaseStatuses, ConnectorTypes } from '../../../common'; import { basicPush, getUserAction } from '../../containers/mock'; import { getLabelTitle, @@ -129,7 +129,7 @@ describe('User action tree helpers', () => { `${i18n.PUSHED_NEW_INCIDENT} ${basicPush.connectorName}` ); expect(wrapper.find(`[data-test-subj="pushed-value"]`).first().prop('href')).toEqual( - JSON.parse(action.newValue).external_url + JSON.parse(action.newValue!).external_url ); }); @@ -142,50 +142,74 @@ describe('User action tree helpers', () => { `${i18n.UPDATE_INCIDENT} ${basicPush.connectorName}` ); expect(wrapper.find(`[data-test-subj="pushed-value"]`).first().prop('href')).toEqual( - JSON.parse(action.newValue).external_url + JSON.parse(action.newValue!).external_url ); }); - it('label title generated for update connector - change connector', () => { - const action = { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: 'servicenow-1' }), - newValue: JSON.stringify({ id: 'resilient-2' }), - }; - const result: string | JSX.Element = getConnectorLabelTitle({ - action, - connectors, - }); - - expect(result).toEqual('selected My Connector 2 as incident management system'); - }); - - it('label title generated for update connector - change connector to none', () => { - const action = { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: 'servicenow-1' }), - newValue: JSON.stringify({ id: 'none' }), - }; - const result: string | JSX.Element = getConnectorLabelTitle({ - action, - connectors, + describe('getConnectorLabelTitle', () => { + it('returns an empty string when the encoded old value is null', () => { + const result = getConnectorLabelTitle({ + action: getUserAction(['connector'], 'update', { oldValue: null }), + connectors, + }); + + expect(result).toEqual(''); + }); + + it('returns an empty string when the encoded new value is null', () => { + const result = getConnectorLabelTitle({ + action: getUserAction(['connector'], 'update', { newValue: null }), + connectors, + }); + + expect(result).toEqual(''); + }); + + it('returns the change connector label', () => { + const result: string | JSX.Element = getConnectorLabelTitle({ + action: getUserAction(['connector'], 'update', { + oldValue: JSON.stringify({ + type: ConnectorTypes.serviceNowITSM, + name: 'a', + fields: null, + }), + oldValConnectorId: 'servicenow-1', + newValue: JSON.stringify({ type: ConnectorTypes.resilient, name: 'a', fields: null }), + newValConnectorId: 'resilient-2', + }), + connectors, + }); + + expect(result).toEqual('selected My Connector 2 as incident management system'); + }); + + it('returns the removed connector label', () => { + const result: string | JSX.Element = getConnectorLabelTitle({ + action: getUserAction(['connector'], 'update', { + oldValue: JSON.stringify({ type: ConnectorTypes.serviceNowITSM, name: '', fields: null }), + oldValConnectorId: 'servicenow-1', + newValue: JSON.stringify({ type: ConnectorTypes.none, name: '', fields: null }), + newValConnectorId: 'none', + }), + connectors, + }); + + expect(result).toEqual('removed external incident management system'); + }); + + it('returns the connector fields changed label', () => { + const result: string | JSX.Element = getConnectorLabelTitle({ + action: getUserAction(['connector'], 'update', { + oldValue: JSON.stringify({ type: ConnectorTypes.serviceNowITSM, name: '', fields: null }), + oldValConnectorId: 'servicenow-1', + newValue: JSON.stringify({ type: ConnectorTypes.serviceNowITSM, name: '', fields: null }), + newValConnectorId: 'servicenow-1', + }), + connectors, + }); + + expect(result).toEqual('changed connector field'); }); - - expect(result).toEqual('removed external incident management system'); - }); - - it('label title generated for update connector - field change', () => { - const action = { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: 'servicenow-1' }), - newValue: JSON.stringify({ id: 'servicenow-1' }), - }; - const result: string | JSX.Element = getConnectorLabelTitle({ - action, - connectors, - }); - - expect(result).toEqual('changed connector field'); }); describe('toStringArray', () => { diff --git a/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx index 744b14926b35..2eb44f91190c 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx @@ -23,10 +23,11 @@ import { CommentType, Comment, CommentRequestActionsType, + noneConnectorId, } from '../../../common'; import { CaseUserActions } from '../../containers/types'; import { CaseServices } from '../../containers/use_get_case_user_actions'; -import { parseString } from '../../containers/utils'; +import { parseStringAsConnector, parseStringAsExternalService } from '../../common/user_actions'; import { Tags } from '../tag_list/tags'; import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar'; import { UserActionTimestamp } from './user_action_timestamp'; @@ -97,23 +98,27 @@ export const getConnectorLabelTitle = ({ action: CaseUserActions; connectors: ActionConnector[]; }) => { - const oldValue = parseString(`${action.oldValue}`); - const newValue = parseString(`${action.newValue}`); + const oldConnector = parseStringAsConnector(action.oldValConnectorId, action.oldValue); + const newConnector = parseStringAsConnector(action.newValConnectorId, action.newValue); - if (oldValue === null || newValue === null) { + if (!oldConnector || !newConnector) { return ''; } - // Connector changed - if (oldValue.id !== newValue.id) { - const newConnector = connectors.find((c) => c.id === newValue.id); - return newValue.id != null && newValue.id !== 'none' && newConnector != null - ? i18n.SELECTED_THIRD_PARTY(newConnector.name) - : i18n.REMOVED_THIRD_PARTY; - } else { - // Field changed + // if the ids are the same, assume we just changed the fields + if (oldConnector.id === newConnector.id) { return i18n.CHANGED_CONNECTOR_FIELD; } + + // ids are not the same so check and see if the id is a valid connector and then return its name + // if the connector id is the none connector value then it must have been removed + const newConnectorActionInfo = connectors.find((c) => c.id === newConnector.id); + if (newConnector.id !== noneConnectorId && newConnectorActionInfo != null) { + return i18n.SELECTED_THIRD_PARTY(newConnectorActionInfo.name); + } + + // it wasn't a valid connector or it was the none connector, so it must have been removed + return i18n.REMOVED_THIRD_PARTY; }; const getTagsLabelTitle = (action: CaseUserActions) => { @@ -133,7 +138,8 @@ const getTagsLabelTitle = (action: CaseUserActions) => { }; export const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) => { - const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; + const externalService = parseStringAsExternalService(action.newValConnectorId, action.newValue); + return ( {`${firstPush ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} ${ - pushedVal?.connector_name + externalService?.connector_name }`} - - {pushedVal?.external_title} + + {externalService?.external_title} @@ -157,20 +163,19 @@ export const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: b export const getPushInfo = ( caseServices: CaseServices, - // a JSON parse failure will result in null for parsedValue - parsedValue: { connector_id: string | null; connector_name: string } | null, + externalService: CaseFullExternalService | undefined, index: number ) => - parsedValue != null && parsedValue.connector_id != null + externalService != null && externalService.connector_id != null ? { - firstPush: caseServices[parsedValue.connector_id]?.firstPushIndex === index, - parsedConnectorId: parsedValue.connector_id, - parsedConnectorName: parsedValue.connector_name, + firstPush: caseServices[externalService.connector_id]?.firstPushIndex === index, + parsedConnectorId: externalService.connector_id, + parsedConnectorName: externalService.connector_name, } : { firstPush: false, - parsedConnectorId: 'none', - parsedConnectorName: 'none', + parsedConnectorId: noneConnectorId, + parsedConnectorName: noneConnectorId, }; const getUpdateActionIcon = (actionField: string): string => { diff --git a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx index 784817229caf..7ea415324194 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx @@ -35,7 +35,7 @@ import { Ecs, } from '../../../common'; import { CaseServices } from '../../containers/use_get_case_user_actions'; -import { parseString } from '../../containers/utils'; +import { parseStringAsExternalService } from '../../common/user_actions'; import { OnUpdateFields } from '../case_view'; import { getConnectorLabelTitle, @@ -512,10 +512,14 @@ export const UserActionTree = React.memo( // Pushed information if (action.actionField.length === 1 && action.actionField[0] === 'pushed') { - const parsedValue = parseString(`${action.newValue}`); + const parsedExternalService = parseStringAsExternalService( + action.newValConnectorId, + action.newValue + ); + const { firstPush, parsedConnectorId, parsedConnectorName } = getPushInfo( caseServices, - parsedValue, + parsedExternalService, index ); diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index c955bb34240e..fcd564969d48 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -9,6 +9,7 @@ import { ActionLicense, AllCases, Case, CasesStatus, CaseUserActions, Comment } import { AssociationType, + CaseUserActionConnector, CaseResponse, CasesFindResponse, CasesResponse, @@ -19,6 +20,9 @@ import { CommentResponse, CommentType, ConnectorTypes, + isCreateConnector, + isPush, + isUpdateConnector, SECURITY_SOLUTION_OWNER, UserAction, UserActionField, @@ -240,7 +244,9 @@ export const pushedCase: Case = { const basicAction = { actionAt: basicCreatedAt, actionBy: elasticUser, + oldValConnectorId: null, oldValue: null, + newValConnectorId: null, newValue: 'what a cool value', caseId: basicCaseId, commentId: null, @@ -308,12 +314,7 @@ export const basicCaseSnake: CaseResponse = { closed_at: null, closed_by: null, comments: [basicCommentSnake], - connector: { - id: 'none', - name: 'My Connector', - type: ConnectorTypes.none, - fields: null, - }, + connector: { id: 'none', name: 'My Connector', type: ConnectorTypes.none, fields: null }, created_at: basicCreatedAt, created_by: elasticUserSnake, external_service: null, @@ -328,8 +329,8 @@ export const casesStatusSnake: CasesStatusResponse = { count_open_cases: 20, }; +export const pushConnectorId = '123'; export const pushSnake = { - connector_id: '123', connector_name: 'connector name', external_id: 'external_id', external_title: 'external title', @@ -350,7 +351,7 @@ export const pushedCaseSnake = { type: ConnectorTypes.jira, fields: null, }, - external_service: basicPushSnake, + external_service: { ...basicPushSnake, connector_id: pushConnectorId }, }; export const reporters: string[] = ['alexis', 'kim', 'maria', 'steph']; @@ -385,17 +386,20 @@ const basicActionSnake = { comment_id: null, owner: SECURITY_SOLUTION_OWNER, }; -export const getUserActionSnake = (af: UserActionField, a: UserAction) => ({ - ...basicActionSnake, - action_id: `${af[0]}-${a}`, - action_field: af, - action: a, - comment_id: af[0] === 'comment' ? basicCommentId : null, - new_value: - a === 'push-to-service' && af[0] === 'pushed' - ? JSON.stringify(basicPushSnake) - : basicAction.newValue, -}); +export const getUserActionSnake = (af: UserActionField, a: UserAction) => { + const isPushToService = a === 'push-to-service' && af[0] === 'pushed'; + + return { + ...basicActionSnake, + action_id: `${af[0]}-${a}`, + action_field: af, + action: a, + comment_id: af[0] === 'comment' ? basicCommentId : null, + new_value: isPushToService ? JSON.stringify(basicPushSnake) : basicAction.newValue, + new_val_connector_id: isPushToService ? pushConnectorId : null, + old_val_connector_id: null, + }; +}; export const caseUserActionsSnake: CaseUserActionsResponse = [ getUserActionSnake(['description'], 'create'), @@ -405,17 +409,76 @@ export const caseUserActionsSnake: CaseUserActionsResponse = [ // user actions -export const getUserAction = (af: UserActionField, a: UserAction) => ({ - ...basicAction, - actionId: `${af[0]}-${a}`, - actionField: af, - action: a, - commentId: af[0] === 'comment' ? basicCommentId : null, - newValue: - a === 'push-to-service' && af[0] === 'pushed' - ? JSON.stringify(basicPushSnake) - : basicAction.newValue, -}); +export const getUserAction = ( + af: UserActionField, + a: UserAction, + overrides?: Partial +): CaseUserActions => { + return { + ...basicAction, + actionId: `${af[0]}-${a}`, + actionField: af, + action: a, + commentId: af[0] === 'comment' ? basicCommentId : null, + ...getValues(a, af, overrides), + }; +}; + +const getValues = ( + userAction: UserAction, + actionFields: UserActionField, + overrides?: Partial +): Partial => { + if (isCreateConnector(userAction, actionFields)) { + return { + newValue: + overrides?.newValue === undefined ? JSON.stringify(basicCaseSnake) : overrides.newValue, + newValConnectorId: overrides?.newValConnectorId ?? null, + oldValue: null, + oldValConnectorId: null, + }; + } else if (isUpdateConnector(userAction, actionFields)) { + return { + newValue: + overrides?.newValue === undefined + ? JSON.stringify({ name: 'My Connector', type: ConnectorTypes.none, fields: null }) + : overrides.newValue, + newValConnectorId: overrides?.newValConnectorId ?? null, + oldValue: + overrides?.oldValue === undefined + ? JSON.stringify({ name: 'My Connector2', type: ConnectorTypes.none, fields: null }) + : overrides.oldValue, + oldValConnectorId: overrides?.oldValConnectorId ?? null, + }; + } else if (isPush(userAction, actionFields)) { + return { + newValue: + overrides?.newValue === undefined ? JSON.stringify(basicPushSnake) : overrides?.newValue, + newValConnectorId: + overrides?.newValConnectorId === undefined ? pushConnectorId : overrides.newValConnectorId, + oldValue: overrides?.oldValue ?? null, + oldValConnectorId: overrides?.oldValConnectorId ?? null, + }; + } else { + return { + newValue: overrides?.newValue === undefined ? basicAction.newValue : overrides.newValue, + newValConnectorId: overrides?.newValConnectorId ?? null, + oldValue: overrides?.oldValue ?? null, + oldValConnectorId: overrides?.oldValConnectorId ?? null, + }; + } +}; + +export const getJiraConnectorWithoutId = (overrides?: Partial) => { + return JSON.stringify({ + name: 'jira1', + type: ConnectorTypes.jira, + ...jiraFields, + ...overrides, + }); +}; + +export const jiraFields = { fields: { issueType: '10006', priority: null, parent: null } }; export const getAlertUserAction = () => ({ ...basicAction, diff --git a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx index 62b4cf92434c..e7e46fa46c7c 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx @@ -18,7 +18,9 @@ import { basicPushSnake, caseUserActions, elasticUser, + getJiraConnectorWithoutId, getUserAction, + jiraFields, } from './mock'; import * as api from './api'; @@ -299,15 +301,14 @@ describe('useGetCaseUserActions', () => { const pushAction123 = getUserAction(['pushed'], 'push-to-service'); const push456 = { ...basicPushSnake, - connector_id: '456', connector_name: 'other connector name', external_id: 'other_external_id', }; - const pushAction456 = { - ...getUserAction(['pushed'], 'push-to-service'), + const pushAction456 = getUserAction(['pushed'], 'push-to-service', { newValue: JSON.stringify(push456), - }; + newValConnectorId: '456', + }); const userActions = [ ...caseUserActions, @@ -346,15 +347,14 @@ describe('useGetCaseUserActions', () => { const pushAction123 = getUserAction(['pushed'], 'push-to-service'); const push456 = { ...basicPushSnake, - connector_id: '456', connector_name: 'other connector name', external_id: 'other_external_id', }; - const pushAction456 = { - ...getUserAction(['pushed'], 'push-to-service'), + const pushAction456 = getUserAction(['pushed'], 'push-to-service', { newValue: JSON.stringify(push456), - }; + newValConnectorId: '456', + }); const userActions = [ ...caseUserActions, @@ -392,11 +392,7 @@ describe('useGetCaseUserActions', () => { const userActions = [ ...caseUserActions, getUserAction(['pushed'], 'push-to-service'), - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }), - newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }), - }, + createUpdateConnectorFields123HighPriorityUserAction(), ]; const result = getPushedInfo(userActions, '123'); @@ -418,11 +414,7 @@ describe('useGetCaseUserActions', () => { const userActions = [ ...caseUserActions, getUserAction(['pushed'], 'push-to-service'), - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }), - newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - }, + createChangeConnector123To456UserAction(), ]; const result = getPushedInfo(userActions, '123'); @@ -444,16 +436,8 @@ describe('useGetCaseUserActions', () => { const userActions = [ ...caseUserActions, getUserAction(['pushed'], 'push-to-service'), - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }), - newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - }, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }), - }, + createChangeConnector123To456UserAction(), + createChangeConnector456To123UserAction(), ]; const result = getPushedInfo(userActions, '123'); @@ -474,22 +458,10 @@ describe('useGetCaseUserActions', () => { it('Change fields and connector after push - hasDataToPush: true', () => { const userActions = [ ...caseUserActions, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }), - newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }), - }, + createUpdateConnectorFields123HighPriorityUserAction(), getUserAction(['pushed'], 'push-to-service'), - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }), - newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - }, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'Low' } }), - }, + createChangeConnector123HighPriorityTo456UserAction(), + createChangeConnector456To123PriorityLowUserAction(), ]; const result = getPushedInfo(userActions, '123'); @@ -510,22 +482,10 @@ describe('useGetCaseUserActions', () => { it('Change only connector after push - hasDataToPush: false', () => { const userActions = [ ...caseUserActions, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }), - newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }), - }, + createUpdateConnectorFields123HighPriorityUserAction(), getUserAction(['pushed'], 'push-to-service'), - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }), - newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - }, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }), - }, + createChangeConnector123HighPriorityTo456UserAction(), + createChangeConnector456To123HighPriorityUserAction(), ]; const result = getPushedInfo(userActions, '123'); @@ -547,45 +507,24 @@ describe('useGetCaseUserActions', () => { const pushAction123 = getUserAction(['pushed'], 'push-to-service'); const push456 = { ...basicPushSnake, - connector_id: '456', connector_name: 'other connector name', external_id: 'other_external_id', }; - const pushAction456 = { - ...getUserAction(['pushed'], 'push-to-service'), + const pushAction456 = getUserAction(['pushed'], 'push-to-service', { newValue: JSON.stringify(push456), - }; + newValConnectorId: '456', + }); const userActions = [ ...caseUserActions, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }), - newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }), - }, + createUpdateConnectorFields123HighPriorityUserAction(), pushAction123, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }), - newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - }, + createChangeConnector123HighPriorityTo456UserAction(), pushAction456, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'Low' } }), - }, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'Low' } }), - newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - }, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'Low' } }), - }, + createChangeConnector456To123PriorityLowUserAction(), + createChangeConnector123LowPriorityTo456UserAction(), + createChangeConnector456To123PriorityLowUserAction(), ]; const result = getPushedInfo(userActions, '123'); @@ -617,34 +556,22 @@ describe('useGetCaseUserActions', () => { const pushAction123 = getUserAction(['pushed'], 'push-to-service'); const push456 = { ...basicPushSnake, - connector_id: '456', connector_name: 'other connector name', external_id: 'other_external_id', }; - const pushAction456 = { - ...getUserAction(['pushed'], 'push-to-service'), + const pushAction456 = getUserAction(['pushed'], 'push-to-service', { + newValConnectorId: '456', newValue: JSON.stringify(push456), - }; + }); + const userActions = [ ...caseUserActions, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }), - newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }), - }, + createUpdateConnectorFields123HighPriorityUserAction(), pushAction123, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }), - newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - }, + createChangeConnector123HighPriorityTo456UserAction(), pushAction456, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }), - }, + createChangeConnector456To123HighPriorityUserAction(), ]; const result = getPushedInfo(userActions, '123'); @@ -675,22 +602,10 @@ describe('useGetCaseUserActions', () => { it('Changing other connectors fields does not count as an update', () => { const userActions = [ ...caseUserActions, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }), - newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }), - }, + createUpdateConnectorFields123HighPriorityUserAction(), getUserAction(['pushed'], 'push-to-service'), - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }), - newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - }, - { - ...getUserAction(['connector'], 'update'), - oldValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }), - newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '3' } }), - }, + createChangeConnector123HighPriorityTo456UserAction(), + createUpdateConnectorFields456HighPriorityUserAction(), ]; const result = getPushedInfo(userActions, '123'); @@ -709,3 +624,83 @@ describe('useGetCaseUserActions', () => { }); }); }); + +const jira123HighPriorityFields = { + fields: { ...jiraFields.fields, priority: 'High' }, +}; + +const jira123LowPriorityFields = { + fields: { ...jiraFields.fields, priority: 'Low' }, +}; + +const jira456Fields = { + fields: { issueType: '10', parent: null, priority: null }, +}; + +const jira456HighPriorityFields = { + fields: { ...jira456Fields.fields, priority: 'High' }, +}; + +const createUpdateConnectorFields123HighPriorityUserAction = () => + getUserAction(['connector'], 'update', { + oldValue: getJiraConnectorWithoutId(), + newValue: getJiraConnectorWithoutId(jira123HighPriorityFields), + oldValConnectorId: '123', + newValConnectorId: '123', + }); + +const createUpdateConnectorFields456HighPriorityUserAction = () => + getUserAction(['connector'], 'update', { + oldValue: getJiraConnectorWithoutId(jira456Fields), + newValue: getJiraConnectorWithoutId(jira456HighPriorityFields), + oldValConnectorId: '456', + newValConnectorId: '456', + }); + +const createChangeConnector123HighPriorityTo456UserAction = () => + getUserAction(['connector'], 'update', { + oldValue: getJiraConnectorWithoutId(jira123HighPriorityFields), + oldValConnectorId: '123', + newValue: getJiraConnectorWithoutId(jira456Fields), + newValConnectorId: '456', + }); + +const createChangeConnector123To456UserAction = () => + getUserAction(['connector'], 'update', { + oldValue: getJiraConnectorWithoutId(), + oldValConnectorId: '123', + newValue: getJiraConnectorWithoutId(jira456Fields), + newValConnectorId: '456', + }); + +const createChangeConnector123LowPriorityTo456UserAction = () => + getUserAction(['connector'], 'update', { + oldValue: getJiraConnectorWithoutId(jira123LowPriorityFields), + oldValConnectorId: '123', + newValue: getJiraConnectorWithoutId(jira456Fields), + newValConnectorId: '456', + }); + +const createChangeConnector456To123UserAction = () => + getUserAction(['connector'], 'update', { + oldValue: getJiraConnectorWithoutId(jira456Fields), + oldValConnectorId: '456', + newValue: getJiraConnectorWithoutId(), + newValConnectorId: '123', + }); + +const createChangeConnector456To123HighPriorityUserAction = () => + getUserAction(['connector'], 'update', { + oldValue: getJiraConnectorWithoutId(jira456Fields), + oldValConnectorId: '456', + newValue: getJiraConnectorWithoutId(jira123HighPriorityFields), + newValConnectorId: '123', + }); + +const createChangeConnector456To123PriorityLowUserAction = () => + getUserAction(['connector'], 'update', { + oldValue: getJiraConnectorWithoutId(jira456Fields), + oldValConnectorId: '456', + newValue: getJiraConnectorWithoutId(jira123LowPriorityFields), + newValConnectorId: '123', + }); diff --git a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx index e481519ba19a..36d600c3f1c9 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx @@ -18,7 +18,8 @@ import { } from '../../common'; import { getCaseUserActions, getSubCaseUserActions } from './api'; import * as i18n from './translations'; -import { convertToCamelCase, parseString } from './utils'; +import { convertToCamelCase } from './utils'; +import { parseStringAsConnector, parseStringAsExternalService } from '../common/user_actions'; import { useToasts } from '../common/lib/kibana'; export interface CaseService extends CaseExternalService { @@ -58,8 +59,24 @@ export interface UseGetCaseUserActions extends CaseUserActionsState { ) => Promise; } -const getExternalService = (value: string): CaseExternalService | null => - convertToCamelCase(parseString(`${value}`)); +const unknownExternalServiceConnectorId = 'unknown'; + +const getExternalService = ( + connectorId: string | null, + encodedValue: string | null +): CaseExternalService | null => { + const decodedValue = parseStringAsExternalService(connectorId, encodedValue); + + if (decodedValue == null) { + return null; + } + return { + ...convertToCamelCase(decodedValue), + // if in the rare case that the connector id is null we'll set it to unknown if we need to reference it in the UI + // anywhere. The id would only ever be null if a migration failed or some logic error within the backend occurred + connectorId: connectorId ?? unknownExternalServiceConnectorId, + }; +}; const groupConnectorFields = ( userActions: CaseUserActions[] @@ -69,22 +86,26 @@ const groupConnectorFields = ( return acc; } - const oldValue = parseString(`${mua.oldValue}`); - const newValue = parseString(`${mua.newValue}`); + const oldConnector = parseStringAsConnector(mua.oldValConnectorId, mua.oldValue); + const newConnector = parseStringAsConnector(mua.newValConnectorId, mua.newValue); - if (oldValue == null || newValue == null) { + if (!oldConnector || !newConnector) { return acc; } return { ...acc, - [oldValue.id]: [ - ...(acc[oldValue.id] || []), - ...(oldValue.id === newValue.id ? [oldValue.fields, newValue.fields] : [oldValue.fields]), + [oldConnector.id]: [ + ...(acc[oldConnector.id] || []), + ...(oldConnector.id === newConnector.id + ? [oldConnector.fields, newConnector.fields] + : [oldConnector.fields]), ], - [newValue.id]: [ - ...(acc[newValue.id] || []), - ...(oldValue.id === newValue.id ? [oldValue.fields, newValue.fields] : [newValue.fields]), + [newConnector.id]: [ + ...(acc[newConnector.id] || []), + ...(oldConnector.id === newConnector.id + ? [oldConnector.fields, newConnector.fields] + : [newConnector.fields]), ], }; }, {} as Record>); @@ -137,9 +158,7 @@ export const getPushedInfo = ( const hasDataToPushForConnector = (connectorId: string): boolean => { const caseUserActionsReversed = [...caseUserActions].reverse(); const lastPushOfConnectorReversedIndex = caseUserActionsReversed.findIndex( - (mua) => - mua.action === 'push-to-service' && - getExternalService(`${mua.newValue}`)?.connectorId === connectorId + (mua) => mua.action === 'push-to-service' && mua.newValConnectorId === connectorId ); if (lastPushOfConnectorReversedIndex === -1) { @@ -190,7 +209,7 @@ export const getPushedInfo = ( return acc; } - const externalService = getExternalService(`${cua.newValue}`); + const externalService = getExternalService(cua.newValConnectorId, cua.newValue); if (externalService === null) { return acc; } diff --git a/x-pack/plugins/cases/public/containers/utils.ts b/x-pack/plugins/cases/public/containers/utils.ts index de67b1cfbd6f..b0cc0c72fee7 100644 --- a/x-pack/plugins/cases/public/containers/utils.ts +++ b/x-pack/plugins/cases/public/containers/utils.ts @@ -36,14 +36,6 @@ import * as i18n from './translations'; export const getTypedPayload = (a: unknown): T => a as T; -export const parseString = (params: string) => { - try { - return JSON.parse(params); - } catch { - return null; - } -}; - export const convertArrayToCamelCase = (arrayOfSnakes: unknown[]): unknown[] => arrayOfSnakes.reduce((acc: unknown[], value) => { if (isArray(value)) { diff --git a/x-pack/plugins/cases/server/client/attachments/add.ts b/x-pack/plugins/cases/server/client/attachments/add.ts index 507405d58cef..b84a6bd84c43 100644 --- a/x-pack/plugins/cases/server/client/attachments/add.ts +++ b/x-pack/plugins/cases/server/client/attachments/add.ts @@ -106,7 +106,7 @@ async function getSubCase({ caseId, subCaseId: newSubCase.id, fields: ['status', 'sub_case'], - newValue: JSON.stringify({ status: newSubCase.attributes.status }), + newValue: { status: newSubCase.attributes.status }, owner: newSubCase.attributes.owner, }), ], @@ -220,7 +220,7 @@ const addGeneratedAlerts = async ( subCaseId: updatedCase.subCaseId, commentId: newComment.id, fields: ['comment'], - newValue: JSON.stringify(query), + newValue: query, owner: newComment.attributes.owner, }), ], @@ -408,7 +408,7 @@ export const addComment = async ( subCaseId: updatedCase.subCaseId, commentId: newComment.id, fields: ['comment'], - newValue: JSON.stringify(query), + newValue: query, owner: newComment.attributes.owner, }), ], diff --git a/x-pack/plugins/cases/server/client/attachments/update.ts b/x-pack/plugins/cases/server/client/attachments/update.ts index 9816efd9a845..b5e9e6c37235 100644 --- a/x-pack/plugins/cases/server/client/attachments/update.ts +++ b/x-pack/plugins/cases/server/client/attachments/update.ts @@ -17,6 +17,7 @@ import { SUB_CASE_SAVED_OBJECT, CaseResponse, CommentPatchRequest, + CommentRequest, } from '../../../common'; import { AttachmentService, CasesService } from '../../services'; import { CasesClientArgs } from '..'; @@ -193,12 +194,12 @@ export async function update( subCaseId: subCaseID, commentId: updatedComment.id, fields: ['comment'], - newValue: JSON.stringify(queryRestAttributes), - oldValue: JSON.stringify( + // casting because typescript is complaining that it's not a Record even though it is + newValue: queryRestAttributes as CommentRequest, + oldValue: // We are interested only in ContextBasicRt attributes // myComment.attribute contains also CommentAttributesBasicRt attributes - pick(Object.keys(queryRestAttributes), myComment.attributes) - ), + pick(Object.keys(queryRestAttributes), myComment.attributes), owner: myComment.attributes.owner, }), ], diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index 887990fef893..488bc523f779 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -106,7 +106,7 @@ export const create = async ( actionBy: { username, full_name, email }, caseId: newCase.id, fields: ['description', 'status', 'tags', 'title', 'connector', 'settings', OWNER_FIELD], - newValue: JSON.stringify(query), + newValue: query, owner: newCase.attributes.owner, }), ], diff --git a/x-pack/plugins/cases/server/client/cases/delete.ts b/x-pack/plugins/cases/server/client/cases/delete.ts index 80a687a0e72f..4333535f17a2 100644 --- a/x-pack/plugins/cases/server/client/cases/delete.ts +++ b/x-pack/plugins/cases/server/client/cases/delete.ts @@ -168,7 +168,7 @@ export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): P 'settings', OWNER_FIELD, 'comment', - ...(ENABLE_CASE_CONNECTOR ? ['sub_case'] : []), + ...(ENABLE_CASE_CONNECTOR ? ['sub_case' as const] : []), ], owner: caseInfo.attributes.owner, }) diff --git a/x-pack/plugins/cases/server/client/cases/mock.ts b/x-pack/plugins/cases/server/client/cases/mock.ts index 313d6cd12a6d..22520cea1101 100644 --- a/x-pack/plugins/cases/server/client/cases/mock.ts +++ b/x-pack/plugins/cases/server/client/cases/mock.ts @@ -231,8 +231,10 @@ export const userActions: CaseUserActionsResponse = [ username: 'elastic', }, new_value: - '{"title":"Case SIR","tags":["sir"],"description":"testing sir","connector":{"id":"456","name":"ServiceNow SN","type":".servicenow-sir","fields":{"category":"Denial of Service","destIp":true,"malwareHash":true,"malwareUrl":true,"priority":"2","sourceIp":true,"subcategory":"45"}},"settings":{"syncAlerts":true}}', + '{"title":"Case SIR","tags":["sir"],"description":"testing sir","connector":{"name":"ServiceNow SN","type":".servicenow-sir","fields":{"category":"Denial of Service","destIp":true,"malwareHash":true,"malwareUrl":true,"priority":"2","sourceIp":true,"subcategory":"45"}},"settings":{"syncAlerts":true}}', + new_val_connector_id: '456', old_value: null, + old_val_connector_id: null, action_id: 'fd830c60-6646-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', comment_id: null, @@ -248,7 +250,9 @@ export const userActions: CaseUserActionsResponse = [ username: 'elastic', }, new_value: - '{"pushed_at":"2021-02-03T17:41:26.108Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"456","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}', + '{"pushed_at":"2021-02-03T17:41:26.108Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}', + new_val_connector_id: '456', + old_val_connector_id: null, old_value: null, action_id: '0a801750-6647-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', @@ -265,6 +269,8 @@ export const userActions: CaseUserActionsResponse = [ username: 'elastic', }, new_value: '{"type":"alert","alertId":"alert-id-1","index":".siem-signals-default-000008"}', + new_val_connector_id: null, + old_val_connector_id: null, old_value: null, action_id: '7373eb60-6647-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', @@ -282,6 +288,8 @@ export const userActions: CaseUserActionsResponse = [ }, new_value: '{"type":"alert","alertId":"alert-id-2","index":".siem-signals-default-000008"}', old_value: null, + new_val_connector_id: null, + old_val_connector_id: null, action_id: '7abc6410-6647-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', comment_id: 'comment-alert-2', @@ -297,8 +305,10 @@ export const userActions: CaseUserActionsResponse = [ username: 'elastic', }, new_value: - '{"pushed_at":"2021-02-03T17:45:29.400Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"456","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}', + '{"pushed_at":"2021-02-03T17:45:29.400Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}', + new_val_connector_id: '456', old_value: null, + old_val_connector_id: null, action_id: '9b91d8f0-6647-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', comment_id: null, @@ -315,6 +325,8 @@ export const userActions: CaseUserActionsResponse = [ }, new_value: '{"comment":"a comment!","type":"user"}', old_value: null, + new_val_connector_id: null, + old_val_connector_id: null, action_id: '0818e5e0-6648-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', comment_id: 'comment-user-1', diff --git a/x-pack/plugins/cases/server/client/cases/push.ts b/x-pack/plugins/cases/server/client/cases/push.ts index 3048cf01bb3b..1b090a653546 100644 --- a/x-pack/plugins/cases/server/client/cases/push.ts +++ b/x-pack/plugins/cases/server/client/cases/push.ts @@ -241,7 +241,7 @@ export const push = async ( actionBy: { username, full_name, email }, caseId, fields: ['pushed'], - newValue: JSON.stringify(externalService), + newValue: externalService, owner: myCase.attributes.owner, }), ], diff --git a/x-pack/plugins/cases/server/client/cases/utils.test.ts b/x-pack/plugins/cases/server/client/cases/utils.test.ts index d7c45d3e1e9a..315e9966d347 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.test.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.test.ts @@ -799,8 +799,10 @@ describe('utils', () => { username: 'elastic', }, new_value: - // The connector id is 123 - '{"pushed_at":"2021-02-03T17:45:29.400Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"123","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}', + // The connector id is 123 + '{"pushed_at":"2021-02-03T17:45:29.400Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}', + new_val_connector_id: '123', + old_val_connector_id: null, old_value: null, action_id: '9b91d8f0-6647-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', diff --git a/x-pack/plugins/cases/server/client/cases/utils.ts b/x-pack/plugins/cases/server/client/cases/utils.ts index 359ad4b41ead..f5cf2fe4b3f5 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.ts @@ -20,6 +20,8 @@ import { CommentRequestUserType, CommentRequestAlertType, CommentRequestActionsType, + CaseUserActionResponse, + isPush, } from '../../../common'; import { ActionsClient } from '../../../../actions/server'; import { CasesClientGetAlertsResponse } from '../../client/alerts/types'; @@ -55,22 +57,36 @@ export const getLatestPushInfo = ( userActions: CaseUserActionsResponse ): { index: number; pushedInfo: CaseFullExternalService } | null => { for (const [index, action] of [...userActions].reverse().entries()) { - if (action.action === 'push-to-service' && action.new_value) + if ( + isPush(action.action, action.action_field) && + isValidNewValue(action) && + connectorId === action.new_val_connector_id + ) { try { const pushedInfo = JSON.parse(action.new_value); - if (pushedInfo.connector_id === connectorId) { - // We returned the index of the element in the userActions array. - // As we traverse the userActions in reverse we need to calculate the index of a normal traversal - return { index: userActions.length - index - 1, pushedInfo }; - } + // We returned the index of the element in the userActions array. + // As we traverse the userActions in reverse we need to calculate the index of a normal traversal + return { + index: userActions.length - index - 1, + pushedInfo: { ...pushedInfo, connector_id: connectorId }, + }; } catch (e) { - // Silence JSON parse errors + // ignore parse failures and check the next user action } + } } return null; }; +type NonNullNewValueAction = Omit & { + new_value: string; + new_val_connector_id: string; +}; + +const isValidNewValue = (userAction: CaseUserActionResponse): userAction is NonNullNewValueAction => + userAction.new_val_connector_id != null && userAction.new_value != null; + const getCommentContent = (comment: CommentResponse): string => { if (comment.type === CommentType.user) { return comment.comment; diff --git a/x-pack/plugins/cases/server/client/user_actions/get.test.ts b/x-pack/plugins/cases/server/client/user_actions/get.test.ts new file mode 100644 index 000000000000..302e069cde4d --- /dev/null +++ b/x-pack/plugins/cases/server/client/user_actions/get.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseUserActionResponse, SUB_CASE_SAVED_OBJECT } from '../../../common'; +import { SUB_CASE_REF_NAME } from '../../common'; +import { extractAttributesWithoutSubCases } from './get'; + +describe('get', () => { + describe('extractAttributesWithoutSubCases', () => { + it('returns an empty array when given an empty array', () => { + expect( + extractAttributesWithoutSubCases({ ...getFindResponseFields(), saved_objects: [] }) + ).toEqual([]); + }); + + it('filters out saved objects with a sub case reference', () => { + expect( + extractAttributesWithoutSubCases({ + ...getFindResponseFields(), + saved_objects: [ + { + type: 'a', + references: [{ name: SUB_CASE_REF_NAME, type: SUB_CASE_SAVED_OBJECT, id: '1' }], + id: 'b', + score: 0, + attributes: {} as CaseUserActionResponse, + }, + ], + }) + ).toEqual([]); + }); + + it('filters out saved objects with a sub case reference with other references', () => { + expect( + extractAttributesWithoutSubCases({ + ...getFindResponseFields(), + saved_objects: [ + { + type: 'a', + references: [ + { name: SUB_CASE_REF_NAME, type: SUB_CASE_SAVED_OBJECT, id: '1' }, + { name: 'a', type: 'b', id: '5' }, + ], + id: 'b', + score: 0, + attributes: {} as CaseUserActionResponse, + }, + ], + }) + ).toEqual([]); + }); + + it('keeps saved objects that do not have a sub case reference', () => { + expect( + extractAttributesWithoutSubCases({ + ...getFindResponseFields(), + saved_objects: [ + { + type: 'a', + references: [ + { name: SUB_CASE_REF_NAME, type: 'awesome', id: '1' }, + { name: 'a', type: 'b', id: '5' }, + ], + id: 'b', + score: 0, + attributes: { field: '1' } as unknown as CaseUserActionResponse, + }, + ], + }) + ).toEqual([{ field: '1' }]); + }); + + it('filters multiple saved objects correctly', () => { + expect( + extractAttributesWithoutSubCases({ + ...getFindResponseFields(), + saved_objects: [ + { + type: 'a', + references: [ + { name: SUB_CASE_REF_NAME, type: 'awesome', id: '1' }, + { name: 'a', type: 'b', id: '5' }, + ], + id: 'b', + score: 0, + attributes: { field: '2' } as unknown as CaseUserActionResponse, + }, + { + type: 'a', + references: [{ name: SUB_CASE_REF_NAME, type: SUB_CASE_SAVED_OBJECT, id: '1' }], + id: 'b', + score: 0, + attributes: { field: '1' } as unknown as CaseUserActionResponse, + }, + ], + }) + ).toEqual([{ field: '2' }]); + }); + }); +}); + +const getFindResponseFields = () => ({ page: 1, per_page: 1, total: 0 }); diff --git a/x-pack/plugins/cases/server/client/user_actions/get.ts b/x-pack/plugins/cases/server/client/user_actions/get.ts index 2a6608014c80..660cf1b6a336 100644 --- a/x-pack/plugins/cases/server/client/user_actions/get.ts +++ b/x-pack/plugins/cases/server/client/user_actions/get.ts @@ -5,14 +5,14 @@ * 2.0. */ +import { SavedObjectReference, SavedObjectsFindResponse } from 'kibana/server'; import { - CASE_COMMENT_SAVED_OBJECT, - CASE_SAVED_OBJECT, CaseUserActionsResponse, CaseUserActionsResponseRt, SUB_CASE_SAVED_OBJECT, + CaseUserActionResponse, } from '../../../common'; -import { createCaseError, checkEnabledCaseConnectorOrThrow } from '../../common'; +import { createCaseError, checkEnabledCaseConnectorOrThrow, SUB_CASE_REF_NAME } from '../../common'; import { CasesClientArgs } from '..'; import { Operations } from '../../authorization'; import { UserActionGet } from './client'; @@ -40,23 +40,12 @@ export const get = async ( operation: Operations.getUserActions, }); - return CaseUserActionsResponseRt.encode( - userActions.saved_objects.reduce((acc, ua) => { - if (subCaseId == null && ua.references.some((uar) => uar.type === SUB_CASE_SAVED_OBJECT)) { - return acc; - } - return [ - ...acc, - { - ...ua.attributes, - action_id: ua.id, - case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '', - comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, - sub_case_id: ua.references.find((r) => r.type === SUB_CASE_SAVED_OBJECT)?.id ?? '', - }, - ]; - }, []) - ); + const resultsToEncode = + subCaseId == null + ? extractAttributesWithoutSubCases(userActions) + : extractAttributes(userActions); + + return CaseUserActionsResponseRt.encode(resultsToEncode); } catch (error) { throw createCaseError({ message: `Failed to retrieve user actions case id: ${caseId} sub case id: ${subCaseId}: ${error}`, @@ -65,3 +54,21 @@ export const get = async ( }); } }; + +export function extractAttributesWithoutSubCases( + userActions: SavedObjectsFindResponse +): CaseUserActionsResponse { + // exclude user actions relating to sub cases from the results + const hasSubCaseReference = (references: SavedObjectReference[]) => + references.find((ref) => ref.type === SUB_CASE_SAVED_OBJECT && ref.name === SUB_CASE_REF_NAME); + + return userActions.saved_objects + .filter((so) => !hasSubCaseReference(so.references)) + .map((so) => so.attributes); +} + +function extractAttributes( + userActions: SavedObjectsFindResponse +): CaseUserActionsResponse { + return userActions.saved_objects.map((so) => so.attributes); +} diff --git a/x-pack/plugins/cases/server/common/constants.ts b/x-pack/plugins/cases/server/common/constants.ts index 1f6af310d6ec..eba0a64a5c0b 100644 --- a/x-pack/plugins/cases/server/common/constants.ts +++ b/x-pack/plugins/cases/server/common/constants.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../common'; + /** * The name of the saved object reference indicating the action connector ID. This is stored in the Saved Object reference * field's name property. @@ -15,3 +17,30 @@ export const CONNECTOR_ID_REFERENCE_NAME = 'connectorId'; * The name of the saved object reference indicating the action connector ID that was used to push a case. */ export const PUSH_CONNECTOR_ID_REFERENCE_NAME = 'pushConnectorId'; + +/** + * The name of the saved object reference indicating the action connector ID that was used for + * adding a connector, or updating the existing connector for a user action's old_value field. + */ +export const USER_ACTION_OLD_ID_REF_NAME = 'oldConnectorId'; + +/** + * The name of the saved object reference indicating the action connector ID that was used for pushing a case, + * for a user action's old_value field. + */ +export const USER_ACTION_OLD_PUSH_ID_REF_NAME = 'oldPushConnectorId'; + +/** + * The name of the saved object reference indicating the caseId reference + */ +export const CASE_REF_NAME = `associated-${CASE_SAVED_OBJECT}`; + +/** + * The name of the saved object reference indicating the commentId reference + */ +export const COMMENT_REF_NAME = `associated-${CASE_COMMENT_SAVED_OBJECT}`; + +/** + * The name of the saved object reference indicating the subCaseId reference + */ +export const SUB_CASE_REF_NAME = `associated-${SUB_CASE_SAVED_OBJECT}`; diff --git a/x-pack/plugins/cases/server/saved_object_types/cases.ts b/x-pack/plugins/cases/server/saved_object_types/cases.ts index a362d77c0662..74c6a053e95c 100644 --- a/x-pack/plugins/cases/server/saved_object_types/cases.ts +++ b/x-pack/plugins/cases/server/saved_object_types/cases.ts @@ -117,7 +117,7 @@ export const createCaseSavedObjectType = ( type: 'keyword', }, title: { - type: 'keyword', + type: 'text', }, status: { type: 'keyword', diff --git a/x-pack/plugins/cases/server/saved_object_types/comments.ts b/x-pack/plugins/cases/server/saved_object_types/comments.ts index af14123eca58..64e75ad26ae2 100644 --- a/x-pack/plugins/cases/server/saved_object_types/comments.ts +++ b/x-pack/plugins/cases/server/saved_object_types/comments.ts @@ -112,5 +112,6 @@ export const createCaseCommentSavedObjectType = ({ migrations: createCommentsMigrations(migrationDeps), management: { importableAndExportable: true, + visibleInManagement: false, }, }); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts index bca12a86a544..9020f65ae352 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts @@ -30,322 +30,324 @@ const create_7_14_0_case = ({ }, }); -describe('7.15.0 connector ID migration', () => { - it('does not create a reference when the connector.id is none', () => { - const caseSavedObject = create_7_14_0_case({ connector: getNoneCaseConnector() }); - - const migratedConnector = caseConnectorIdMigration( - caseSavedObject - ) as SavedObjectSanitizedDoc; - - expect(migratedConnector.references.length).toBe(0); - expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); - expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` - Object { - "fields": null, - "name": "none", - "type": ".none", - } - `); - }); +describe('case migrations', () => { + describe('7.15.0 connector ID migration', () => { + it('does not create a reference when the connector.id is none', () => { + const caseSavedObject = create_7_14_0_case({ connector: getNoneCaseConnector() }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "none", + "type": ".none", + } + `); + }); - it('does not create a reference when the connector is undefined', () => { - const caseSavedObject = create_7_14_0_case(); - - const migratedConnector = caseConnectorIdMigration( - caseSavedObject - ) as SavedObjectSanitizedDoc; - - expect(migratedConnector.references.length).toBe(0); - expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); - expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` - Object { - "fields": null, - "name": "none", - "type": ".none", - } - `); - }); + it('does not create a reference when the connector is undefined', () => { + const caseSavedObject = create_7_14_0_case(); - it('sets the connector to the default none connector if the connector.id is undefined', () => { - const caseSavedObject = create_7_14_0_case({ - connector: { - fields: null, - name: ConnectorTypes.jira, - type: ConnectorTypes.jira, - } as ESCaseConnectorWithId, - }); + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; - const migratedConnector = caseConnectorIdMigration( - caseSavedObject - ) as SavedObjectSanitizedDoc; - - expect(migratedConnector.references.length).toBe(0); - expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); - expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` - Object { - "fields": null, - "name": "none", - "type": ".none", - } - `); - }); + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "none", + "type": ".none", + } + `); + }); - it('does not create a reference when the external_service is null', () => { - const caseSavedObject = create_7_14_0_case({ externalService: null }); + it('sets the connector to the default none connector if the connector.id is undefined', () => { + const caseSavedObject = create_7_14_0_case({ + connector: { + fields: null, + name: ConnectorTypes.jira, + type: ConnectorTypes.jira, + } as ESCaseConnectorWithId, + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "none", + "type": ".none", + } + `); + }); - const migratedConnector = caseConnectorIdMigration( - caseSavedObject - ) as SavedObjectSanitizedDoc; + it('does not create a reference when the external_service is null', () => { + const caseSavedObject = create_7_14_0_case({ externalService: null }); - expect(migratedConnector.references.length).toBe(0); - expect(migratedConnector.attributes.external_service).toBeNull(); - }); + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; - it('does not create a reference when the external_service is undefined and sets external_service to null', () => { - const caseSavedObject = create_7_14_0_case(); + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.external_service).toBeNull(); + }); - const migratedConnector = caseConnectorIdMigration( - caseSavedObject - ) as SavedObjectSanitizedDoc; + it('does not create a reference when the external_service is undefined and sets external_service to null', () => { + const caseSavedObject = create_7_14_0_case(); - expect(migratedConnector.references.length).toBe(0); - expect(migratedConnector.attributes.external_service).toBeNull(); - }); + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; - it('does not create a reference when the external_service.connector_id is none', () => { - const caseSavedObject = create_7_14_0_case({ - externalService: createExternalService({ connector_id: noneConnectorId }), + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.external_service).toBeNull(); }); - const migratedConnector = caseConnectorIdMigration( - caseSavedObject - ) as SavedObjectSanitizedDoc; - - expect(migratedConnector.references.length).toBe(0); - expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` - Object { - "connector_name": ".jira", - "external_id": "100", - "external_title": "awesome", - "external_url": "http://www.google.com", - "pushed_at": "2019-11-25T21:54:48.952Z", - "pushed_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - } - `); - }); - - it('preserves the existing references when migrating', () => { - const caseSavedObject = { - ...create_7_14_0_case(), - references: [{ id: '1', name: 'awesome', type: 'hello' }], - }; + it('does not create a reference when the external_service.connector_id is none', () => { + const caseSavedObject = create_7_14_0_case({ + externalService: createExternalService({ connector_id: noneConnectorId }), + }); - const migratedConnector = caseConnectorIdMigration( - caseSavedObject - ) as SavedObjectSanitizedDoc; + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; - expect(migratedConnector.references.length).toBe(1); - expect(migratedConnector.references).toMatchInlineSnapshot(` - Array [ + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` Object { - "id": "1", - "name": "awesome", - "type": "hello", - }, - ] - `); - }); + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); - it('creates a connector reference and removes the connector.id field', () => { - const caseSavedObject = create_7_14_0_case({ - connector: { - id: '123', - fields: null, - name: 'connector', - type: ConnectorTypes.jira, - }, + it('preserves the existing references when migrating', () => { + const caseSavedObject = { + ...create_7_14_0_case(), + references: [{ id: '1', name: 'awesome', type: 'hello' }], + }; + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(1); + expect(migratedConnector.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "awesome", + "type": "hello", + }, + ] + `); }); - const migratedConnector = caseConnectorIdMigration( - caseSavedObject - ) as SavedObjectSanitizedDoc; - - expect(migratedConnector.references.length).toBe(1); - expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); - expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` - Object { - "fields": null, - "name": "connector", - "type": ".jira", - } - `); - expect(migratedConnector.references).toMatchInlineSnapshot(` - Array [ - Object { - "id": "123", - "name": "connectorId", - "type": "action", + it('creates a connector reference and removes the connector.id field', () => { + const caseSavedObject = create_7_14_0_case({ + connector: { + id: '123', + fields: null, + name: 'connector', + type: ConnectorTypes.jira, }, - ] - `); - }); + }); - it('creates a push connector reference and removes the connector_id field', () => { - const caseSavedObject = create_7_14_0_case({ - externalService: { - connector_id: '100', - connector_name: '.jira', - external_id: '100', - external_title: 'awesome', - external_url: 'http://www.google.com', - pushed_at: '2019-11-25T21:54:48.952Z', - pushed_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - }, - }); + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; - const migratedConnector = caseConnectorIdMigration( - caseSavedObject - ) as SavedObjectSanitizedDoc; - - expect(migratedConnector.references.length).toBe(1); - expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id'); - expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` - Object { - "connector_name": ".jira", - "external_id": "100", - "external_title": "awesome", - "external_url": "http://www.google.com", - "pushed_at": "2019-11-25T21:54:48.952Z", - "pushed_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - } - `); - expect(migratedConnector.references).toMatchInlineSnapshot(` - Array [ + expect(migratedConnector.references.length).toBe(1); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` Object { - "id": "100", - "name": "pushConnectorId", - "type": "action", - }, - ] - `); - }); + "fields": null, + "name": "connector", + "type": ".jira", + } + `); + expect(migratedConnector.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "123", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); - it('does not create a reference and preserves the existing external_service fields when connector_id is null', () => { - const caseSavedObject = create_7_14_0_case({ - externalService: { - connector_id: null, - connector_name: '.jira', - external_id: '100', - external_title: 'awesome', - external_url: 'http://www.google.com', - pushed_at: '2019-11-25T21:54:48.952Z', - pushed_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', + it('creates a push connector reference and removes the connector_id field', () => { + const caseSavedObject = create_7_14_0_case({ + externalService: { + connector_id: '100', + connector_name: '.jira', + external_id: '100', + external_title: 'awesome', + external_url: 'http://www.google.com', + pushed_at: '2019-11-25T21:54:48.952Z', + pushed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, }, - }, + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(1); + expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id'); + expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + expect(migratedConnector.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); }); - const migratedConnector = caseConnectorIdMigration( - caseSavedObject - ) as SavedObjectSanitizedDoc; - - expect(migratedConnector.references.length).toBe(0); - expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id'); - expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` - Object { - "connector_name": ".jira", - "external_id": "100", - "external_title": "awesome", - "external_url": "http://www.google.com", - "pushed_at": "2019-11-25T21:54:48.952Z", - "pushed_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", + it('does not create a reference and preserves the existing external_service fields when connector_id is null', () => { + const caseSavedObject = create_7_14_0_case({ + externalService: { + connector_id: null, + connector_name: '.jira', + external_id: '100', + external_title: 'awesome', + external_url: 'http://www.google.com', + pushed_at: '2019-11-25T21:54:48.952Z', + pushed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, }, - } - `); - }); + }); - it('migrates both connector and external_service when provided', () => { - const caseSavedObject = create_7_14_0_case({ - externalService: { - connector_id: '100', - connector_name: '.jira', - external_id: '100', - external_title: 'awesome', - external_url: 'http://www.google.com', - pushed_at: '2019-11-25T21:54:48.952Z', - pushed_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - }, - connector: { - id: '123', - fields: null, - name: 'connector', - type: ConnectorTypes.jira, - }, + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id'); + expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); }); - const migratedConnector = caseConnectorIdMigration( - caseSavedObject - ) as SavedObjectSanitizedDoc; - - expect(migratedConnector.references.length).toBe(2); - expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id'); - expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` - Object { - "connector_name": ".jira", - "external_id": "100", - "external_title": "awesome", - "external_url": "http://www.google.com", - "pushed_at": "2019-11-25T21:54:48.952Z", - "pushed_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", + it('migrates both connector and external_service when provided', () => { + const caseSavedObject = create_7_14_0_case({ + externalService: { + connector_id: '100', + connector_name: '.jira', + external_id: '100', + external_title: 'awesome', + external_url: 'http://www.google.com', + pushed_at: '2019-11-25T21:54:48.952Z', + pushed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, }, - } - `); - expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); - expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` - Object { - "fields": null, - "name": "connector", - "type": ".jira", - } - `); - expect(migratedConnector.references).toMatchInlineSnapshot(` - Array [ - Object { - "id": "123", - "name": "connectorId", - "type": "action", + connector: { + id: '123', + fields: null, + name: 'connector', + type: ConnectorTypes.jira, }, + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(2); + expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id'); + expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` Object { - "id": "100", - "name": "pushConnectorId", - "type": "action", - }, - ] - `); + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "connector", + "type": ".jira", + } + `); + expect(migratedConnector.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "123", + "name": "connectorId", + "type": "action", + }, + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); }); }); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts index bffd4171270e..80f02fa3bf6a 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts @@ -14,7 +14,11 @@ import { } from '../../../../../../src/core/server'; import { ESConnectorFields } from '../../services'; import { ConnectorTypes, CaseType } from '../../../common'; -import { transformConnectorIdToReference, transformPushConnectorIdToReference } from './utils'; +import { + transformConnectorIdToReference, + transformPushConnectorIdToReference, +} from '../../services/user_actions/transform'; +import { CONNECTOR_ID_REFERENCE_NAME, PUSH_CONNECTOR_ID_REFERENCE_NAME } from '../../common'; interface UnsanitizedCaseConnector { connector_id: string; @@ -50,11 +54,13 @@ export const caseConnectorIdMigration = ( // removing the id field since it will be stored in the references instead const { connector, external_service, ...restAttributes } = doc.attributes; - const { transformedConnector, references: connectorReferences } = - transformConnectorIdToReference(connector); + const { transformedConnector, references: connectorReferences } = transformConnectorIdToReference( + CONNECTOR_ID_REFERENCE_NAME, + connector + ); const { transformedPushConnector, references: pushConnectorReferences } = - transformPushConnectorIdToReference(external_service); + transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, external_service); const { references = [] } = doc; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts index 4467b499817a..9ae0285598db 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts @@ -40,87 +40,89 @@ const create_7_14_0_configSchema = (connector?: ESCaseConnectorWithId) => ({ }, }); -describe('7.15.0 connector ID migration', () => { - it('does not create a reference when the connector ID is none', () => { - const configureSavedObject = create_7_14_0_configSchema(getNoneCaseConnector()); +describe('configuration migrations', () => { + describe('7.15.0 connector ID migration', () => { + it('does not create a reference when the connector ID is none', () => { + const configureSavedObject = create_7_14_0_configSchema(getNoneCaseConnector()); - const migratedConnector = configureConnectorIdMigration( - configureSavedObject - ) as SavedObjectSanitizedDoc; + const migratedConnector = configureConnectorIdMigration( + configureSavedObject + ) as SavedObjectSanitizedDoc; - expect(migratedConnector.references.length).toBe(0); - expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); - }); + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + }); - it('does not create a reference when the connector is undefined and defaults it to the none connector', () => { - const configureSavedObject = create_7_14_0_configSchema(); + it('does not create a reference when the connector is undefined and defaults it to the none connector', () => { + const configureSavedObject = create_7_14_0_configSchema(); - const migratedConnector = configureConnectorIdMigration( - configureSavedObject - ) as SavedObjectSanitizedDoc; + const migratedConnector = configureConnectorIdMigration( + configureSavedObject + ) as SavedObjectSanitizedDoc; - expect(migratedConnector.references.length).toBe(0); - expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` - Object { - "fields": null, - "name": "none", - "type": ".none", - } - `); - }); - - it('creates a reference using the connector id', () => { - const configureSavedObject = create_7_14_0_configSchema({ - id: '123', - fields: null, - name: 'connector', - type: ConnectorTypes.jira, + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "none", + "type": ".none", + } + `); }); - const migratedConnector = configureConnectorIdMigration( - configureSavedObject - ) as SavedObjectSanitizedDoc; + it('creates a reference using the connector id', () => { + const configureSavedObject = create_7_14_0_configSchema({ + id: '123', + fields: null, + name: 'connector', + type: ConnectorTypes.jira, + }); - expect(migratedConnector.references).toEqual([ - { id: '123', type: ACTION_SAVED_OBJECT_TYPE, name: CONNECTOR_ID_REFERENCE_NAME }, - ]); - expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); - }); + const migratedConnector = configureConnectorIdMigration( + configureSavedObject + ) as SavedObjectSanitizedDoc; - it('returns the other attributes and default connector when the connector is undefined', () => { - const configureSavedObject = create_7_14_0_configSchema(); + expect(migratedConnector.references).toEqual([ + { id: '123', type: ACTION_SAVED_OBJECT_TYPE, name: CONNECTOR_ID_REFERENCE_NAME }, + ]); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + }); - const migratedConnector = configureConnectorIdMigration( - configureSavedObject - ) as SavedObjectSanitizedDoc; + it('returns the other attributes and default connector when the connector is undefined', () => { + const configureSavedObject = create_7_14_0_configSchema(); - expect(migratedConnector).toMatchInlineSnapshot(` - Object { - "attributes": Object { - "closure_type": "close-by-pushing", - "connector": Object { - "fields": null, - "name": "none", - "type": ".none", - }, - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "owner": "securitySolution", - "updated_at": "2020-04-09T09:43:51.778Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", + const migratedConnector = configureConnectorIdMigration( + configureSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "closure_type": "close-by-pushing", + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "owner": "securitySolution", + "updated_at": "2020-04-09T09:43:51.778Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, }, - }, - "id": "1", - "references": Array [], - "type": "cases-configure", - } - `); + "id": "1", + "references": Array [], + "type": "cases-configure", + } + `); + }); }); }); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts index 527d40fca2e3..f9937253e0d2 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts @@ -13,7 +13,8 @@ import { } from '../../../../../../src/core/server'; import { ConnectorTypes } from '../../../common'; import { addOwnerToSO, SanitizedCaseOwner } from '.'; -import { transformConnectorIdToReference } from './utils'; +import { transformConnectorIdToReference } from '../../services/user_actions/transform'; +import { CONNECTOR_ID_REFERENCE_NAME } from '../../common'; interface UnsanitizedConfigureConnector { connector_id: string; @@ -34,8 +35,10 @@ export const configureConnectorIdMigration = ( ): SavedObjectSanitizedDoc => { // removing the id field since it will be stored in the references instead const { connector, ...restAttributes } = doc.attributes; - const { transformedConnector, references: connectorReferences } = - transformConnectorIdToReference(connector); + const { transformedConnector, references: connectorReferences } = transformConnectorIdToReference( + CONNECTOR_ID_REFERENCE_NAME, + connector + ); const { references = [] } = doc; return { diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts index a445131073d1..a4f50fbfcde5 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts @@ -5,24 +5,17 @@ * 2.0. */ -/* eslint-disable @typescript-eslint/naming-convention */ - import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc, } from '../../../../../../src/core/server'; -import { ConnectorTypes, SECURITY_SOLUTION_OWNER } from '../../../common'; +import { SECURITY_SOLUTION_OWNER } from '../../../common'; export { caseMigrations } from './cases'; export { configureMigrations } from './configuration'; +export { userActionsMigrations } from './user_actions'; export { createCommentsMigrations, CreateCommentsMigrationsDeps } from './comments'; -interface UserActions { - action_field: string[]; - new_value: string; - old_value: string; -} - export interface SanitizedCaseOwner { owner: string; } @@ -38,52 +31,6 @@ export const addOwnerToSO = >( references: doc.references || [], }); -export const userActionsMigrations = { - '7.10.0': (doc: SavedObjectUnsanitizedDoc): SavedObjectSanitizedDoc => { - const { action_field, new_value, old_value, ...restAttributes } = doc.attributes; - - if ( - action_field == null || - !Array.isArray(action_field) || - action_field[0] !== 'connector_id' - ) { - return { ...doc, references: doc.references || [] }; - } - - return { - ...doc, - attributes: { - ...restAttributes, - action_field: ['connector'], - new_value: - new_value != null - ? JSON.stringify({ - id: new_value, - name: 'none', - type: ConnectorTypes.none, - fields: null, - }) - : new_value, - old_value: - old_value != null - ? JSON.stringify({ - id: old_value, - name: 'none', - type: ConnectorTypes.none, - fields: null, - }) - : old_value, - }, - references: doc.references || [], - }; - }, - '7.14.0': ( - doc: SavedObjectUnsanitizedDoc> - ): SavedObjectSanitizedDoc => { - return addOwnerToSO(doc); - }, -}; - export const connectorMappingsMigrations = { '7.14.0': ( doc: SavedObjectUnsanitizedDoc> diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.test.ts new file mode 100644 index 000000000000..e71c8db0db69 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.test.ts @@ -0,0 +1,562 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { SavedObjectMigrationContext, SavedObjectSanitizedDoc } from 'kibana/server'; +import { migrationMocks } from 'src/core/server/mocks'; +import { CaseUserActionAttributes, CASE_USER_ACTION_SAVED_OBJECT } from '../../../common'; +import { + createConnectorObject, + createExternalService, + createJiraConnector, +} from '../../services/test_utils'; +import { userActionsConnectorIdMigration } from './user_actions'; + +const create_7_14_0_userAction = ( + params: { + action?: string; + action_field?: string[]; + new_value?: string | null | object; + old_value?: string | null | object; + } = {} +) => { + const { new_value, old_value, ...restParams } = params; + + return { + type: CASE_USER_ACTION_SAVED_OBJECT, + id: '1', + attributes: { + ...restParams, + new_value: new_value && typeof new_value === 'object' ? JSON.stringify(new_value) : new_value, + old_value: old_value && typeof old_value === 'object' ? JSON.stringify(old_value) : old_value, + }, + }; +}; + +describe('user action migrations', () => { + describe('7.15.0 connector ID migration', () => { + describe('userActionsConnectorIdMigration', () => { + let context: jest.Mocked; + + beforeEach(() => { + context = migrationMocks.createContext(); + }); + + describe('push user action', () => { + it('extracts the external_service connector_id to references for a new pushed user action', () => { + const userAction = create_7_14_0_userAction({ + action: 'push-to-service', + action_field: ['pushed'], + new_value: createExternalService(), + old_value: null, + }); + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + const parsedExternalService = JSON.parse(migratedUserAction.attributes.new_value!); + expect(parsedExternalService).not.toHaveProperty('connector_id'); + expect(parsedExternalService).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + + expect(migratedUserAction.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + + expect(migratedUserAction.attributes.old_value).toBeNull(); + }); + + it('extract the external_service connector_id to references for new and old pushed user action', () => { + const userAction = create_7_14_0_userAction({ + action: 'push-to-service', + action_field: ['pushed'], + new_value: createExternalService(), + old_value: createExternalService({ connector_id: '5' }), + }); + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + const parsedNewExternalService = JSON.parse(migratedUserAction.attributes.new_value!); + const parsedOldExternalService = JSON.parse(migratedUserAction.attributes.old_value!); + + expect(parsedNewExternalService).not.toHaveProperty('connector_id'); + expect(parsedOldExternalService).not.toHaveProperty('connector_id'); + expect(migratedUserAction.references).toEqual([ + { id: '100', name: 'pushConnectorId', type: 'action' }, + { id: '5', name: 'oldPushConnectorId', type: 'action' }, + ]); + }); + + it('preserves the existing references after extracting the external_service connector_id field', () => { + const userAction = { + ...create_7_14_0_userAction({ + action: 'push-to-service', + action_field: ['pushed'], + new_value: createExternalService(), + old_value: createExternalService({ connector_id: '5' }), + }), + references: [{ id: '500', name: 'someReference', type: 'ref' }], + }; + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + const parsedNewExternalService = JSON.parse(migratedUserAction.attributes.new_value!); + const parsedOldExternalService = JSON.parse(migratedUserAction.attributes.old_value!); + + expect(parsedNewExternalService).not.toHaveProperty('connector_id'); + expect(parsedOldExternalService).not.toHaveProperty('connector_id'); + expect(migratedUserAction.references).toEqual([ + { id: '500', name: 'someReference', type: 'ref' }, + { id: '100', name: 'pushConnectorId', type: 'action' }, + { id: '5', name: 'oldPushConnectorId', type: 'action' }, + ]); + }); + + it('leaves the object unmodified when it is not a valid push user action', () => { + const userAction = create_7_14_0_userAction({ + action: 'push-to-service', + action_field: ['invalid field'], + new_value: 'hello', + old_value: null, + }); + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + expect(migratedUserAction.attributes.old_value).toBeNull(); + expect(migratedUserAction).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "action": "push-to-service", + "action_field": Array [ + "invalid field", + ], + "new_value": "hello", + "old_value": null, + }, + "id": "1", + "references": Array [], + "type": "cases-user-actions", + } + `); + }); + + it('leaves the object unmodified when it new value is invalid json', () => { + const userAction = create_7_14_0_userAction({ + action: 'push-to-service', + action_field: ['pushed'], + new_value: '{a', + old_value: null, + }); + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + expect(migratedUserAction.attributes.old_value).toBeNull(); + expect(migratedUserAction.attributes.new_value).toEqual('{a'); + expect(migratedUserAction).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "action": "push-to-service", + "action_field": Array [ + "pushed", + ], + "new_value": "{a", + "old_value": null, + }, + "id": "1", + "references": Array [], + "type": "cases-user-actions", + } + `); + }); + + it('logs an error new value is invalid json', () => { + const userAction = create_7_14_0_userAction({ + action: 'push-to-service', + action_field: ['pushed'], + new_value: '{a', + old_value: null, + }); + + userActionsConnectorIdMigration(userAction, context); + + expect(context.log.error).toHaveBeenCalled(); + }); + }); + + describe('update connector user action', () => { + it('extracts the connector id to references for a new create connector user action', () => { + const userAction = create_7_14_0_userAction({ + action: 'update', + action_field: ['connector'], + new_value: createJiraConnector(), + old_value: null, + }); + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + const parsedConnector = JSON.parse(migratedUserAction.attributes.new_value!); + expect(parsedConnector).not.toHaveProperty('id'); + expect(parsedConnector).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + } + `); + + expect(migratedUserAction.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + + expect(migratedUserAction.attributes.old_value).toBeNull(); + }); + + it('extracts the connector id to references for a new and old create connector user action', () => { + const userAction = create_7_14_0_userAction({ + action: 'update', + action_field: ['connector'], + new_value: createJiraConnector(), + old_value: { ...createJiraConnector(), id: '5' }, + }); + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + const parsedNewConnector = JSON.parse(migratedUserAction.attributes.new_value!); + const parsedOldConnector = JSON.parse(migratedUserAction.attributes.new_value!); + + expect(parsedNewConnector).not.toHaveProperty('id'); + expect(parsedOldConnector).not.toHaveProperty('id'); + + expect(migratedUserAction.references).toEqual([ + { id: '1', name: 'connectorId', type: 'action' }, + { id: '5', name: 'oldConnectorId', type: 'action' }, + ]); + }); + + it('preserves the existing references after extracting the connector.id field', () => { + const userAction = { + ...create_7_14_0_userAction({ + action: 'update', + action_field: ['connector'], + new_value: createJiraConnector(), + old_value: { ...createJiraConnector(), id: '5' }, + }), + references: [{ id: '500', name: 'someReference', type: 'ref' }], + }; + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + const parsedNewConnectorId = JSON.parse(migratedUserAction.attributes.new_value!); + const parsedOldConnectorId = JSON.parse(migratedUserAction.attributes.old_value!); + + expect(parsedNewConnectorId).not.toHaveProperty('id'); + expect(parsedOldConnectorId).not.toHaveProperty('id'); + expect(migratedUserAction.references).toEqual([ + { id: '500', name: 'someReference', type: 'ref' }, + { id: '1', name: 'connectorId', type: 'action' }, + { id: '5', name: 'oldConnectorId', type: 'action' }, + ]); + }); + + it('leaves the object unmodified when it is not a valid create connector user action', () => { + const userAction = create_7_14_0_userAction({ + action: 'update', + action_field: ['invalid action'], + new_value: 'new json value', + old_value: 'old value', + }); + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + expect(migratedUserAction).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "action": "update", + "action_field": Array [ + "invalid action", + ], + "new_value": "new json value", + "old_value": "old value", + }, + "id": "1", + "references": Array [], + "type": "cases-user-actions", + } + `); + }); + + it('leaves the object unmodified when old_value is invalid json', () => { + const userAction = create_7_14_0_userAction({ + action: 'update', + action_field: ['connector'], + new_value: '{}', + old_value: '{b', + }); + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + expect(migratedUserAction).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "action": "update", + "action_field": Array [ + "connector", + ], + "new_value": "{}", + "old_value": "{b", + }, + "id": "1", + "references": Array [], + "type": "cases-user-actions", + } + `); + }); + + it('logs an error message when old_value is invalid json', () => { + const userAction = create_7_14_0_userAction({ + action: 'update', + action_field: ['connector'], + new_value: createJiraConnector(), + old_value: '{b', + }); + + userActionsConnectorIdMigration(userAction, context); + + expect(context.log.error).toHaveBeenCalled(); + }); + }); + + describe('create connector user action', () => { + it('extracts the connector id to references for a new create connector user action', () => { + const userAction = create_7_14_0_userAction({ + action: 'create', + action_field: ['connector'], + new_value: createConnectorObject(), + old_value: null, + }); + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + const parsedConnector = JSON.parse(migratedUserAction.attributes.new_value!); + expect(parsedConnector.connector).not.toHaveProperty('id'); + expect(parsedConnector).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + }, + } + `); + + expect(migratedUserAction.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + + expect(migratedUserAction.attributes.old_value).toBeNull(); + }); + + it('extracts the connector id to references for a new and old create connector user action', () => { + const userAction = create_7_14_0_userAction({ + action: 'create', + action_field: ['connector'], + new_value: createConnectorObject(), + old_value: createConnectorObject({ id: '5' }), + }); + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + const parsedNewConnector = JSON.parse(migratedUserAction.attributes.new_value!); + const parsedOldConnector = JSON.parse(migratedUserAction.attributes.new_value!); + + expect(parsedNewConnector.connector).not.toHaveProperty('id'); + expect(parsedOldConnector.connector).not.toHaveProperty('id'); + + expect(migratedUserAction.references).toEqual([ + { id: '1', name: 'connectorId', type: 'action' }, + { id: '5', name: 'oldConnectorId', type: 'action' }, + ]); + }); + + it('preserves the existing references after extracting the connector.id field', () => { + const userAction = { + ...create_7_14_0_userAction({ + action: 'create', + action_field: ['connector'], + new_value: createConnectorObject(), + old_value: createConnectorObject({ id: '5' }), + }), + references: [{ id: '500', name: 'someReference', type: 'ref' }], + }; + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + const parsedNewConnectorId = JSON.parse(migratedUserAction.attributes.new_value!); + const parsedOldConnectorId = JSON.parse(migratedUserAction.attributes.old_value!); + + expect(parsedNewConnectorId.connector).not.toHaveProperty('id'); + expect(parsedOldConnectorId.connector).not.toHaveProperty('id'); + expect(migratedUserAction.references).toEqual([ + { id: '500', name: 'someReference', type: 'ref' }, + { id: '1', name: 'connectorId', type: 'action' }, + { id: '5', name: 'oldConnectorId', type: 'action' }, + ]); + }); + + it('leaves the object unmodified when it is not a valid create connector user action', () => { + const userAction = create_7_14_0_userAction({ + action: 'create', + action_field: ['invalid action'], + new_value: 'new json value', + old_value: 'old value', + }); + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + expect(migratedUserAction).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "action": "create", + "action_field": Array [ + "invalid action", + ], + "new_value": "new json value", + "old_value": "old value", + }, + "id": "1", + "references": Array [], + "type": "cases-user-actions", + } + `); + }); + + it('leaves the object unmodified when new_value is invalid json', () => { + const userAction = create_7_14_0_userAction({ + action: 'create', + action_field: ['connector'], + new_value: 'new json value', + old_value: 'old value', + }); + + const migratedUserAction = userActionsConnectorIdMigration( + userAction, + context + ) as SavedObjectSanitizedDoc; + + expect(migratedUserAction).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "action": "create", + "action_field": Array [ + "connector", + ], + "new_value": "new json value", + "old_value": "old value", + }, + "id": "1", + "references": Array [], + "type": "cases-user-actions", + } + `); + }); + + it('logs an error message when new_value is invalid json', () => { + const userAction = create_7_14_0_userAction({ + action: 'create', + action_field: ['connector'], + new_value: 'new json value', + old_value: 'old value', + }); + + userActionsConnectorIdMigration(userAction, context); + + expect(context.log.error).toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.ts new file mode 100644 index 000000000000..ed6b57ef647f --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.ts @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { addOwnerToSO, SanitizedCaseOwner } from '.'; +import { + SavedObjectUnsanitizedDoc, + SavedObjectSanitizedDoc, + SavedObjectMigrationContext, + LogMeta, +} from '../../../../../../src/core/server'; +import { ConnectorTypes, isCreateConnector, isPush, isUpdateConnector } from '../../../common'; + +import { extractConnectorIdFromJson } from '../../services/user_actions/transform'; +import { UserActionFieldType } from '../../services/user_actions/types'; + +interface UserActions { + action_field: string[]; + new_value: string; + old_value: string; +} + +interface UserActionUnmigratedConnectorDocument { + action?: string; + action_field?: string[]; + new_value?: string | null; + old_value?: string | null; +} + +interface UserActionLogMeta extends LogMeta { + migrations: { userAction: { id: string } }; +} + +export function userActionsConnectorIdMigration( + doc: SavedObjectUnsanitizedDoc, + context: SavedObjectMigrationContext +): SavedObjectSanitizedDoc { + const originalDocWithReferences = { ...doc, references: doc.references ?? [] }; + + if (!isConnectorUserAction(doc.attributes.action, doc.attributes.action_field)) { + return originalDocWithReferences; + } + + try { + return formatDocumentWithConnectorReferences(doc); + } catch (error) { + logError(doc.id, context, error); + + return originalDocWithReferences; + } +} + +function isConnectorUserAction(action?: string, actionFields?: string[]): boolean { + return ( + isCreateConnector(action, actionFields) || + isUpdateConnector(action, actionFields) || + isPush(action, actionFields) + ); +} + +function formatDocumentWithConnectorReferences( + doc: SavedObjectUnsanitizedDoc +): SavedObjectSanitizedDoc { + const { new_value, old_value, action, action_field, ...restAttributes } = doc.attributes; + const { references = [] } = doc; + + const { transformedActionDetails: transformedNewValue, references: newValueConnectorRefs } = + extractConnectorIdFromJson({ + action, + actionFields: action_field, + actionDetails: new_value, + fieldType: UserActionFieldType.New, + }); + + const { transformedActionDetails: transformedOldValue, references: oldValueConnectorRefs } = + extractConnectorIdFromJson({ + action, + actionFields: action_field, + actionDetails: old_value, + fieldType: UserActionFieldType.Old, + }); + + return { + ...doc, + attributes: { + ...restAttributes, + action, + action_field, + new_value: transformedNewValue, + old_value: transformedOldValue, + }, + references: [...references, ...newValueConnectorRefs, ...oldValueConnectorRefs], + }; +} + +function logError(id: string, context: SavedObjectMigrationContext, error: Error) { + context.log.error( + `Failed to migrate user action connector doc id: ${id} version: ${context.migrationVersion} error: ${error.message}`, + { + migrations: { + userAction: { + id, + }, + }, + } + ); +} + +export const userActionsMigrations = { + '7.10.0': (doc: SavedObjectUnsanitizedDoc): SavedObjectSanitizedDoc => { + const { action_field, new_value, old_value, ...restAttributes } = doc.attributes; + + if ( + action_field == null || + !Array.isArray(action_field) || + action_field[0] !== 'connector_id' + ) { + return { ...doc, references: doc.references || [] }; + } + + return { + ...doc, + attributes: { + ...restAttributes, + action_field: ['connector'], + new_value: + new_value != null + ? JSON.stringify({ + id: new_value, + name: 'none', + type: ConnectorTypes.none, + fields: null, + }) + : new_value, + old_value: + old_value != null + ? JSON.stringify({ + id: old_value, + name: 'none', + type: ConnectorTypes.none, + fields: null, + }) + : old_value, + }, + references: doc.references || [], + }; + }, + '7.14.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return addOwnerToSO(doc); + }, + '7.16.0': userActionsConnectorIdMigration, +}; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts deleted file mode 100644 index f591bef6b323..000000000000 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { noneConnectorId } from '../../../common'; -import { createExternalService, createJiraConnector } from '../../services/test_utils'; -import { transformConnectorIdToReference, transformPushConnectorIdToReference } from './utils'; - -describe('migration utils', () => { - describe('transformConnectorIdToReference', () => { - it('returns the default none connector when the connector is undefined', () => { - expect(transformConnectorIdToReference().transformedConnector).toMatchInlineSnapshot(` - Object { - "connector": Object { - "fields": null, - "name": "none", - "type": ".none", - }, - } - `); - }); - - it('returns the default none connector when the id is undefined', () => { - expect(transformConnectorIdToReference({ id: undefined }).transformedConnector) - .toMatchInlineSnapshot(` - Object { - "connector": Object { - "fields": null, - "name": "none", - "type": ".none", - }, - } - `); - }); - - it('returns the default none connector when the id is none', () => { - expect(transformConnectorIdToReference({ id: noneConnectorId }).transformedConnector) - .toMatchInlineSnapshot(` - Object { - "connector": Object { - "fields": null, - "name": "none", - "type": ".none", - }, - } - `); - }); - - it('returns the default none connector when the id is none and other fields are defined', () => { - expect( - transformConnectorIdToReference({ ...createJiraConnector(), id: noneConnectorId }) - .transformedConnector - ).toMatchInlineSnapshot(` - Object { - "connector": Object { - "fields": null, - "name": "none", - "type": ".none", - }, - } - `); - }); - - it('returns an empty array of references when the connector is undefined', () => { - expect(transformConnectorIdToReference().references.length).toBe(0); - }); - - it('returns an empty array of references when the id is undefined', () => { - expect(transformConnectorIdToReference({ id: undefined }).references.length).toBe(0); - }); - - it('returns an empty array of references when the id is the none connector', () => { - expect(transformConnectorIdToReference({ id: noneConnectorId }).references.length).toBe(0); - }); - - it('returns an empty array of references when the id is the none connector and other fields are defined', () => { - expect( - transformConnectorIdToReference({ ...createJiraConnector(), id: noneConnectorId }) - .references.length - ).toBe(0); - }); - - it('returns a jira connector', () => { - const transformedFields = transformConnectorIdToReference(createJiraConnector()); - expect(transformedFields.transformedConnector).toMatchInlineSnapshot(` - Object { - "connector": Object { - "fields": Object { - "issueType": "bug", - "parent": "2", - "priority": "high", - }, - "name": ".jira", - "type": ".jira", - }, - } - `); - expect(transformedFields.references).toMatchInlineSnapshot(` - Array [ - Object { - "id": "1", - "name": "connectorId", - "type": "action", - }, - ] - `); - }); - }); - - describe('transformPushConnectorIdToReference', () => { - it('sets external_service to null when it is undefined', () => { - expect(transformPushConnectorIdToReference().transformedPushConnector).toMatchInlineSnapshot(` - Object { - "external_service": null, - } - `); - }); - - it('sets external_service to null when it is null', () => { - expect(transformPushConnectorIdToReference(null).transformedPushConnector) - .toMatchInlineSnapshot(` - Object { - "external_service": null, - } - `); - }); - - it('returns an object when external_service is defined but connector_id is undefined', () => { - expect( - transformPushConnectorIdToReference({ connector_id: undefined }).transformedPushConnector - ).toMatchInlineSnapshot(` - Object { - "external_service": Object {}, - } - `); - }); - - it('returns an object when external_service is defined but connector_id is null', () => { - expect(transformPushConnectorIdToReference({ connector_id: null }).transformedPushConnector) - .toMatchInlineSnapshot(` - Object { - "external_service": Object {}, - } - `); - }); - - it('returns an object when external_service is defined but connector_id is none', () => { - const otherFields = { otherField: 'hi' }; - - expect( - transformPushConnectorIdToReference({ ...otherFields, connector_id: noneConnectorId }) - .transformedPushConnector - ).toMatchInlineSnapshot(` - Object { - "external_service": Object { - "otherField": "hi", - }, - } - `); - }); - - it('returns an empty array of references when the external_service is undefined', () => { - expect(transformPushConnectorIdToReference().references.length).toBe(0); - }); - - it('returns an empty array of references when the external_service is null', () => { - expect(transformPushConnectorIdToReference(null).references.length).toBe(0); - }); - - it('returns an empty array of references when the connector_id is undefined', () => { - expect( - transformPushConnectorIdToReference({ connector_id: undefined }).references.length - ).toBe(0); - }); - - it('returns an empty array of references when the connector_id is null', () => { - expect( - transformPushConnectorIdToReference({ connector_id: undefined }).references.length - ).toBe(0); - }); - - it('returns an empty array of references when the connector_id is the none connector', () => { - expect( - transformPushConnectorIdToReference({ connector_id: noneConnectorId }).references.length - ).toBe(0); - }); - - it('returns an empty array of references when the connector_id is the none connector and other fields are defined', () => { - expect( - transformPushConnectorIdToReference({ - ...createExternalService(), - connector_id: noneConnectorId, - }).references.length - ).toBe(0); - }); - - it('returns the external_service connector', () => { - const transformedFields = transformPushConnectorIdToReference(createExternalService()); - expect(transformedFields.transformedPushConnector).toMatchInlineSnapshot(` - Object { - "external_service": Object { - "connector_name": ".jira", - "external_id": "100", - "external_title": "awesome", - "external_url": "http://www.google.com", - "pushed_at": "2019-11-25T21:54:48.952Z", - "pushed_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - }, - } - `); - expect(transformedFields.references).toMatchInlineSnapshot(` - Array [ - Object { - "id": "100", - "name": "pushConnectorId", - "type": "action", - }, - ] - `); - }); - }); -}); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts deleted file mode 100644 index 0100a04cde67..000000000000 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* eslint-disable @typescript-eslint/naming-convention */ - -import { noneConnectorId } from '../../../common'; -import { SavedObjectReference } from '../../../../../../src/core/server'; -import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; -import { - getNoneCaseConnector, - CONNECTOR_ID_REFERENCE_NAME, - PUSH_CONNECTOR_ID_REFERENCE_NAME, -} from '../../common'; - -export const transformConnectorIdToReference = (connector?: { - id?: string; -}): { transformedConnector: Record; references: SavedObjectReference[] } => { - const { id: connectorId, ...restConnector } = connector ?? {}; - - const references = createConnectorReference( - connectorId, - ACTION_SAVED_OBJECT_TYPE, - CONNECTOR_ID_REFERENCE_NAME - ); - - const { id: ignoreNoneId, ...restNoneConnector } = getNoneCaseConnector(); - const connectorFieldsToReturn = - connector && references.length > 0 ? restConnector : restNoneConnector; - - return { - transformedConnector: { - connector: connectorFieldsToReturn, - }, - references, - }; -}; - -const createConnectorReference = ( - id: string | null | undefined, - type: string, - name: string -): SavedObjectReference[] => { - return id && id !== noneConnectorId - ? [ - { - id, - type, - name, - }, - ] - : []; -}; - -export const transformPushConnectorIdToReference = ( - external_service?: { connector_id?: string | null } | null -): { transformedPushConnector: Record; references: SavedObjectReference[] } => { - const { connector_id: pushConnectorId, ...restExternalService } = external_service ?? {}; - - const references = createConnectorReference( - pushConnectorId, - ACTION_SAVED_OBJECT_TYPE, - PUSH_CONNECTOR_ID_REFERENCE_NAME - ); - - return { - transformedPushConnector: { external_service: external_service ? restExternalService : null }, - references, - }; -}; diff --git a/x-pack/plugins/cases/server/saved_object_types/user_actions.ts b/x-pack/plugins/cases/server/saved_object_types/user_actions.ts index 883105982bcb..7ef7c639ed9d 100644 --- a/x-pack/plugins/cases/server/saved_object_types/user_actions.ts +++ b/x-pack/plugins/cases/server/saved_object_types/user_actions.ts @@ -51,5 +51,6 @@ export const caseUserActionSavedObjectType: SavedObjectsType = { migrations: userActionsMigrations, management: { importableAndExportable: true, + visibleInManagement: false, }, }; diff --git a/x-pack/plugins/cases/server/services/cases/index.test.ts b/x-pack/plugins/cases/server/services/cases/index.test.ts index 18f4ff867cfa..8c71abe5bff4 100644 --- a/x-pack/plugins/cases/server/services/cases/index.test.ts +++ b/x-pack/plugins/cases/server/services/cases/index.test.ts @@ -40,6 +40,7 @@ import { createSavedObjectReferences, createCaseSavedObjectResponse, basicCaseFields, + createSOFindResponse, } from '../test_utils'; import { ESCaseAttributes } from './types'; @@ -87,13 +88,6 @@ const createFindSO = ( score: 0, }); -const createSOFindResponse = (savedObjects: Array>) => ({ - saved_objects: savedObjects, - total: savedObjects.length, - per_page: savedObjects.length, - page: 1, -}); - const createCaseUpdateParams = ( connector?: CaseConnector, externalService?: CaseFullExternalService diff --git a/x-pack/plugins/cases/server/services/test_utils.ts b/x-pack/plugins/cases/server/services/test_utils.ts index b712ea07f9c7..07743eda6121 100644 --- a/x-pack/plugins/cases/server/services/test_utils.ts +++ b/x-pack/plugins/cases/server/services/test_utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SavedObject, SavedObjectReference } from 'kibana/server'; +import { SavedObject, SavedObjectReference, SavedObjectsFindResult } from 'kibana/server'; import { ESConnectorFields } from '.'; import { CONNECTOR_ID_REFERENCE_NAME, PUSH_CONNECTOR_ID_REFERENCE_NAME } from '../common'; import { @@ -54,7 +54,7 @@ export const createESJiraConnector = ( { key: 'parent', value: '2' }, ], type: ConnectorTypes.jira, - ...(overrides && { ...overrides }), + ...overrides, }; }; @@ -94,7 +94,7 @@ export const createExternalService = ( email: 'testemail@elastic.co', username: 'elastic', }, - ...(overrides && { ...overrides }), + ...overrides, }); export const basicCaseFields = { @@ -198,3 +198,14 @@ export const createSavedObjectReferences = ({ ] : []), ]; + +export const createConnectorObject = (overrides?: Partial) => ({ + connector: { ...createJiraConnector(), ...overrides }, +}); + +export const createSOFindResponse = (savedObjects: Array>) => ({ + saved_objects: savedObjects, + total: savedObjects.length, + per_page: savedObjects.length, + page: 1, +}); diff --git a/x-pack/plugins/cases/server/services/user_actions/helpers.test.ts b/x-pack/plugins/cases/server/services/user_actions/helpers.test.ts new file mode 100644 index 000000000000..7bcbaf58d0f6 --- /dev/null +++ b/x-pack/plugins/cases/server/services/user_actions/helpers.test.ts @@ -0,0 +1,332 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { UserActionField } from '../../../common'; +import { createConnectorObject, createExternalService, createJiraConnector } from '../test_utils'; +import { buildCaseUserActionItem } from './helpers'; + +const defaultFields = () => ({ + actionAt: 'now', + actionBy: { + email: 'a', + full_name: 'j', + username: '1', + }, + caseId: '300', + owner: 'securitySolution', +}); + +describe('user action helpers', () => { + describe('buildCaseUserActionItem', () => { + describe('push user action', () => { + it('extracts the external_service connector_id to references for a new pushed user action', () => { + const userAction = buildCaseUserActionItem({ + ...defaultFields(), + action: 'push-to-service', + fields: ['pushed'], + newValue: createExternalService(), + }); + + const parsedExternalService = JSON.parse(userAction.attributes.new_value!); + expect(parsedExternalService).not.toHaveProperty('connector_id'); + expect(parsedExternalService).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + + expect(userAction.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "300", + "name": "associated-cases", + "type": "cases", + }, + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + + expect(userAction.attributes.old_value).toBeNull(); + }); + + it('extract the external_service connector_id to references for new and old pushed user action', () => { + const userAction = buildCaseUserActionItem({ + ...defaultFields(), + action: 'push-to-service', + fields: ['pushed'], + newValue: createExternalService(), + oldValue: createExternalService({ connector_id: '5' }), + }); + + const parsedNewExternalService = JSON.parse(userAction.attributes.new_value!); + const parsedOldExternalService = JSON.parse(userAction.attributes.old_value!); + + expect(parsedNewExternalService).not.toHaveProperty('connector_id'); + expect(parsedOldExternalService).not.toHaveProperty('connector_id'); + expect(userAction.references).toEqual([ + { id: '300', name: 'associated-cases', type: 'cases' }, + { id: '100', name: 'pushConnectorId', type: 'action' }, + { id: '5', name: 'oldPushConnectorId', type: 'action' }, + ]); + }); + + it('leaves the object unmodified when it is not a valid push user action', () => { + const userAction = buildCaseUserActionItem({ + ...defaultFields(), + action: 'push-to-service', + fields: ['invalid field'] as unknown as UserActionField, + newValue: 'hello' as unknown as Record, + }); + + expect(userAction.attributes.old_value).toBeNull(); + expect(userAction).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "action": "push-to-service", + "action_at": "now", + "action_by": Object { + "email": "a", + "full_name": "j", + "username": "1", + }, + "action_field": Array [ + "invalid field", + ], + "new_value": "hello", + "old_value": null, + "owner": "securitySolution", + }, + "references": Array [ + Object { + "id": "300", + "name": "associated-cases", + "type": "cases", + }, + ], + } + `); + }); + }); + + describe('update connector user action', () => { + it('extracts the connector id to references for a new create connector user action', () => { + const userAction = buildCaseUserActionItem({ + ...defaultFields(), + action: 'update', + fields: ['connector'], + newValue: createJiraConnector(), + }); + + const parsedConnector = JSON.parse(userAction.attributes.new_value!); + expect(parsedConnector).not.toHaveProperty('id'); + expect(parsedConnector).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + } + `); + + expect(userAction.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "300", + "name": "associated-cases", + "type": "cases", + }, + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + + expect(userAction.attributes.old_value).toBeNull(); + }); + + it('extracts the connector id to references for a new and old create connector user action', () => { + const userAction = buildCaseUserActionItem({ + ...defaultFields(), + action: 'update', + fields: ['connector'], + newValue: createJiraConnector(), + oldValue: { ...createJiraConnector(), id: '5' }, + }); + + const parsedNewConnector = JSON.parse(userAction.attributes.new_value!); + const parsedOldConnector = JSON.parse(userAction.attributes.new_value!); + + expect(parsedNewConnector).not.toHaveProperty('id'); + expect(parsedOldConnector).not.toHaveProperty('id'); + + expect(userAction.references).toEqual([ + { id: '300', name: 'associated-cases', type: 'cases' }, + { id: '1', name: 'connectorId', type: 'action' }, + { id: '5', name: 'oldConnectorId', type: 'action' }, + ]); + }); + + it('leaves the object unmodified when it is not a valid create connector user action', () => { + const userAction = buildCaseUserActionItem({ + ...defaultFields(), + action: 'update', + fields: ['invalid field'] as unknown as UserActionField, + newValue: 'hello' as unknown as Record, + oldValue: 'old value' as unknown as Record, + }); + + expect(userAction).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "action": "update", + "action_at": "now", + "action_by": Object { + "email": "a", + "full_name": "j", + "username": "1", + }, + "action_field": Array [ + "invalid field", + ], + "new_value": "hello", + "old_value": "old value", + "owner": "securitySolution", + }, + "references": Array [ + Object { + "id": "300", + "name": "associated-cases", + "type": "cases", + }, + ], + } + `); + }); + }); + + describe('create connector user action', () => { + it('extracts the connector id to references for a new create connector user action', () => { + const userAction = buildCaseUserActionItem({ + ...defaultFields(), + action: 'create', + fields: ['connector'], + newValue: createConnectorObject(), + }); + + const parsedConnector = JSON.parse(userAction.attributes.new_value!); + expect(parsedConnector.connector).not.toHaveProperty('id'); + expect(parsedConnector).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + }, + } + `); + + expect(userAction.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "300", + "name": "associated-cases", + "type": "cases", + }, + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + + expect(userAction.attributes.old_value).toBeNull(); + }); + + it('extracts the connector id to references for a new and old create connector user action', () => { + const userAction = buildCaseUserActionItem({ + ...defaultFields(), + action: 'create', + fields: ['connector'], + newValue: createConnectorObject(), + oldValue: createConnectorObject({ id: '5' }), + }); + + const parsedNewConnector = JSON.parse(userAction.attributes.new_value!); + const parsedOldConnector = JSON.parse(userAction.attributes.new_value!); + + expect(parsedNewConnector.connector).not.toHaveProperty('id'); + expect(parsedOldConnector.connector).not.toHaveProperty('id'); + + expect(userAction.references).toEqual([ + { id: '300', name: 'associated-cases', type: 'cases' }, + { id: '1', name: 'connectorId', type: 'action' }, + { id: '5', name: 'oldConnectorId', type: 'action' }, + ]); + }); + + it('leaves the object unmodified when it is not a valid create connector user action', () => { + const userAction = buildCaseUserActionItem({ + ...defaultFields(), + action: 'create', + fields: ['invalid action'] as unknown as UserActionField, + newValue: 'new json value' as unknown as Record, + oldValue: 'old value' as unknown as Record, + }); + + expect(userAction).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "action": "create", + "action_at": "now", + "action_by": Object { + "email": "a", + "full_name": "j", + "username": "1", + }, + "action_field": Array [ + "invalid action", + ], + "new_value": "new json value", + "old_value": "old value", + "owner": "securitySolution", + }, + "references": Array [ + Object { + "id": "300", + "name": "associated-cases", + "type": "cases", + }, + ], + } + `); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/services/user_actions/helpers.ts b/x-pack/plugins/cases/server/services/user_actions/helpers.ts index 223e731aa8d9..e91b69f0995b 100644 --- a/x-pack/plugins/cases/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/cases/server/services/user_actions/helpers.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SavedObject, SavedObjectsUpdateResponse } from 'kibana/server'; +import { SavedObject, SavedObjectReference, SavedObjectsUpdateResponse } from 'kibana/server'; import { get, isPlainObject, isString } from 'lodash'; import deepEqual from 'fast-deep-equal'; @@ -23,8 +23,68 @@ import { } from '../../../common'; import { isTwoArraysDifference } from '../../client/utils'; import { UserActionItem } from '.'; +import { extractConnectorId } from './transform'; +import { UserActionFieldType } from './types'; +import { CASE_REF_NAME, COMMENT_REF_NAME, SUB_CASE_REF_NAME } from '../../common'; -export const transformNewUserAction = ({ +interface BuildCaseUserActionParams { + action: UserAction; + actionAt: string; + actionBy: User; + caseId: string; + owner: string; + fields: UserActionField; + newValue?: Record | string | null; + oldValue?: Record | string | null; + subCaseId?: string; +} + +export const buildCaseUserActionItem = ({ + action, + actionAt, + actionBy, + caseId, + fields, + newValue, + oldValue, + subCaseId, + owner, +}: BuildCaseUserActionParams): UserActionItem => { + const { transformedActionDetails: transformedNewValue, references: newValueReferences } = + extractConnectorId({ + action, + actionFields: fields, + actionDetails: newValue, + fieldType: UserActionFieldType.New, + }); + + const { transformedActionDetails: transformedOldValue, references: oldValueReferences } = + extractConnectorId({ + action, + actionFields: fields, + actionDetails: oldValue, + fieldType: UserActionFieldType.Old, + }); + + return { + attributes: transformNewUserAction({ + actionField: fields, + action, + actionAt, + owner, + ...actionBy, + newValue: transformedNewValue, + oldValue: transformedOldValue, + }), + references: [ + ...createCaseReferences(caseId, subCaseId), + ...newValueReferences, + ...oldValueReferences, + ], + }; +}; + +const transformNewUserAction = ({ actionField, action, actionAt, @@ -55,103 +115,43 @@ export const transformNewUserAction = ({ owner, }); -interface BuildCaseUserAction { - action: UserAction; - actionAt: string; - actionBy: User; - caseId: string; - owner: string; - fields: UserActionField | unknown[]; - newValue?: string | unknown; - oldValue?: string | unknown; - subCaseId?: string; -} +const createCaseReferences = (caseId: string, subCaseId?: string): SavedObjectReference[] => [ + { + type: CASE_SAVED_OBJECT, + name: CASE_REF_NAME, + id: caseId, + }, + ...(subCaseId + ? [ + { + type: SUB_CASE_SAVED_OBJECT, + name: SUB_CASE_REF_NAME, + id: subCaseId, + }, + ] + : []), +]; -interface BuildCommentUserActionItem extends BuildCaseUserAction { +interface BuildCommentUserActionItem extends BuildCaseUserActionParams { commentId: string; } -export const buildCommentUserActionItem = ({ - action, - actionAt, - actionBy, - caseId, - commentId, - fields, - newValue, - oldValue, - subCaseId, - owner, -}: BuildCommentUserActionItem): UserActionItem => ({ - attributes: transformNewUserAction({ - actionField: fields as UserActionField, - action, - actionAt, - owner, - ...actionBy, - newValue: newValue as string, - oldValue: oldValue as string, - }), - references: [ - { - type: CASE_SAVED_OBJECT, - name: `associated-${CASE_SAVED_OBJECT}`, - id: caseId, - }, - { - type: CASE_COMMENT_SAVED_OBJECT, - name: `associated-${CASE_COMMENT_SAVED_OBJECT}`, - id: commentId, - }, - ...(subCaseId - ? [ - { - type: SUB_CASE_SAVED_OBJECT, - id: subCaseId, - name: `associated-${SUB_CASE_SAVED_OBJECT}`, - }, - ] - : []), - ], -}); +export const buildCommentUserActionItem = (params: BuildCommentUserActionItem): UserActionItem => { + const { commentId } = params; + const { attributes, references } = buildCaseUserActionItem(params); -export const buildCaseUserActionItem = ({ - action, - actionAt, - actionBy, - caseId, - fields, - newValue, - oldValue, - subCaseId, - owner, -}: BuildCaseUserAction): UserActionItem => ({ - attributes: transformNewUserAction({ - actionField: fields as UserActionField, - action, - actionAt, - owner, - ...actionBy, - newValue: newValue as string, - oldValue: oldValue as string, - }), - references: [ - { - type: CASE_SAVED_OBJECT, - name: `associated-${CASE_SAVED_OBJECT}`, - id: caseId, - }, - ...(subCaseId - ? [ - { - type: SUB_CASE_SAVED_OBJECT, - name: `associated-${SUB_CASE_SAVED_OBJECT}`, - id: subCaseId, - }, - ] - : []), - ], -}); + return { + attributes, + references: [ + ...references, + { + type: CASE_COMMENT_SAVED_OBJECT, + name: COMMENT_REF_NAME, + id: commentId, + }, + ], + }; +}; const userActionFieldsAllowed: UserActionField = [ 'comment', @@ -278,8 +278,8 @@ const buildGenericCaseUserActions = ({ caseId, subCaseId, fields: [field], - newValue: JSON.stringify(updatedValue), - oldValue: JSON.stringify(origValue), + newValue: updatedValue, + oldValue: origValue, owner: originalItem.attributes.owner, }), ]; diff --git a/x-pack/plugins/cases/server/services/user_actions/index.test.ts b/x-pack/plugins/cases/server/services/user_actions/index.test.ts new file mode 100644 index 000000000000..c4a350f4ac01 --- /dev/null +++ b/x-pack/plugins/cases/server/services/user_actions/index.test.ts @@ -0,0 +1,557 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObject, SavedObjectsFindResult } from 'kibana/server'; +import { transformFindResponseToExternalModel, UserActionItem } from '.'; +import { + CaseUserActionAttributes, + CASE_USER_ACTION_SAVED_OBJECT, + UserAction, + UserActionField, +} from '../../../common'; + +import { + createConnectorObject, + createExternalService, + createJiraConnector, + createSOFindResponse, +} from '../test_utils'; +import { buildCaseUserActionItem, buildCommentUserActionItem } from './helpers'; + +const createConnectorUserAction = ( + subCaseId?: string, + overrides?: Partial +): SavedObject => { + return { + ...createUserActionSO({ + action: 'create', + fields: ['connector'], + newValue: createConnectorObject(), + subCaseId, + }), + ...(overrides && { ...overrides }), + }; +}; + +const updateConnectorUserAction = ({ + subCaseId, + overrides, + oldValue, +}: { + subCaseId?: string; + overrides?: Partial; + oldValue?: string | null | Record; +} = {}): SavedObject => { + return { + ...createUserActionSO({ + action: 'update', + fields: ['connector'], + newValue: createJiraConnector(), + oldValue, + subCaseId, + }), + ...(overrides && { ...overrides }), + }; +}; + +const pushConnectorUserAction = ({ + subCaseId, + overrides, + oldValue, +}: { + subCaseId?: string; + overrides?: Partial; + oldValue?: string | null | Record; +} = {}): SavedObject => { + return { + ...createUserActionSO({ + action: 'push-to-service', + fields: ['pushed'], + newValue: createExternalService(), + oldValue, + subCaseId, + }), + ...(overrides && { ...overrides }), + }; +}; + +const createUserActionFindSO = ( + userAction: SavedObject +): SavedObjectsFindResult => ({ + ...userAction, + score: 0, +}); + +const createUserActionSO = ({ + action, + fields, + subCaseId, + newValue, + oldValue, + attributesOverrides, + commentId, +}: { + action: UserAction; + fields: UserActionField; + subCaseId?: string; + newValue?: string | null | Record; + oldValue?: string | null | Record; + attributesOverrides?: Partial; + commentId?: string; +}): SavedObject => { + const defaultParams = { + action, + actionAt: 'abc', + actionBy: { + email: 'a', + username: 'b', + full_name: 'abc', + }, + caseId: '1', + subCaseId, + fields, + newValue, + oldValue, + owner: 'securitySolution', + }; + + let userAction: UserActionItem; + + if (commentId) { + userAction = buildCommentUserActionItem({ + commentId, + ...defaultParams, + }); + } else { + userAction = buildCaseUserActionItem(defaultParams); + } + + return { + type: CASE_USER_ACTION_SAVED_OBJECT, + id: '100', + attributes: { + ...userAction.attributes, + ...(attributesOverrides && { ...attributesOverrides }), + }, + references: userAction.references, + }; +}; + +describe('CaseUserActionService', () => { + describe('transformFindResponseToExternalModel', () => { + it('does not populate the ids when the response is an empty array', () => { + expect(transformFindResponseToExternalModel(createSOFindResponse([]))).toMatchInlineSnapshot(` + Object { + "page": 1, + "per_page": 0, + "saved_objects": Array [], + "total": 0, + } + `); + }); + + it('preserves the saved object fields and attributes when inject the ids', () => { + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(createConnectorUserAction())]) + ); + + expect(transformed).toMatchInlineSnapshot(` + Object { + "page": 1, + "per_page": 1, + "saved_objects": Array [ + Object { + "attributes": Object { + "action": "create", + "action_at": "abc", + "action_by": Object { + "email": "a", + "full_name": "abc", + "username": "b", + }, + "action_field": Array [ + "connector", + ], + "action_id": "100", + "case_id": "1", + "comment_id": null, + "new_val_connector_id": "1", + "new_value": "{\\"connector\\":{\\"name\\":\\".jira\\",\\"type\\":\\".jira\\",\\"fields\\":{\\"issueType\\":\\"bug\\",\\"priority\\":\\"high\\",\\"parent\\":\\"2\\"}}}", + "old_val_connector_id": null, + "old_value": null, + "owner": "securitySolution", + "sub_case_id": "", + }, + "id": "100", + "references": Array [ + Object { + "id": "1", + "name": "associated-cases", + "type": "cases", + }, + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ], + "score": 0, + "type": "cases-user-actions", + }, + ], + "total": 1, + } + `); + }); + + it('populates the new_val_connector_id for multiple user actions', () => { + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([ + createUserActionFindSO(createConnectorUserAction()), + createUserActionFindSO(createConnectorUserAction()), + ]) + ); + + expect(transformed.saved_objects[0].attributes.new_val_connector_id).toEqual('1'); + expect(transformed.saved_objects[1].attributes.new_val_connector_id).toEqual('1'); + }); + + it('populates the old_val_connector_id for multiple user actions', () => { + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([ + createUserActionFindSO( + createUserActionSO({ + action: 'create', + fields: ['connector'], + oldValue: createConnectorObject(), + }) + ), + createUserActionFindSO( + createUserActionSO({ + action: 'create', + fields: ['connector'], + oldValue: createConnectorObject({ id: '10' }), + }) + ), + ]) + ); + + expect(transformed.saved_objects[0].attributes.old_val_connector_id).toEqual('1'); + expect(transformed.saved_objects[1].attributes.old_val_connector_id).toEqual('10'); + }); + + describe('reference ids', () => { + it('sets case_id to an empty string when it cannot find the reference', () => { + const userAction = { + ...createConnectorUserAction(), + references: [], + }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.case_id).toEqual(''); + }); + + it('sets comment_id to null when it cannot find the reference', () => { + const userAction = { + ...createUserActionSO({ action: 'create', fields: ['connector'], commentId: '5' }), + references: [], + }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.comment_id).toBeNull(); + }); + + it('sets sub_case_id to an empty string when it cannot find the reference', () => { + const userAction = { + ...createUserActionSO({ action: 'create', fields: ['connector'], subCaseId: '5' }), + references: [], + }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.comment_id).toBeNull(); + }); + + it('sets case_id correctly when it finds the reference', () => { + const userAction = createConnectorUserAction(); + + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.case_id).toEqual('1'); + }); + + it('sets comment_id correctly when it finds the reference', () => { + const userAction = createUserActionSO({ + action: 'create', + fields: ['connector'], + commentId: '5', + }); + + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.comment_id).toEqual('5'); + }); + + it('sets sub_case_id correctly when it finds the reference', () => { + const userAction = { + ...createUserActionSO({ action: 'create', fields: ['connector'], subCaseId: '5' }), + }; + + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.sub_case_id).toEqual('5'); + }); + + it('sets action_id correctly to the saved object id', () => { + const userAction = { + ...createUserActionSO({ action: 'create', fields: ['connector'], subCaseId: '5' }), + }; + + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.action_id).toEqual('100'); + }); + }); + + describe('create connector', () => { + it('does not populate the new_val_connector_id when it cannot find the reference', () => { + const userAction = { ...createConnectorUserAction(), references: [] }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.new_val_connector_id).toBeNull(); + }); + + it('does not populate the old_val_connector_id when it cannot find the reference', () => { + const userAction = { ...createConnectorUserAction(), references: [] }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.old_val_connector_id).toBeNull(); + }); + + it('does not populate the new_val_connector_id when the reference exists but the action and fields are invalid', () => { + const validUserAction = createConnectorUserAction(); + const invalidUserAction = { + ...validUserAction, + attributes: { ...validUserAction.attributes, action: 'invalid' }, + }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([ + createUserActionFindSO(invalidUserAction as SavedObject), + ]) + ); + + expect(transformed.saved_objects[0].attributes.new_val_connector_id).toBeNull(); + }); + + it('does not populate the old_val_connector_id when the reference exists but the action and fields are invalid', () => { + const validUserAction = createUserActionSO({ + action: 'create', + fields: ['connector'], + oldValue: createConnectorObject(), + }); + + const invalidUserAction = { + ...validUserAction, + attributes: { ...validUserAction.attributes, action: 'invalid' }, + }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([ + createUserActionFindSO(invalidUserAction as SavedObject), + ]) + ); + + expect(transformed.saved_objects[0].attributes.old_val_connector_id).toBeNull(); + }); + + it('populates the new_val_connector_id', () => { + const userAction = createConnectorUserAction(); + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.new_val_connector_id).toEqual('1'); + }); + + it('populates the old_val_connector_id', () => { + const userAction = createUserActionSO({ + action: 'create', + fields: ['connector'], + oldValue: createConnectorObject(), + }); + + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.old_val_connector_id).toEqual('1'); + }); + }); + + describe('update connector', () => { + it('does not populate the new_val_connector_id when it cannot find the reference', () => { + const userAction = { ...updateConnectorUserAction(), references: [] }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.new_val_connector_id).toBeNull(); + }); + + it('does not populate the old_val_connector_id when it cannot find the reference', () => { + const userAction = { + ...updateConnectorUserAction({ oldValue: createJiraConnector() }), + references: [], + }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.old_val_connector_id).toBeNull(); + }); + + it('does not populate the new_val_connector_id when the reference exists but the action and fields are invalid', () => { + const validUserAction = updateConnectorUserAction(); + const invalidUserAction = { + ...validUserAction, + attributes: { ...validUserAction.attributes, action: 'invalid' }, + }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([ + createUserActionFindSO(invalidUserAction as SavedObject), + ]) + ); + + expect(transformed.saved_objects[0].attributes.new_val_connector_id).toBeNull(); + }); + + it('does not populate the old_val_connector_id when the reference exists but the action and fields are invalid', () => { + const validUserAction = updateConnectorUserAction({ oldValue: createJiraConnector() }); + + const invalidUserAction = { + ...validUserAction, + attributes: { ...validUserAction.attributes, action: 'invalid' }, + }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([ + createUserActionFindSO(invalidUserAction as SavedObject), + ]) + ); + + expect(transformed.saved_objects[0].attributes.old_val_connector_id).toBeNull(); + }); + + it('populates the new_val_connector_id', () => { + const userAction = updateConnectorUserAction(); + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.new_val_connector_id).toEqual('1'); + }); + + it('populates the old_val_connector_id', () => { + const userAction = updateConnectorUserAction({ oldValue: createJiraConnector() }); + + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.old_val_connector_id).toEqual('1'); + }); + }); + + describe('push connector', () => { + it('does not populate the new_val_connector_id when it cannot find the reference', () => { + const userAction = { ...pushConnectorUserAction(), references: [] }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.new_val_connector_id).toBeNull(); + }); + + it('does not populate the old_val_connector_id when it cannot find the reference', () => { + const userAction = { + ...pushConnectorUserAction({ oldValue: createExternalService() }), + references: [], + }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.old_val_connector_id).toBeNull(); + }); + + it('does not populate the new_val_connector_id when the reference exists but the action and fields are invalid', () => { + const validUserAction = pushConnectorUserAction(); + const invalidUserAction = { + ...validUserAction, + attributes: { ...validUserAction.attributes, action: 'invalid' }, + }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([ + createUserActionFindSO(invalidUserAction as SavedObject), + ]) + ); + + expect(transformed.saved_objects[0].attributes.new_val_connector_id).toBeNull(); + }); + + it('does not populate the old_val_connector_id when the reference exists but the action and fields are invalid', () => { + const validUserAction = pushConnectorUserAction({ oldValue: createExternalService() }); + + const invalidUserAction = { + ...validUserAction, + attributes: { ...validUserAction.attributes, action: 'invalid' }, + }; + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([ + createUserActionFindSO(invalidUserAction as SavedObject), + ]) + ); + + expect(transformed.saved_objects[0].attributes.old_val_connector_id).toBeNull(); + }); + + it('populates the new_val_connector_id', () => { + const userAction = pushConnectorUserAction(); + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.new_val_connector_id).toEqual('100'); + }); + + it('populates the old_val_connector_id', () => { + const userAction = pushConnectorUserAction({ oldValue: createExternalService() }); + + const transformed = transformFindResponseToExternalModel( + createSOFindResponse([createUserActionFindSO(userAction)]) + ); + + expect(transformed.saved_objects[0].attributes.old_val_connector_id).toEqual('100'); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/services/user_actions/index.ts b/x-pack/plugins/cases/server/services/user_actions/index.ts index b70244816555..4f158862e3d6 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { Logger, SavedObjectReference } from 'kibana/server'; +import { + Logger, + SavedObjectReference, + SavedObjectsFindResponse, + SavedObjectsFindResult, +} from 'kibana/server'; import { CASE_SAVED_OBJECT, @@ -13,8 +18,17 @@ import { CaseUserActionAttributes, MAX_DOCS_PER_PAGE, SUB_CASE_SAVED_OBJECT, + CaseUserActionResponse, + CASE_COMMENT_SAVED_OBJECT, + isCreateConnector, + isPush, + isUpdateConnector, } from '../../../common'; import { ClientArgs } from '..'; +import { UserActionFieldType } from './types'; +import { CASE_REF_NAME, COMMENT_REF_NAME, SUB_CASE_REF_NAME } from '../../common'; +import { ConnectorIdReferenceName, PushConnectorIdReferenceName } from './transform'; +import { findConnectorIdReference } from '../transform'; interface GetCaseUserActionArgs extends ClientArgs { caseId: string; @@ -33,12 +47,16 @@ interface PostCaseUserActionArgs extends ClientArgs { export class CaseUserActionService { constructor(private readonly log: Logger) {} - public async getAll({ unsecuredSavedObjectsClient, caseId, subCaseId }: GetCaseUserActionArgs) { + public async getAll({ + unsecuredSavedObjectsClient, + caseId, + subCaseId, + }: GetCaseUserActionArgs): Promise> { try { const id = subCaseId ?? caseId; const type = subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; - return await unsecuredSavedObjectsClient.find({ + const userActions = await unsecuredSavedObjectsClient.find({ type: CASE_USER_ACTION_SAVED_OBJECT, hasReference: { type, id }, page: 1, @@ -46,17 +64,22 @@ export class CaseUserActionService { sortField: 'action_at', sortOrder: 'asc', }); + + return transformFindResponseToExternalModel(userActions); } catch (error) { this.log.error(`Error on GET case user action case id: ${caseId}: ${error}`); throw error; } } - public async bulkCreate({ unsecuredSavedObjectsClient, actions }: PostCaseUserActionArgs) { + public async bulkCreate({ + unsecuredSavedObjectsClient, + actions, + }: PostCaseUserActionArgs): Promise { try { this.log.debug(`Attempting to POST a new case user action`); - return await unsecuredSavedObjectsClient.bulkCreate( + await unsecuredSavedObjectsClient.bulkCreate( actions.map((action) => ({ type: CASE_USER_ACTION_SAVED_OBJECT, ...action })) ); } catch (error) { @@ -65,3 +88,71 @@ export class CaseUserActionService { } } } + +export function transformFindResponseToExternalModel( + userActions: SavedObjectsFindResponse +): SavedObjectsFindResponse { + return { + ...userActions, + saved_objects: userActions.saved_objects.map((so) => ({ + ...so, + ...transformToExternalModel(so), + })), + }; +} + +function transformToExternalModel( + userAction: SavedObjectsFindResult +): SavedObjectsFindResult { + const { references } = userAction; + + const newValueConnectorId = getConnectorIdFromReferences(UserActionFieldType.New, userAction); + const oldValueConnectorId = getConnectorIdFromReferences(UserActionFieldType.Old, userAction); + + const caseId = findReferenceId(CASE_REF_NAME, CASE_SAVED_OBJECT, references) ?? ''; + const commentId = + findReferenceId(COMMENT_REF_NAME, CASE_COMMENT_SAVED_OBJECT, references) ?? null; + const subCaseId = findReferenceId(SUB_CASE_REF_NAME, SUB_CASE_SAVED_OBJECT, references) ?? ''; + + return { + ...userAction, + attributes: { + ...userAction.attributes, + action_id: userAction.id, + case_id: caseId, + comment_id: commentId, + sub_case_id: subCaseId, + new_val_connector_id: newValueConnectorId, + old_val_connector_id: oldValueConnectorId, + }, + }; +} + +function getConnectorIdFromReferences( + fieldType: UserActionFieldType, + userAction: SavedObjectsFindResult +): string | null { + const { + // eslint-disable-next-line @typescript-eslint/naming-convention + attributes: { action, action_field }, + references, + } = userAction; + + if (isCreateConnector(action, action_field) || isUpdateConnector(action, action_field)) { + return findConnectorIdReference(ConnectorIdReferenceName[fieldType], references)?.id ?? null; + } else if (isPush(action, action_field)) { + return ( + findConnectorIdReference(PushConnectorIdReferenceName[fieldType], references)?.id ?? null + ); + } + + return null; +} + +function findReferenceId( + name: string, + type: string, + references: SavedObjectReference[] +): string | undefined { + return references.find((ref) => ref.name === name && ref.type === type)?.id; +} diff --git a/x-pack/plugins/cases/server/services/user_actions/transform.test.ts b/x-pack/plugins/cases/server/services/user_actions/transform.test.ts new file mode 100644 index 000000000000..2d2877061709 --- /dev/null +++ b/x-pack/plugins/cases/server/services/user_actions/transform.test.ts @@ -0,0 +1,1246 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { noneConnectorId } from '../../../common'; +import { + CONNECTOR_ID_REFERENCE_NAME, + getNoneCaseConnector, + PUSH_CONNECTOR_ID_REFERENCE_NAME, + USER_ACTION_OLD_ID_REF_NAME, + USER_ACTION_OLD_PUSH_ID_REF_NAME, +} from '../../common'; +import { createConnectorObject, createExternalService, createJiraConnector } from '../test_utils'; +import { + extractConnectorIdHelper, + extractConnectorIdFromJson, + extractConnectorId, + transformConnectorIdToReference, + transformPushConnectorIdToReference, +} from './transform'; +import { UserActionFieldType } from './types'; + +describe('user action transform utils', () => { + describe('transformConnectorIdToReference', () => { + it('returns the default none connector when the connector is undefined', () => { + expect(transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME).transformedConnector) + .toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + } + `); + }); + + it('returns the default none connector when the id is undefined', () => { + expect( + transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME, { id: undefined }) + .transformedConnector + ).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + } + `); + }); + + it('returns the default none connector when the id is none', () => { + expect( + transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME, { id: noneConnectorId }) + .transformedConnector + ).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + } + `); + }); + + it('returns the default none connector when the id is none and other fields are defined', () => { + expect( + transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME, { + ...createJiraConnector(), + id: noneConnectorId, + }).transformedConnector + ).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + } + `); + }); + + it('returns an empty array of references when the connector is undefined', () => { + expect(transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME).references.length).toBe( + 0 + ); + }); + + it('returns an empty array of references when the id is undefined', () => { + expect( + transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME, { id: undefined }).references + .length + ).toBe(0); + }); + + it('returns an empty array of references when the id is the none connector', () => { + expect( + transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME, { id: noneConnectorId }) + .references.length + ).toBe(0); + }); + + it('returns an empty array of references when the id is the none connector and other fields are defined', () => { + expect( + transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME, { + ...createJiraConnector(), + id: noneConnectorId, + }).references.length + ).toBe(0); + }); + + it('returns a jira connector', () => { + const transformedFields = transformConnectorIdToReference( + CONNECTOR_ID_REFERENCE_NAME, + createJiraConnector() + ); + expect(transformedFields.transformedConnector).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + }, + } + `); + expect(transformedFields.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('returns a jira connector with the user action reference name', () => { + const transformedFields = transformConnectorIdToReference( + USER_ACTION_OLD_ID_REF_NAME, + createJiraConnector() + ); + expect(transformedFields.transformedConnector).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + }, + } + `); + expect(transformedFields.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "oldConnectorId", + "type": "action", + }, + ] + `); + }); + }); + + describe('transformPushConnectorIdToReference', () => { + it('sets external_service to null when it is undefined', () => { + expect( + transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME) + .transformedPushConnector + ).toMatchInlineSnapshot(` + Object { + "external_service": null, + } + `); + }); + + it('sets external_service to null when it is null', () => { + expect( + transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, null) + .transformedPushConnector + ).toMatchInlineSnapshot(` + Object { + "external_service": null, + } + `); + }); + + it('returns an object when external_service is defined but connector_id is undefined', () => { + expect( + transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, { + connector_id: undefined, + }).transformedPushConnector + ).toMatchInlineSnapshot(` + Object { + "external_service": Object {}, + } + `); + }); + + it('returns an object when external_service is defined but connector_id is null', () => { + expect( + transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, { + connector_id: null, + }).transformedPushConnector + ).toMatchInlineSnapshot(` + Object { + "external_service": Object {}, + } + `); + }); + + it('returns an object when external_service is defined but connector_id is none', () => { + const otherFields = { otherField: 'hi' }; + + expect( + transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, { + ...otherFields, + connector_id: noneConnectorId, + }).transformedPushConnector + ).toMatchInlineSnapshot(` + Object { + "external_service": Object { + "otherField": "hi", + }, + } + `); + }); + + it('returns an empty array of references when the external_service is undefined', () => { + expect( + transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME).references.length + ).toBe(0); + }); + + it('returns an empty array of references when the external_service is null', () => { + expect( + transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, null).references + .length + ).toBe(0); + }); + + it('returns an empty array of references when the connector_id is undefined', () => { + expect( + transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, { + connector_id: undefined, + }).references.length + ).toBe(0); + }); + + it('returns an empty array of references when the connector_id is null', () => { + expect( + transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, { + connector_id: undefined, + }).references.length + ).toBe(0); + }); + + it('returns an empty array of references when the connector_id is the none connector', () => { + expect( + transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, { + connector_id: noneConnectorId, + }).references.length + ).toBe(0); + }); + + it('returns an empty array of references when the connector_id is the none connector and other fields are defined', () => { + expect( + transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, { + ...createExternalService(), + connector_id: noneConnectorId, + }).references.length + ).toBe(0); + }); + + it('returns the external_service connector', () => { + const transformedFields = transformPushConnectorIdToReference( + PUSH_CONNECTOR_ID_REFERENCE_NAME, + createExternalService() + ); + expect(transformedFields.transformedPushConnector).toMatchInlineSnapshot(` + Object { + "external_service": Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + } + `); + expect(transformedFields.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('returns the external_service connector with a user actions reference name', () => { + const transformedFields = transformPushConnectorIdToReference( + USER_ACTION_OLD_PUSH_ID_REF_NAME, + createExternalService() + ); + + expect(transformedFields.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "oldPushConnectorId", + "type": "action", + }, + ] + `); + }); + }); + + describe('extractConnectorIdHelper', () => { + it('throws an error when action details has a circular reference', () => { + const circularRef = { prop: {} }; + circularRef.prop = circularRef; + + expect(() => { + extractConnectorIdHelper({ + action: 'a', + actionFields: [], + actionDetails: circularRef, + fieldType: UserActionFieldType.New, + }); + }).toThrow(); + }); + + describe('create action', () => { + it('returns no references and untransformed json when actionDetails is not a valid connector', () => { + expect( + extractConnectorIdHelper({ + action: 'create', + actionFields: ['connector'], + actionDetails: { a: 'hello' }, + fieldType: UserActionFieldType.New, + }) + ).toMatchInlineSnapshot(` + Object { + "references": Array [], + "transformedActionDetails": "{\\"a\\":\\"hello\\"}", + } + `); + }); + + it('returns no references and untransformed json when the action is create and action fields does not contain connector', () => { + expect( + extractConnectorIdHelper({ + action: 'create', + actionFields: ['', 'something', 'onnector'], + actionDetails: { a: 'hello' }, + fieldType: UserActionFieldType.New, + }) + ).toMatchInlineSnapshot(` + Object { + "references": Array [], + "transformedActionDetails": "{\\"a\\":\\"hello\\"}", + } + `); + }); + + it('returns the stringified json without the id', () => { + const jiraConnector = createConnectorObject(); + + const { transformedActionDetails } = extractConnectorIdHelper({ + action: 'create', + actionFields: ['connector'], + actionDetails: jiraConnector, + fieldType: UserActionFieldType.New, + }); + + expect(JSON.parse(transformedActionDetails)).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + }, + } + `); + }); + + it('removes the connector.id when the connector is none', () => { + const connector = { connector: getNoneCaseConnector() }; + + const { transformedActionDetails } = extractConnectorIdHelper({ + action: 'create', + actionFields: ['connector'], + actionDetails: connector, + fieldType: UserActionFieldType.New, + })!; + + const parsedJson = JSON.parse(transformedActionDetails); + + expect(parsedJson.connector).not.toHaveProperty('id'); + expect(parsedJson).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + } + `); + }); + + it('does not return a reference when the connector is none', () => { + const connector = { connector: getNoneCaseConnector() }; + + const { references } = extractConnectorIdHelper({ + action: 'create', + actionFields: ['connector'], + actionDetails: connector, + fieldType: UserActionFieldType.New, + })!; + + expect(references).toEqual([]); + }); + + it('returns a reference to the connector.id', () => { + const connector = createConnectorObject(); + + const { references } = extractConnectorIdHelper({ + action: 'create', + actionFields: ['connector'], + actionDetails: connector, + fieldType: UserActionFieldType.New, + })!; + + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('returns an old reference name to the connector.id', () => { + const connector = createConnectorObject(); + + const { references } = extractConnectorIdHelper({ + action: 'create', + actionFields: ['connector'], + actionDetails: connector, + fieldType: UserActionFieldType.Old, + })!; + + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "oldConnectorId", + "type": "action", + }, + ] + `); + }); + + it('returns the transformed connector and the description', () => { + const details = { ...createConnectorObject(), description: 'a description' }; + + const { transformedActionDetails } = extractConnectorIdHelper({ + action: 'create', + actionFields: ['connector'], + actionDetails: details, + fieldType: UserActionFieldType.Old, + })!; + + const parsedJson = JSON.parse(transformedActionDetails); + + expect(parsedJson).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + }, + "description": "a description", + } + `); + }); + }); + + describe('update action', () => { + it('returns no references and untransformed json when actionDetails is not a valid connector', () => { + expect( + extractConnectorIdHelper({ + action: 'update', + actionFields: ['connector'], + actionDetails: { a: 'hello' }, + fieldType: UserActionFieldType.New, + }) + ).toMatchInlineSnapshot(` + Object { + "references": Array [], + "transformedActionDetails": "{\\"a\\":\\"hello\\"}", + } + `); + }); + + it('returns no references and untransformed json when the action is update and action fields does not contain connector', () => { + expect( + extractConnectorIdHelper({ + action: 'update', + actionFields: ['', 'something', 'onnector'], + actionDetails: 5, + fieldType: UserActionFieldType.New, + }) + ).toMatchInlineSnapshot(` + Object { + "references": Array [], + "transformedActionDetails": "5", + } + `); + }); + + it('returns the stringified json without the id', () => { + const jiraConnector = createJiraConnector(); + + const { transformedActionDetails } = extractConnectorIdHelper({ + action: 'update', + actionFields: ['connector'], + actionDetails: jiraConnector, + fieldType: UserActionFieldType.New, + }); + + const transformedConnetor = JSON.parse(transformedActionDetails!); + expect(transformedConnetor).not.toHaveProperty('id'); + expect(transformedConnetor).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('returns the stringified json without the id when the connector is none', () => { + const connector = getNoneCaseConnector(); + + const { transformedActionDetails } = extractConnectorIdHelper({ + action: 'update', + actionFields: ['connector'], + actionDetails: connector, + fieldType: UserActionFieldType.New, + }); + + const transformedConnetor = JSON.parse(transformedActionDetails); + expect(transformedConnetor).not.toHaveProperty('id'); + expect(transformedConnetor).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "none", + "type": ".none", + } + `); + }); + + it('returns a reference to the connector.id', () => { + const jiraConnector = createJiraConnector(); + + const { references } = extractConnectorIdHelper({ + action: 'update', + actionFields: ['connector'], + actionDetails: jiraConnector, + fieldType: UserActionFieldType.New, + })!; + + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('does not return a reference when the connector is none', () => { + const connector = getNoneCaseConnector(); + + const { references } = extractConnectorIdHelper({ + action: 'update', + actionFields: ['connector'], + actionDetails: connector, + fieldType: UserActionFieldType.New, + })!; + + expect(references).toEqual([]); + }); + + it('returns an old reference name to the connector.id', () => { + const jiraConnector = createJiraConnector(); + + const { references } = extractConnectorIdHelper({ + action: 'update', + actionFields: ['connector'], + actionDetails: jiraConnector, + fieldType: UserActionFieldType.Old, + })!; + + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "oldConnectorId", + "type": "action", + }, + ] + `); + }); + }); + + describe('push action', () => { + it('returns no references and untransformed json when actionDetails is not a valid external_service', () => { + expect( + extractConnectorIdHelper({ + action: 'push-to-service', + actionFields: ['pushed'], + actionDetails: { a: 'hello' }, + fieldType: UserActionFieldType.New, + }) + ).toMatchInlineSnapshot(` + Object { + "references": Array [], + "transformedActionDetails": "{\\"a\\":\\"hello\\"}", + } + `); + }); + + it('returns no references and untransformed json when the action is push-to-service and action fields does not contain pushed', () => { + expect( + extractConnectorIdHelper({ + action: 'push-to-service', + actionFields: ['', 'something', 'ushed'], + actionDetails: { a: 'hello' }, + fieldType: UserActionFieldType.New, + }) + ).toMatchInlineSnapshot(` + Object { + "references": Array [], + "transformedActionDetails": "{\\"a\\":\\"hello\\"}", + } + `); + }); + + it('returns the stringified json without the connector_id', () => { + const externalService = createExternalService(); + + const { transformedActionDetails } = extractConnectorIdHelper({ + action: 'push-to-service', + actionFields: ['pushed'], + actionDetails: externalService, + fieldType: UserActionFieldType.New, + }); + + const transformedExternalService = JSON.parse(transformedActionDetails); + expect(transformedExternalService).not.toHaveProperty('connector_id'); + expect(transformedExternalService).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('returns a reference to the connector_id', () => { + const externalService = createExternalService(); + + const { references } = extractConnectorIdHelper({ + action: 'push-to-service', + actionFields: ['pushed'], + actionDetails: externalService, + fieldType: UserActionFieldType.New, + })!; + + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('returns an old reference name to the connector_id', () => { + const externalService = createExternalService(); + + const { references } = extractConnectorIdHelper({ + action: 'push-to-service', + actionFields: ['pushed'], + actionDetails: externalService, + fieldType: UserActionFieldType.Old, + })!; + + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "oldPushConnectorId", + "type": "action", + }, + ] + `); + }); + }); + }); + + describe('extractConnectorId', () => { + it('returns null when the action details has a circular reference', () => { + const circularRef = { prop: {} }; + circularRef.prop = circularRef; + + const { transformedActionDetails, references } = extractConnectorId({ + action: 'a', + actionFields: ['a'], + actionDetails: circularRef, + fieldType: UserActionFieldType.New, + }); + + expect(transformedActionDetails).toBeNull(); + expect(references).toEqual([]); + }); + + describe('fails to extract the id', () => { + it('returns a null transformed action details when it is initially null', () => { + const { transformedActionDetails, references } = extractConnectorId({ + action: 'a', + actionFields: ['a'], + actionDetails: null, + fieldType: UserActionFieldType.New, + }); + + expect(transformedActionDetails).toBeNull(); + expect(references).toEqual([]); + }); + + it('returns an undefined transformed action details when it is initially undefined', () => { + const { transformedActionDetails, references } = extractConnectorId({ + action: 'a', + actionFields: ['a'], + actionDetails: undefined, + fieldType: UserActionFieldType.New, + }); + + expect(transformedActionDetails).toBeUndefined(); + expect(references).toEqual([]); + }); + + it('returns a json encoded string and empty references when the action is not a valid connector', () => { + const { transformedActionDetails, references } = extractConnectorId({ + action: 'a', + actionFields: ['a'], + actionDetails: { a: 'hello' }, + fieldType: UserActionFieldType.New, + }); + + expect(JSON.parse(transformedActionDetails!)).toEqual({ a: 'hello' }); + expect(references).toEqual([]); + }); + + it('returns a json encoded string and empty references when the action details is an invalid object', () => { + const { transformedActionDetails, references } = extractConnectorId({ + action: 'a', + actionFields: ['a'], + actionDetails: 5 as unknown as Record, + fieldType: UserActionFieldType.New, + }); + + expect(transformedActionDetails!).toEqual('5'); + expect(references).toEqual([]); + }); + }); + + describe('create', () => { + it('extracts the connector.id from a new create jira connector to the references', () => { + const { transformedActionDetails, references } = extractConnectorId({ + action: 'create', + actionFields: ['connector'], + actionDetails: createConnectorObject(), + fieldType: UserActionFieldType.New, + }); + + const parsedJson = JSON.parse(transformedActionDetails!); + + expect(parsedJson).not.toHaveProperty('id'); + expect(parsedJson).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + }, + } + `); + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('extracts the connector.id from an old create jira connector to the references', () => { + const { transformedActionDetails, references } = extractConnectorId({ + action: 'create', + actionFields: ['connector'], + actionDetails: createConnectorObject(), + fieldType: UserActionFieldType.Old, + }); + + const parsedJson = JSON.parse(transformedActionDetails!); + + expect(parsedJson).not.toHaveProperty('id'); + expect(parsedJson).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + }, + } + `); + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "oldConnectorId", + "type": "action", + }, + ] + `); + }); + }); + + describe('update', () => { + it('extracts the connector.id from a new create jira connector to the references', () => { + const { transformedActionDetails, references } = extractConnectorId({ + action: 'update', + actionFields: ['connector'], + actionDetails: createJiraConnector(), + fieldType: UserActionFieldType.New, + }); + + const parsedJson = JSON.parse(transformedActionDetails!); + + expect(parsedJson).not.toHaveProperty('id'); + expect(parsedJson).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + } + `); + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('extracts the connector.id from an old create jira connector to the references', () => { + const { transformedActionDetails, references } = extractConnectorId({ + action: 'update', + actionFields: ['connector'], + actionDetails: createJiraConnector(), + fieldType: UserActionFieldType.Old, + }); + + const parsedJson = JSON.parse(transformedActionDetails!); + + expect(parsedJson).not.toHaveProperty('id'); + expect(parsedJson).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + } + `); + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "oldConnectorId", + "type": "action", + }, + ] + `); + }); + }); + + describe('push action', () => { + it('returns the stringified json without the connector_id', () => { + const externalService = createExternalService(); + + const { transformedActionDetails } = extractConnectorId({ + action: 'push-to-service', + actionFields: ['pushed'], + actionDetails: externalService, + fieldType: UserActionFieldType.New, + }); + + const transformedExternalService = JSON.parse(transformedActionDetails!); + expect(transformedExternalService).not.toHaveProperty('connector_id'); + expect(transformedExternalService).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('returns a reference to the connector_id', () => { + const externalService = createExternalService(); + + const { references } = extractConnectorId({ + action: 'push-to-service', + actionFields: ['pushed'], + actionDetails: externalService, + fieldType: UserActionFieldType.New, + })!; + + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('returns a reference to the old action details connector_id', () => { + const externalService = createExternalService(); + + const { references } = extractConnectorId({ + action: 'push-to-service', + actionFields: ['pushed'], + actionDetails: externalService, + fieldType: UserActionFieldType.Old, + })!; + + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "oldPushConnectorId", + "type": "action", + }, + ] + `); + }); + }); + }); + + describe('extractConnectorIdFromJson', () => { + describe('fails to extract the id', () => { + it('returns no references and null transformed json when action is undefined', () => { + expect( + extractConnectorIdFromJson({ + actionFields: [], + actionDetails: undefined, + fieldType: UserActionFieldType.New, + }) + ).toEqual({ + transformedActionDetails: undefined, + references: [], + }); + }); + + it('returns no references and undefined transformed json when actionFields is undefined', () => { + expect( + extractConnectorIdFromJson({ action: 'a', fieldType: UserActionFieldType.New }) + ).toEqual({ + transformedActionDetails: undefined, + references: [], + }); + }); + + it('returns no references and undefined transformed json when actionDetails is undefined', () => { + expect( + extractConnectorIdFromJson({ + action: 'a', + actionFields: [], + fieldType: UserActionFieldType.New, + }) + ).toEqual({ + transformedActionDetails: undefined, + references: [], + }); + }); + + it('returns no references and undefined transformed json when actionDetails is null', () => { + expect( + extractConnectorIdFromJson({ + action: 'a', + actionFields: [], + actionDetails: null, + fieldType: UserActionFieldType.New, + }) + ).toEqual({ + transformedActionDetails: null, + references: [], + }); + }); + + it('throws an error when actionDetails is invalid json', () => { + expect(() => + extractConnectorIdFromJson({ + action: 'a', + actionFields: [], + actionDetails: '{a', + fieldType: UserActionFieldType.New, + }) + ).toThrow(); + }); + }); + + describe('create action', () => { + it('returns the stringified json without the id', () => { + const jiraConnector = createConnectorObject(); + + const { transformedActionDetails } = extractConnectorIdFromJson({ + action: 'create', + actionFields: ['connector'], + actionDetails: JSON.stringify(jiraConnector), + fieldType: UserActionFieldType.New, + }); + + expect(JSON.parse(transformedActionDetails!)).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + }, + } + `); + }); + + it('returns a reference to the connector.id', () => { + const jiraConnector = createConnectorObject(); + + const { references } = extractConnectorIdFromJson({ + action: 'create', + actionFields: ['connector'], + actionDetails: JSON.stringify(jiraConnector), + fieldType: UserActionFieldType.New, + })!; + + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + }); + + describe('update action', () => { + it('returns the stringified json without the id', () => { + const jiraConnector = createJiraConnector(); + + const { transformedActionDetails } = extractConnectorIdFromJson({ + action: 'update', + actionFields: ['connector'], + actionDetails: JSON.stringify(jiraConnector), + fieldType: UserActionFieldType.New, + }); + + const transformedConnetor = JSON.parse(transformedActionDetails!); + expect(transformedConnetor).not.toHaveProperty('id'); + expect(transformedConnetor).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('returns a reference to the connector.id', () => { + const jiraConnector = createJiraConnector(); + + const { references } = extractConnectorIdFromJson({ + action: 'update', + actionFields: ['connector'], + actionDetails: JSON.stringify(jiraConnector), + fieldType: UserActionFieldType.New, + })!; + + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + }); + + describe('push action', () => { + it('returns the stringified json without the connector_id', () => { + const externalService = createExternalService(); + + const { transformedActionDetails } = extractConnectorIdFromJson({ + action: 'push-to-service', + actionFields: ['pushed'], + actionDetails: JSON.stringify(externalService), + fieldType: UserActionFieldType.New, + }); + + const transformedExternalService = JSON.parse(transformedActionDetails!); + expect(transformedExternalService).not.toHaveProperty('connector_id'); + expect(transformedExternalService).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('returns a reference to the connector_id', () => { + const externalService = createExternalService(); + + const { references } = extractConnectorIdFromJson({ + action: 'push-to-service', + actionFields: ['pushed'], + actionDetails: JSON.stringify(externalService), + fieldType: UserActionFieldType.New, + })!; + + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/services/user_actions/transform.ts b/x-pack/plugins/cases/server/services/user_actions/transform.ts new file mode 100644 index 000000000000..93595374208a --- /dev/null +++ b/x-pack/plugins/cases/server/services/user_actions/transform.ts @@ -0,0 +1,320 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import * as rt from 'io-ts'; +import { isString } from 'lodash'; + +import { SavedObjectReference } from '../../../../../../src/core/server'; +import { + CaseAttributes, + CaseConnector, + CaseConnectorRt, + CaseExternalServiceBasicRt, + isCreateConnector, + isPush, + isUpdateConnector, + noneConnectorId, +} from '../../../common'; +import { + CONNECTOR_ID_REFERENCE_NAME, + getNoneCaseConnector, + PUSH_CONNECTOR_ID_REFERENCE_NAME, + USER_ACTION_OLD_ID_REF_NAME, + USER_ACTION_OLD_PUSH_ID_REF_NAME, +} from '../../common'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { UserActionFieldType } from './types'; + +/** + * Extracts the connector id from a json encoded string and formats it as a saved object reference. This will remove + * the field it extracted the connector id from. + */ +export function extractConnectorIdFromJson({ + action, + actionFields, + actionDetails, + fieldType, +}: { + action?: string; + actionFields?: string[]; + actionDetails?: string | null; + fieldType: UserActionFieldType; +}): { transformedActionDetails?: string | null; references: SavedObjectReference[] } { + if (!action || !actionFields || !actionDetails) { + return { transformedActionDetails: actionDetails, references: [] }; + } + + const decodedJson = JSON.parse(actionDetails); + + return extractConnectorIdHelper({ + action, + actionFields, + actionDetails: decodedJson, + fieldType, + }); +} + +/** + * Extracts the connector id from an unencoded object and formats it as a saved object reference. + * This will remove the field it extracted the connector id from. + */ +export function extractConnectorId({ + action, + actionFields, + actionDetails, + fieldType, +}: { + action: string; + actionFields: string[]; + actionDetails?: Record | string | null; + fieldType: UserActionFieldType; +}): { + transformedActionDetails?: string | null; + references: SavedObjectReference[]; +} { + if (!actionDetails || isString(actionDetails)) { + // the action was null, undefined, or a regular string so just return it unmodified and not encoded + return { transformedActionDetails: actionDetails, references: [] }; + } + + try { + return extractConnectorIdHelper({ + action, + actionFields, + actionDetails, + fieldType, + }); + } catch (error) { + return { transformedActionDetails: encodeActionDetails(actionDetails), references: [] }; + } +} + +function encodeActionDetails(actionDetails: Record): string | null { + try { + return JSON.stringify(actionDetails); + } catch (error) { + return null; + } +} + +/** + * Internal helper function for extracting the connector id. This is only exported for usage in unit tests. + * This function handles encoding the transformed fields as a json string + */ +export function extractConnectorIdHelper({ + action, + actionFields, + actionDetails, + fieldType, +}: { + action: string; + actionFields: string[]; + actionDetails: unknown; + fieldType: UserActionFieldType; +}): { transformedActionDetails: string; references: SavedObjectReference[] } { + let transformedActionDetails: unknown = actionDetails; + let referencesToReturn: SavedObjectReference[] = []; + + try { + if (isCreateCaseConnector(action, actionFields, actionDetails)) { + const { transformedActionDetails: transformedConnectorPortion, references } = + transformConnectorFromCreateAndUpdateAction(actionDetails.connector, fieldType); + + // the above call only transforms the connector portion of the action details so let's add back + // the rest of the details and we'll overwrite the connector portion when the transformed one + transformedActionDetails = { + ...actionDetails, + ...transformedConnectorPortion, + }; + referencesToReturn = references; + } else if (isUpdateCaseConnector(action, actionFields, actionDetails)) { + const { + transformedActionDetails: { connector: transformedConnector }, + references, + } = transformConnectorFromCreateAndUpdateAction(actionDetails, fieldType); + + transformedActionDetails = transformedConnector; + referencesToReturn = references; + } else if (isPushConnector(action, actionFields, actionDetails)) { + ({ transformedActionDetails, references: referencesToReturn } = + transformConnectorFromPushAction(actionDetails, fieldType)); + } + } catch (error) { + // ignore any errors, we'll just return whatever was passed in for action details in that case + } + + return { + transformedActionDetails: JSON.stringify(transformedActionDetails), + references: referencesToReturn, + }; +} + +function isCreateCaseConnector( + action: string, + actionFields: string[], + actionDetails: unknown +): actionDetails is { connector: CaseConnector } { + try { + const unsafeCase = actionDetails as CaseAttributes; + + return ( + isCreateConnector(action, actionFields) && + unsafeCase.connector !== undefined && + CaseConnectorRt.is(unsafeCase.connector) + ); + } catch { + return false; + } +} + +export const ConnectorIdReferenceName: Record = { + [UserActionFieldType.New]: CONNECTOR_ID_REFERENCE_NAME, + [UserActionFieldType.Old]: USER_ACTION_OLD_ID_REF_NAME, +}; + +function transformConnectorFromCreateAndUpdateAction( + connector: CaseConnector, + fieldType: UserActionFieldType +): { + transformedActionDetails: { connector: unknown }; + references: SavedObjectReference[]; +} { + const { transformedConnector, references } = transformConnectorIdToReference( + ConnectorIdReferenceName[fieldType], + connector + ); + + return { + transformedActionDetails: transformedConnector, + references, + }; +} + +type ConnectorIdRefNameType = + | typeof CONNECTOR_ID_REFERENCE_NAME + | typeof USER_ACTION_OLD_ID_REF_NAME; + +export const transformConnectorIdToReference = ( + referenceName: ConnectorIdRefNameType, + connector?: { + id?: string; + } +): { + transformedConnector: { connector: unknown }; + references: SavedObjectReference[]; +} => { + const { id: connectorId, ...restConnector } = connector ?? {}; + + const references = createConnectorReference(connectorId, ACTION_SAVED_OBJECT_TYPE, referenceName); + + const { id: ignoreNoneId, ...restNoneConnector } = getNoneCaseConnector(); + const connectorFieldsToReturn = + connector && isConnectorIdValid(connectorId) ? restConnector : restNoneConnector; + + return { + transformedConnector: { + connector: connectorFieldsToReturn, + }, + references, + }; +}; + +const createConnectorReference = ( + id: string | null | undefined, + type: string, + name: string +): SavedObjectReference[] => { + return isConnectorIdValid(id) + ? [ + { + id, + type, + name, + }, + ] + : []; +}; + +const isConnectorIdValid = (id: string | null | undefined): id is string => + id != null && id !== noneConnectorId; + +function isUpdateCaseConnector( + action: string, + actionFields: string[], + actionDetails: unknown +): actionDetails is CaseConnector { + try { + return isUpdateConnector(action, actionFields) && CaseConnectorRt.is(actionDetails); + } catch { + return false; + } +} + +type CaseExternalService = rt.TypeOf; + +function isPushConnector( + action: string, + actionFields: string[], + actionDetails: unknown +): actionDetails is CaseExternalService { + try { + return isPush(action, actionFields) && CaseExternalServiceBasicRt.is(actionDetails); + } catch { + return false; + } +} + +export const PushConnectorIdReferenceName: Record = + { + [UserActionFieldType.New]: PUSH_CONNECTOR_ID_REFERENCE_NAME, + [UserActionFieldType.Old]: USER_ACTION_OLD_PUSH_ID_REF_NAME, + }; + +function transformConnectorFromPushAction( + externalService: CaseExternalService, + fieldType: UserActionFieldType +): { + transformedActionDetails: {} | null; + references: SavedObjectReference[]; +} { + const { transformedPushConnector, references } = transformPushConnectorIdToReference( + PushConnectorIdReferenceName[fieldType], + externalService + ); + + return { + transformedActionDetails: transformedPushConnector.external_service, + references, + }; +} + +type PushConnectorIdRefNameType = + | typeof PUSH_CONNECTOR_ID_REFERENCE_NAME + | typeof USER_ACTION_OLD_PUSH_ID_REF_NAME; + +export const transformPushConnectorIdToReference = ( + referenceName: PushConnectorIdRefNameType, + external_service?: { connector_id?: string | null } | null +): { + transformedPushConnector: { external_service: {} | null }; + references: SavedObjectReference[]; +} => { + const { connector_id: pushConnectorId, ...restExternalService } = external_service ?? {}; + + const references = createConnectorReference( + pushConnectorId, + ACTION_SAVED_OBJECT_TYPE, + referenceName + ); + + return { + transformedPushConnector: { external_service: external_service ? restExternalService : null }, + references, + }; +}; diff --git a/x-pack/plugins/cases/server/services/user_actions/types.ts b/x-pack/plugins/cases/server/services/user_actions/types.ts new file mode 100644 index 000000000000..3c67535255ec --- /dev/null +++ b/x-pack/plugins/cases/server/services/user_actions/types.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Indicates whether which user action field is being parsed, the new_value or the old_value. + */ +export enum UserActionFieldType { + New = 'New', + Old = 'Old', +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts index 49a09b8a7a3f..5ff153c3beb6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts @@ -321,26 +321,25 @@ export const ResultSettingsLogic = kea { const fullInputs: FullAgentPolicyInput[] = []; @@ -32,7 +33,7 @@ export const storedPackagePoliciesToAgentInputs = ( data_stream: { namespace: packagePolicy.namespace || 'default', }, - use_output: DEFAULT_OUTPUT.name, + use_output: outputId, ...(input.compiled_input || {}), ...(input.streams.length ? { diff --git a/x-pack/plugins/fleet/common/types/index.ts b/x-pack/plugins/fleet/common/types/index.ts index 0deda3bf3265..bd970fc2cd83 100644 --- a/x-pack/plugins/fleet/common/types/index.ts +++ b/x-pack/plugins/fleet/common/types/index.ts @@ -8,7 +8,11 @@ export * from './models'; export * from './rest_spec'; -import type { PreconfiguredAgentPolicy, PreconfiguredPackage } from './models/preconfiguration'; +import type { + PreconfiguredAgentPolicy, + PreconfiguredPackage, + PreconfiguredOutput, +} from './models/preconfiguration'; export interface FleetConfigType { enabled: boolean; @@ -26,6 +30,7 @@ export interface FleetConfigType { }; agentPolicies?: PreconfiguredAgentPolicy[]; packages?: PreconfiguredPackage[]; + outputs?: PreconfiguredOutput[]; agentIdVerificationEnabled?: boolean; } diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts index f64467ca674f..3f9e43e72c51 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -23,6 +23,8 @@ export interface NewAgentPolicy { monitoring_enabled?: MonitoringType; unenroll_timeout?: number; is_preconfigured?: boolean; + data_output_id?: string; + monitoring_output_id?: string; } export interface AgentPolicy extends NewAgentPolicy { @@ -71,12 +73,14 @@ export interface FullAgentPolicyOutputPermissions { }; } +export type FullAgentPolicyOutput = Pick & { + [key: string]: any; +}; + export interface FullAgentPolicy { id: string; outputs: { - [key: string]: Pick & { - [key: string]: any; - }; + [key: string]: FullAgentPolicyOutput; }; output_permissions?: { [output: string]: FullAgentPolicyOutputPermissions; diff --git a/x-pack/plugins/fleet/common/types/models/output.ts b/x-pack/plugins/fleet/common/types/models/output.ts index c1dc2a4b4e05..4f70460e89ff 100644 --- a/x-pack/plugins/fleet/common/types/models/output.ts +++ b/x-pack/plugins/fleet/common/types/models/output.ts @@ -17,11 +17,13 @@ export interface NewOutput { hosts?: string[]; ca_sha256?: string; api_key?: string; - config?: Record; config_yaml?: string; + is_preconfigured?: boolean; } -export type OutputSOAttributes = NewOutput; +export type OutputSOAttributes = NewOutput & { + output_id?: string; +}; export type Output = NewOutput & { id: string; diff --git a/x-pack/plugins/fleet/common/types/models/preconfiguration.ts b/x-pack/plugins/fleet/common/types/models/preconfiguration.ts index 6087c910510c..17f9b946885b 100644 --- a/x-pack/plugins/fleet/common/types/models/preconfiguration.ts +++ b/x-pack/plugins/fleet/common/types/models/preconfiguration.ts @@ -11,6 +11,7 @@ import type { NewPackagePolicyInput, } from './package_policy'; import type { NewAgentPolicy } from './agent_policy'; +import type { Output } from './output'; export type InputsOverride = Partial & { vars?: Array; @@ -29,3 +30,7 @@ export interface PreconfiguredAgentPolicy extends Omit; + +export interface PreconfiguredOutput extends Omit { + config?: Record; +} diff --git a/x-pack/plugins/fleet/server/errors/utils.ts b/x-pack/plugins/fleet/server/errors/utils.ts index 2eae04e05bd6..d58f82b94fcd 100644 --- a/x-pack/plugins/fleet/server/errors/utils.ts +++ b/x-pack/plugins/fleet/server/errors/utils.ts @@ -11,6 +11,6 @@ export function isESClientError(error: unknown): error is ResponseError { return error instanceof ResponseError; } -export const isElasticsearchVersionConflictError = (error: Error): boolean => { +export function isElasticsearchVersionConflictError(error: Error): boolean { return isESClientError(error) && error.meta.statusCode === 409; -}; +} diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index 21cdf659f2f5..05ad8a9a9c83 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -9,7 +9,11 @@ import { schema } from '@kbn/config-schema'; import type { TypeOf } from '@kbn/config-schema'; import type { PluginConfigDescriptor, PluginInitializerContext } from 'src/core/server'; -import { PreconfiguredPackagesSchema, PreconfiguredAgentPoliciesSchema } from './types'; +import { + PreconfiguredPackagesSchema, + PreconfiguredAgentPoliciesSchema, + PreconfiguredOutputsSchema, +} from './types'; import { FleetPlugin } from './plugin'; @@ -113,6 +117,7 @@ export const config: PluginConfigDescriptor = { }), packages: PreconfiguredPackagesSchema, agentPolicies: PreconfiguredAgentPoliciesSchema, + outputs: PreconfiguredOutputsSchema, agentIdVerificationEnabled: schema.boolean({ defaultValue: true }), }), }; diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 5c117909432b..83188e004704 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -156,6 +156,8 @@ const getSavedObjectTypes = ( revision: { type: 'integer' }, monitoring_enabled: { type: 'keyword', index: false }, is_preconfigured: { type: 'keyword' }, + data_output_id: { type: 'keyword' }, + monitoring_output_id: { type: 'keyword' }, }, }, migrations: { @@ -196,6 +198,7 @@ const getSavedObjectTypes = ( }, mappings: { properties: { + output_id: { type: 'keyword', index: false }, name: { type: 'keyword' }, type: { type: 'keyword' }, is_default: { type: 'boolean' }, @@ -203,6 +206,7 @@ const getSavedObjectTypes = ( ca_sha256: { type: 'keyword', index: false }, config: { type: 'flattened' }, config_yaml: { type: 'text' }, + is_preconfigured: { type: 'boolean', index: false }, }, }, migrations: { diff --git a/x-pack/plugins/fleet/server/services/agent_policies/__snapshots__/full_agent_policy.test.ts.snap b/x-pack/plugins/fleet/server/services/agent_policies/__snapshots__/full_agent_policy.test.ts.snap new file mode 100644 index 000000000000..970bccbafa63 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agent_policies/__snapshots__/full_agent_policy.test.ts.snap @@ -0,0 +1,292 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getFullAgentPolicy should support a different data output 1`] = ` +Object { + "agent": Object { + "monitoring": Object { + "enabled": true, + "logs": false, + "metrics": true, + "namespace": "default", + "use_output": "default", + }, + }, + "fleet": Object { + "hosts": Array [ + "http://fleetserver:8220", + ], + }, + "id": "agent-policy", + "inputs": Array [], + "output_permissions": Object { + "data-output-id": Object { + "_elastic_agent_checks": Object { + "cluster": Array [ + "monitor", + ], + }, + "_fallback": Object { + "cluster": Array [ + "monitor", + ], + "indices": Array [ + Object { + "names": Array [ + "logs-*", + "metrics-*", + "traces-*", + "synthetics-*", + ".logs-endpoint.diagnostic.collection-*", + ], + "privileges": Array [ + "auto_configure", + "create_doc", + ], + }, + ], + }, + }, + "default": Object { + "_elastic_agent_checks": Object { + "cluster": Array [ + "monitor", + ], + "indices": Array [ + Object { + "names": Array [ + "metrics-elastic_agent-default", + "metrics-elastic_agent.elastic_agent-default", + "metrics-elastic_agent.apm_server-default", + "metrics-elastic_agent.filebeat-default", + "metrics-elastic_agent.fleet_server-default", + "metrics-elastic_agent.metricbeat-default", + "metrics-elastic_agent.osquerybeat-default", + "metrics-elastic_agent.packetbeat-default", + "metrics-elastic_agent.endpoint_security-default", + "metrics-elastic_agent.auditbeat-default", + "metrics-elastic_agent.heartbeat-default", + ], + "privileges": Array [ + "auto_configure", + "create_doc", + ], + }, + ], + }, + }, + }, + "outputs": Object { + "data-output-id": Object { + "api_key": undefined, + "ca_sha256": undefined, + "hosts": Array [ + "http://es-data.co:9201", + ], + "type": "elasticsearch", + }, + "default": Object { + "api_key": undefined, + "ca_sha256": undefined, + "hosts": Array [ + "http://127.0.0.1:9201", + ], + "type": "elasticsearch", + }, + }, + "revision": 1, +} +`; + +exports[`getFullAgentPolicy should support a different monitoring output 1`] = ` +Object { + "agent": Object { + "monitoring": Object { + "enabled": true, + "logs": false, + "metrics": true, + "namespace": "default", + "use_output": "monitoring-output-id", + }, + }, + "fleet": Object { + "hosts": Array [ + "http://fleetserver:8220", + ], + }, + "id": "agent-policy", + "inputs": Array [], + "output_permissions": Object { + "default": Object { + "_elastic_agent_checks": Object { + "cluster": Array [ + "monitor", + ], + }, + "_fallback": Object { + "cluster": Array [ + "monitor", + ], + "indices": Array [ + Object { + "names": Array [ + "logs-*", + "metrics-*", + "traces-*", + "synthetics-*", + ".logs-endpoint.diagnostic.collection-*", + ], + "privileges": Array [ + "auto_configure", + "create_doc", + ], + }, + ], + }, + }, + "monitoring-output-id": Object { + "_elastic_agent_checks": Object { + "cluster": Array [ + "monitor", + ], + "indices": Array [ + Object { + "names": Array [ + "metrics-elastic_agent-default", + "metrics-elastic_agent.elastic_agent-default", + "metrics-elastic_agent.apm_server-default", + "metrics-elastic_agent.filebeat-default", + "metrics-elastic_agent.fleet_server-default", + "metrics-elastic_agent.metricbeat-default", + "metrics-elastic_agent.osquerybeat-default", + "metrics-elastic_agent.packetbeat-default", + "metrics-elastic_agent.endpoint_security-default", + "metrics-elastic_agent.auditbeat-default", + "metrics-elastic_agent.heartbeat-default", + ], + "privileges": Array [ + "auto_configure", + "create_doc", + ], + }, + ], + }, + }, + }, + "outputs": Object { + "default": Object { + "api_key": undefined, + "ca_sha256": undefined, + "hosts": Array [ + "http://127.0.0.1:9201", + ], + "type": "elasticsearch", + }, + "monitoring-output-id": Object { + "api_key": undefined, + "ca_sha256": undefined, + "hosts": Array [ + "http://es-monitoring.co:9201", + ], + "type": "elasticsearch", + }, + }, + "revision": 1, +} +`; + +exports[`getFullAgentPolicy should support both different outputs for data and monitoring 1`] = ` +Object { + "agent": Object { + "monitoring": Object { + "enabled": true, + "logs": false, + "metrics": true, + "namespace": "default", + "use_output": "monitoring-output-id", + }, + }, + "fleet": Object { + "hosts": Array [ + "http://fleetserver:8220", + ], + }, + "id": "agent-policy", + "inputs": Array [], + "output_permissions": Object { + "data-output-id": Object { + "_elastic_agent_checks": Object { + "cluster": Array [ + "monitor", + ], + }, + "_fallback": Object { + "cluster": Array [ + "monitor", + ], + "indices": Array [ + Object { + "names": Array [ + "logs-*", + "metrics-*", + "traces-*", + "synthetics-*", + ".logs-endpoint.diagnostic.collection-*", + ], + "privileges": Array [ + "auto_configure", + "create_doc", + ], + }, + ], + }, + }, + "monitoring-output-id": Object { + "_elastic_agent_checks": Object { + "cluster": Array [ + "monitor", + ], + "indices": Array [ + Object { + "names": Array [ + "metrics-elastic_agent-default", + "metrics-elastic_agent.elastic_agent-default", + "metrics-elastic_agent.apm_server-default", + "metrics-elastic_agent.filebeat-default", + "metrics-elastic_agent.fleet_server-default", + "metrics-elastic_agent.metricbeat-default", + "metrics-elastic_agent.osquerybeat-default", + "metrics-elastic_agent.packetbeat-default", + "metrics-elastic_agent.endpoint_security-default", + "metrics-elastic_agent.auditbeat-default", + "metrics-elastic_agent.heartbeat-default", + ], + "privileges": Array [ + "auto_configure", + "create_doc", + ], + }, + ], + }, + }, + }, + "outputs": Object { + "data-output-id": Object { + "api_key": undefined, + "ca_sha256": undefined, + "hosts": Array [ + "http://es-data.co:9201", + ], + "type": "elasticsearch", + }, + "monitoring-output-id": Object { + "api_key": undefined, + "ca_sha256": undefined, + "hosts": Array [ + "http://es-monitoring.co:9201", + ], + "type": "elasticsearch", + }, + }, + "revision": 1, +} +`; diff --git a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts new file mode 100644 index 000000000000..8df1234982ee --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { savedObjectsClientMock } from 'src/core/server/mocks'; + +import type { AgentPolicy, Output } from '../../types'; + +import { agentPolicyService } from '../agent_policy'; +import { agentPolicyUpdateEventHandler } from '../agent_policy_update'; + +import { getFullAgentPolicy } from './full_agent_policy'; + +const mockedAgentPolicyService = agentPolicyService as jest.Mocked; + +function mockAgentPolicy(data: Partial) { + mockedAgentPolicyService.get.mockResolvedValue({ + id: 'agent-policy', + status: 'active', + package_policies: [], + is_managed: false, + namespace: 'default', + revision: 1, + name: 'Policy', + updated_at: '2020-01-01', + updated_by: 'qwerty', + ...data, + }); +} + +jest.mock('../settings', () => { + return { + getSettings: () => { + return { + id: '93f74c0-e876-11ea-b7d3-8b2acec6f75c', + fleet_server_hosts: ['http://fleetserver:8220'], + }; + }, + }; +}); + +jest.mock('../agent_policy'); + +jest.mock('../output', () => { + return { + outputService: { + getDefaultOutputId: () => 'test-id', + get: (soClient: any, id: string): Output => { + switch (id) { + case 'data-output-id': + return { + id: 'data-output-id', + is_default: false, + name: 'Data output', + // @ts-ignore + type: 'elasticsearch', + hosts: ['http://es-data.co:9201'], + }; + case 'monitoring-output-id': + return { + id: 'monitoring-output-id', + is_default: false, + name: 'Monitoring output', + // @ts-ignore + type: 'elasticsearch', + hosts: ['http://es-monitoring.co:9201'], + }; + default: + return { + id: 'test-id', + is_default: true, + name: 'default', + // @ts-ignore + type: 'elasticsearch', + hosts: ['http://127.0.0.1:9201'], + }; + } + }, + }, + }; +}); + +jest.mock('../agent_policy_update'); +jest.mock('../agents'); +jest.mock('../package_policy'); + +function getAgentPolicyUpdateMock() { + return agentPolicyUpdateEventHandler as unknown as jest.Mock< + typeof agentPolicyUpdateEventHandler + >; +} + +describe('getFullAgentPolicy', () => { + beforeEach(() => { + getAgentPolicyUpdateMock().mockClear(); + mockedAgentPolicyService.get.mockReset(); + }); + + it('should return a policy without monitoring if monitoring is not enabled', async () => { + mockAgentPolicy({ + revision: 1, + }); + const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy'); + + expect(agentPolicy).toMatchObject({ + id: 'agent-policy', + outputs: { + default: { + type: 'elasticsearch', + hosts: ['http://127.0.0.1:9201'], + ca_sha256: undefined, + api_key: undefined, + }, + }, + inputs: [], + revision: 1, + fleet: { + hosts: ['http://fleetserver:8220'], + }, + agent: { + monitoring: { + enabled: false, + logs: false, + metrics: false, + }, + }, + }); + }); + + it('should return a policy with monitoring if monitoring is enabled for logs', async () => { + mockAgentPolicy({ + namespace: 'default', + revision: 1, + monitoring_enabled: ['logs'], + }); + const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy'); + + expect(agentPolicy).toMatchObject({ + id: 'agent-policy', + outputs: { + default: { + type: 'elasticsearch', + hosts: ['http://127.0.0.1:9201'], + ca_sha256: undefined, + api_key: undefined, + }, + }, + inputs: [], + revision: 1, + fleet: { + hosts: ['http://fleetserver:8220'], + }, + agent: { + monitoring: { + namespace: 'default', + use_output: 'default', + enabled: true, + logs: true, + metrics: false, + }, + }, + }); + }); + + it('should return a policy with monitoring if monitoring is enabled for metrics', async () => { + mockAgentPolicy({ + namespace: 'default', + revision: 1, + monitoring_enabled: ['metrics'], + }); + const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy'); + + expect(agentPolicy).toMatchObject({ + id: 'agent-policy', + outputs: { + default: { + type: 'elasticsearch', + hosts: ['http://127.0.0.1:9201'], + ca_sha256: undefined, + api_key: undefined, + }, + }, + inputs: [], + revision: 1, + fleet: { + hosts: ['http://fleetserver:8220'], + }, + agent: { + monitoring: { + namespace: 'default', + use_output: 'default', + enabled: true, + logs: false, + metrics: true, + }, + }, + }); + }); + + it('should support a different monitoring output', async () => { + mockAgentPolicy({ + namespace: 'default', + revision: 1, + monitoring_enabled: ['metrics'], + monitoring_output_id: 'monitoring-output-id', + }); + const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy'); + + expect(agentPolicy).toMatchSnapshot(); + }); + + it('should support a different data output', async () => { + mockAgentPolicy({ + namespace: 'default', + revision: 1, + monitoring_enabled: ['metrics'], + data_output_id: 'data-output-id', + }); + const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy'); + + expect(agentPolicy).toMatchSnapshot(); + }); + + it('should support both different outputs for data and monitoring ', async () => { + mockAgentPolicy({ + namespace: 'default', + revision: 1, + monitoring_enabled: ['metrics'], + data_output_id: 'data-output-id', + monitoring_output_id: 'monitoring-output-id', + }); + const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy'); + + expect(agentPolicy).toMatchSnapshot(); + }); + + it('should use "default" as the default policy id', async () => { + mockAgentPolicy({ + id: 'policy', + status: 'active', + package_policies: [], + is_managed: false, + namespace: 'default', + revision: 1, + data_output_id: 'test-id', + monitoring_output_id: 'test-id', + }); + + const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy'); + + expect(agentPolicy?.outputs.default).toBeDefined(); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts new file mode 100644 index 000000000000..4e8b3a2c1952 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts @@ -0,0 +1,229 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsClientContract } from 'kibana/server'; +import { safeLoad } from 'js-yaml'; + +import type { + FullAgentPolicy, + PackagePolicy, + Settings, + Output, + FullAgentPolicyOutput, +} from '../../types'; +import { agentPolicyService } from '../agent_policy'; +import { outputService } from '../output'; +import { + storedPackagePoliciesToAgentPermissions, + DEFAULT_PERMISSIONS, +} from '../package_policies_to_agent_permissions'; +import { storedPackagePoliciesToAgentInputs, dataTypes, outputType } from '../../../common'; +import type { FullAgentPolicyOutputPermissions } from '../../../common'; +import { getSettings } from '../settings'; +import { PACKAGE_POLICY_DEFAULT_INDEX_PRIVILEGES, DEFAULT_OUTPUT } from '../../constants'; + +const MONITORING_DATASETS = [ + 'elastic_agent', + 'elastic_agent.elastic_agent', + 'elastic_agent.apm_server', + 'elastic_agent.filebeat', + 'elastic_agent.fleet_server', + 'elastic_agent.metricbeat', + 'elastic_agent.osquerybeat', + 'elastic_agent.packetbeat', + 'elastic_agent.endpoint_security', + 'elastic_agent.auditbeat', + 'elastic_agent.heartbeat', +]; + +export async function getFullAgentPolicy( + soClient: SavedObjectsClientContract, + id: string, + options?: { standalone: boolean } +): Promise { + let agentPolicy; + const standalone = options?.standalone; + + try { + agentPolicy = await agentPolicyService.get(soClient, id); + } catch (err) { + if (!err.isBoom || err.output.statusCode !== 404) { + throw err; + } + } + + if (!agentPolicy) { + return null; + } + + const defaultOutputId = await outputService.getDefaultOutputId(soClient); + if (!defaultOutputId) { + throw new Error('Default output is not setup'); + } + + const dataOutputId = agentPolicy.data_output_id || defaultOutputId; + const monitoringOutputId = agentPolicy.monitoring_output_id || defaultOutputId; + + const outputs = await Promise.all( + Array.from(new Set([dataOutputId, monitoringOutputId])).map((outputId) => + outputService.get(soClient, outputId) + ) + ); + + const dataOutput = outputs.find((output) => output.id === dataOutputId); + if (!dataOutput) { + throw new Error(`Data output not found ${dataOutputId}`); + } + const monitoringOutput = outputs.find((output) => output.id === monitoringOutputId); + if (!monitoringOutput) { + throw new Error(`Monitoring output not found ${monitoringOutputId}`); + } + + const fullAgentPolicy: FullAgentPolicy = { + id: agentPolicy.id, + outputs: { + ...outputs.reduce((acc, output) => { + acc[getOutputIdForAgentPolicy(output)] = transformOutputToFullPolicyOutput(output); + + return acc; + }, {}), + }, + inputs: storedPackagePoliciesToAgentInputs( + agentPolicy.package_policies as PackagePolicy[], + getOutputIdForAgentPolicy(dataOutput) + ), + revision: agentPolicy.revision, + ...(agentPolicy.monitoring_enabled && agentPolicy.monitoring_enabled.length > 0 + ? { + agent: { + monitoring: { + namespace: agentPolicy.namespace, + use_output: getOutputIdForAgentPolicy(monitoringOutput), + enabled: true, + logs: agentPolicy.monitoring_enabled.includes(dataTypes.Logs), + metrics: agentPolicy.monitoring_enabled.includes(dataTypes.Metrics), + }, + }, + } + : { + agent: { + monitoring: { enabled: false, logs: false, metrics: false }, + }, + }), + }; + + const dataPermissions = (await storedPackagePoliciesToAgentPermissions( + soClient, + agentPolicy.package_policies + )) || { _fallback: DEFAULT_PERMISSIONS }; + + dataPermissions._elastic_agent_checks = { + cluster: DEFAULT_PERMISSIONS.cluster, + }; + + // TODO: fetch this from the elastic agent package https://github.com/elastic/kibana/issues/107738 + const monitoringNamespace = fullAgentPolicy.agent?.monitoring.namespace; + const monitoringPermissions: FullAgentPolicyOutputPermissions = + monitoringOutputId === dataOutputId + ? dataPermissions + : { + _elastic_agent_checks: { + cluster: DEFAULT_PERMISSIONS.cluster, + }, + }; + if ( + fullAgentPolicy.agent?.monitoring.enabled && + monitoringNamespace && + monitoringOutput && + monitoringOutput.type === outputType.Elasticsearch + ) { + let names: string[] = []; + if (fullAgentPolicy.agent.monitoring.logs) { + names = names.concat( + MONITORING_DATASETS.map((dataset) => `logs-${dataset}-${monitoringNamespace}`) + ); + } + if (fullAgentPolicy.agent.monitoring.metrics) { + names = names.concat( + MONITORING_DATASETS.map((dataset) => `metrics-${dataset}-${monitoringNamespace}`) + ); + } + + monitoringPermissions._elastic_agent_checks.indices = [ + { + names, + privileges: PACKAGE_POLICY_DEFAULT_INDEX_PRIVILEGES, + }, + ]; + } + + // Only add permissions if output.type is "elasticsearch" + fullAgentPolicy.output_permissions = Object.keys(fullAgentPolicy.outputs).reduce< + NonNullable + >((outputPermissions, outputId) => { + const output = fullAgentPolicy.outputs[outputId]; + if (output && output.type === outputType.Elasticsearch) { + outputPermissions[outputId] = + outputId === getOutputIdForAgentPolicy(dataOutput) + ? dataPermissions + : monitoringPermissions; + } + return outputPermissions; + }, {}); + + // only add settings if not in standalone + if (!standalone) { + let settings: Settings; + try { + settings = await getSettings(soClient); + } catch (error) { + throw new Error('Default settings is not setup'); + } + if (settings.fleet_server_hosts && settings.fleet_server_hosts.length) { + fullAgentPolicy.fleet = { + hosts: settings.fleet_server_hosts, + }; + } + } + return fullAgentPolicy; +} + +function transformOutputToFullPolicyOutput( + output: Output, + standalone = false +): FullAgentPolicyOutput { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { config_yaml, type, hosts, ca_sha256, api_key } = output; + const configJs = config_yaml ? safeLoad(config_yaml) : {}; + const newOutput: FullAgentPolicyOutput = { + type, + hosts, + ca_sha256, + api_key, + ...configJs, + }; + + if (standalone) { + delete newOutput.api_key; + newOutput.username = 'ES_USERNAME'; + newOutput.password = 'ES_PASSWORD'; + } + + return newOutput; +} + +/** + * Get id used in full agent policy (sent to the agents) + * we use "default" for the default policy to avoid breaking changes + */ +function getOutputIdForAgentPolicy(output: Output) { + if (output.is_default) { + return DEFAULT_OUTPUT.name; + } + + return output.id; +} diff --git a/x-pack/plugins/fleet/server/services/agent_policies/index.ts b/x-pack/plugins/fleet/server/services/agent_policies/index.ts new file mode 100644 index 000000000000..b793ed26a08b --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agent_policies/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getFullAgentPolicy } from './full_agent_policy'; diff --git a/x-pack/plugins/fleet/server/services/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policy.test.ts index 59e0f6fd7840..6a5cb28dbaa0 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.test.ts @@ -7,7 +7,7 @@ import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; -import type { AgentPolicy, NewAgentPolicy, Output } from '../types'; +import type { AgentPolicy, NewAgentPolicy } from '../types'; import { agentPolicyService } from './agent_policy'; import { agentPolicyUpdateEventHandler } from './agent_policy_update'; @@ -47,24 +47,6 @@ function getSavedObjectMock(agentPolicyAttributes: any) { return mock; } -jest.mock('./output', () => { - return { - outputService: { - getDefaultOutputId: () => 'test-id', - get: (): Output => { - return { - id: 'test-id', - is_default: true, - name: 'default', - // @ts-ignore - type: 'elasticsearch', - hosts: ['http://127.0.0.1:9201'], - }; - }, - }, - }; -}); - jest.mock('./agent_policy_update'); jest.mock('./agents'); jest.mock('./package_policy'); @@ -186,106 +168,17 @@ describe('agent policy', () => { }); }); - describe('getFullAgentPolicy', () => { - it('should return a policy without monitoring if monitoring is not enabled', async () => { - const soClient = getSavedObjectMock({ - revision: 1, - }); - const agentPolicy = await agentPolicyService.getFullAgentPolicy(soClient, 'agent-policy'); - - expect(agentPolicy).toMatchObject({ - id: 'agent-policy', - outputs: { - default: { - type: 'elasticsearch', - hosts: ['http://127.0.0.1:9201'], - ca_sha256: undefined, - api_key: undefined, - }, - }, - inputs: [], - revision: 1, - fleet: { - hosts: ['http://fleetserver:8220'], - }, - agent: { - monitoring: { - enabled: false, - logs: false, - metrics: false, - }, - }, - }); - }); - - it('should return a policy with monitoring if monitoring is enabled for logs', async () => { - const soClient = getSavedObjectMock({ - namespace: 'default', - revision: 1, - monitoring_enabled: ['logs'], - }); - const agentPolicy = await agentPolicyService.getFullAgentPolicy(soClient, 'agent-policy'); - - expect(agentPolicy).toMatchObject({ - id: 'agent-policy', - outputs: { - default: { - type: 'elasticsearch', - hosts: ['http://127.0.0.1:9201'], - ca_sha256: undefined, - api_key: undefined, - }, - }, - inputs: [], - revision: 1, - fleet: { - hosts: ['http://fleetserver:8220'], - }, - agent: { - monitoring: { - namespace: 'default', - use_output: 'default', - enabled: true, - logs: true, - metrics: false, - }, - }, - }); - }); - - it('should return a policy with monitoring if monitoring is enabled for metrics', async () => { + describe('bumpAllAgentPoliciesForOutput', () => { + it('should call agentPolicyUpdateEventHandler with updated event once', async () => { const soClient = getSavedObjectMock({ - namespace: 'default', revision: 1, monitoring_enabled: ['metrics'], }); - const agentPolicy = await agentPolicyService.getFullAgentPolicy(soClient, 'agent-policy'); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - expect(agentPolicy).toMatchObject({ - id: 'agent-policy', - outputs: { - default: { - type: 'elasticsearch', - hosts: ['http://127.0.0.1:9201'], - ca_sha256: undefined, - api_key: undefined, - }, - }, - inputs: [], - revision: 1, - fleet: { - hosts: ['http://fleetserver:8220'], - }, - agent: { - monitoring: { - namespace: 'default', - use_output: 'default', - enabled: true, - logs: false, - metrics: true, - }, - }, - }); + await agentPolicyService.bumpAllAgentPoliciesForOutput(soClient, esClient, 'output-id-123'); + + expect(agentPolicyUpdateEventHandler).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 38fb07754bdd..751e981cb808 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -6,7 +6,6 @@ */ import { uniq, omit } from 'lodash'; -import { safeLoad } from 'js-yaml'; import uuid from 'uuid/v4'; import type { ElasticsearchClient, @@ -21,7 +20,6 @@ import { AGENT_POLICY_SAVED_OBJECT_TYPE, AGENT_SAVED_OBJECT_TYPE, PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, - PACKAGE_POLICY_DEFAULT_INDEX_PRIVILEGES, } from '../constants'; import type { PackagePolicy, @@ -33,52 +31,27 @@ import type { ListWithKuery, NewPackagePolicy, } from '../types'; -import { - agentPolicyStatuses, - storedPackagePoliciesToAgentInputs, - dataTypes, - packageToPackagePolicy, - AGENT_POLICY_INDEX, -} from '../../common'; +import { agentPolicyStatuses, packageToPackagePolicy, AGENT_POLICY_INDEX } from '../../common'; import type { DeleteAgentPolicyResponse, - Settings, FleetServerPolicy, Installation, Output, DeletePackagePoliciesResponse, } from '../../common'; import { AgentPolicyNameExistsError, HostedAgentPolicyRestrictionRelatedError } from '../errors'; -import { - storedPackagePoliciesToAgentPermissions, - DEFAULT_PERMISSIONS, -} from '../services/package_policies_to_agent_permissions'; import { getPackageInfo } from './epm/packages'; import { getAgentsByKuery } from './agents'; import { packagePolicyService } from './package_policy'; import { outputService } from './output'; import { agentPolicyUpdateEventHandler } from './agent_policy_update'; -import { getSettings } from './settings'; import { normalizeKuery, escapeSearchQueryPhrase } from './saved_object'; import { appContextService } from './app_context'; +import { getFullAgentPolicy } from './agent_policies'; const SAVED_OBJECT_TYPE = AGENT_POLICY_SAVED_OBJECT_TYPE; -const MONITORING_DATASETS = [ - 'elastic_agent', - 'elastic_agent.elastic_agent', - 'elastic_agent.apm_server', - 'elastic_agent.filebeat', - 'elastic_agent.fleet_server', - 'elastic_agent.metricbeat', - 'elastic_agent.osquerybeat', - 'elastic_agent.packetbeat', - 'elastic_agent.endpoint_security', - 'elastic_agent.auditbeat', - 'elastic_agent.heartbeat', -]; - class AgentPolicyService { private triggerAgentPolicyUpdatedEvent = async ( soClient: SavedObjectsClientContract, @@ -472,6 +445,38 @@ class AgentPolicyService { return res; } + public async bumpAllAgentPoliciesForOutput( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + outputId: string, + options?: { user?: AuthenticatedUser } + ): Promise> { + const currentPolicies = await soClient.find({ + type: SAVED_OBJECT_TYPE, + fields: ['revision', 'data_output_id', 'monitoring_output_id'], + searchFields: ['data_output_id', 'monitoring_output_id'], + search: escapeSearchQueryPhrase(outputId), + }); + const bumpedPolicies = currentPolicies.saved_objects.map((policy) => { + policy.attributes = { + ...policy.attributes, + revision: policy.attributes.revision + 1, + updated_at: new Date().toISOString(), + updated_by: options?.user ? options.user.username : 'system', + }; + return policy; + }); + const res = await soClient.bulkUpdate(bumpedPolicies); + + await Promise.all( + currentPolicies.saved_objects.map((policy) => + this.triggerAgentPolicyUpdatedEvent(soClient, esClient, 'updated', policy.id) + ) + ); + + return res; + } + public async bumpAllAgentPolicies( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, @@ -724,139 +729,7 @@ class AgentPolicyService { id: string, options?: { standalone: boolean } ): Promise { - let agentPolicy; - const standalone = options?.standalone; - - try { - agentPolicy = await this.get(soClient, id); - } catch (err) { - if (!err.isBoom || err.output.statusCode !== 404) { - throw err; - } - } - - if (!agentPolicy) { - return null; - } - - const defaultOutputId = await outputService.getDefaultOutputId(soClient); - if (!defaultOutputId) { - throw new Error('Default output is not setup'); - } - const defaultOutput = await outputService.get(soClient, defaultOutputId); - - const fullAgentPolicy: FullAgentPolicy = { - id: agentPolicy.id, - outputs: { - // TEMPORARY as we only support a default output - ...[defaultOutput].reduce( - // eslint-disable-next-line @typescript-eslint/naming-convention - (outputs, { config_yaml, name, type, hosts, ca_sha256, api_key }) => { - const configJs = config_yaml ? safeLoad(config_yaml) : {}; - outputs[name] = { - type, - hosts, - ca_sha256, - api_key, - ...configJs, - }; - - if (options?.standalone) { - delete outputs[name].api_key; - outputs[name].username = 'ES_USERNAME'; - outputs[name].password = 'ES_PASSWORD'; - } - - return outputs; - }, - {} - ), - }, - inputs: storedPackagePoliciesToAgentInputs(agentPolicy.package_policies as PackagePolicy[]), - revision: agentPolicy.revision, - ...(agentPolicy.monitoring_enabled && agentPolicy.monitoring_enabled.length > 0 - ? { - agent: { - monitoring: { - namespace: agentPolicy.namespace, - use_output: defaultOutput.name, - enabled: true, - logs: agentPolicy.monitoring_enabled.includes(dataTypes.Logs), - metrics: agentPolicy.monitoring_enabled.includes(dataTypes.Metrics), - }, - }, - } - : { - agent: { - monitoring: { enabled: false, logs: false, metrics: false }, - }, - }), - }; - - const permissions = (await storedPackagePoliciesToAgentPermissions( - soClient, - agentPolicy.package_policies - )) || { _fallback: DEFAULT_PERMISSIONS }; - - permissions._elastic_agent_checks = { - cluster: DEFAULT_PERMISSIONS.cluster, - }; - - // TODO: fetch this from the elastic agent package - const monitoringOutput = fullAgentPolicy.agent?.monitoring.use_output; - const monitoringNamespace = fullAgentPolicy.agent?.monitoring.namespace; - if ( - fullAgentPolicy.agent?.monitoring.enabled && - monitoringNamespace && - monitoringOutput && - fullAgentPolicy.outputs[monitoringOutput]?.type === 'elasticsearch' - ) { - let names: string[] = []; - if (fullAgentPolicy.agent.monitoring.logs) { - names = names.concat( - MONITORING_DATASETS.map((dataset) => `logs-${dataset}-${monitoringNamespace}`) - ); - } - if (fullAgentPolicy.agent.monitoring.metrics) { - names = names.concat( - MONITORING_DATASETS.map((dataset) => `metrics-${dataset}-${monitoringNamespace}`) - ); - } - - permissions._elastic_agent_checks.indices = [ - { - names, - privileges: PACKAGE_POLICY_DEFAULT_INDEX_PRIVILEGES, - }, - ]; - } - - // Only add permissions if output.type is "elasticsearch" - fullAgentPolicy.output_permissions = Object.keys(fullAgentPolicy.outputs).reduce< - NonNullable - >((outputPermissions, outputName) => { - const output = fullAgentPolicy.outputs[outputName]; - if (output && output.type === 'elasticsearch') { - outputPermissions[outputName] = permissions; - } - return outputPermissions; - }, {}); - - // only add settings if not in standalone - if (!standalone) { - let settings: Settings; - try { - settings = await getSettings(soClient); - } catch (error) { - throw new Error('Default settings is not setup'); - } - if (settings.fleet_server_hosts && settings.fleet_server_hosts.length) { - fullAgentPolicy.fleet = { - hosts: settings.fleet_server_hosts, - }; - } - } - return fullAgentPolicy; + return getFullAgentPolicy(soClient, id, options); } } diff --git a/x-pack/plugins/fleet/server/services/output.test.ts b/x-pack/plugins/fleet/server/services/output.test.ts index 26e3955607ad..8103794fb080 100644 --- a/x-pack/plugins/fleet/server/services/output.test.ts +++ b/x-pack/plugins/fleet/server/services/output.test.ts @@ -5,8 +5,10 @@ * 2.0. */ -import { outputService } from './output'; +import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import type { OutputSOAttributes } from '../types'; +import { outputService, outputIdToUuid } from './output'; import { appContextService } from './app_context'; jest.mock('./app_context'); @@ -34,7 +36,97 @@ const CONFIG_WITHOUT_ES_HOSTS = { }, }; +function getMockedSoClient() { + const soClient = savedObjectsClientMock.create(); + soClient.get.mockImplementation(async (type: string, id: string) => { + switch (id) { + case outputIdToUuid('output-test'): { + return { + id: outputIdToUuid('output-test'), + type: 'ingest-outputs', + references: [], + attributes: { + output_id: 'output-test', + }, + }; + } + default: + throw new Error('not found'); + } + }); + + return soClient; +} + describe('Output Service', () => { + describe('create', () => { + it('work with a predefined id', async () => { + const soClient = getMockedSoClient(); + soClient.create.mockResolvedValue({ + id: outputIdToUuid('output-test'), + type: 'ingest-output', + attributes: {}, + references: [], + }); + await outputService.create( + soClient, + { + is_default: false, + name: 'Test', + type: 'elasticsearch', + }, + { id: 'output-test' } + ); + + expect(soClient.create).toBeCalled(); + + // ID should always be the same for a predefined id + expect(soClient.create.mock.calls[0][2]?.id).toEqual(outputIdToUuid('output-test')); + expect((soClient.create.mock.calls[0][1] as OutputSOAttributes).output_id).toEqual( + 'output-test' + ); + }); + }); + + describe('get', () => { + it('work with a predefined id', async () => { + const soClient = getMockedSoClient(); + const output = await outputService.get(soClient, 'output-test'); + + expect(soClient.get).toHaveBeenCalledWith('ingest-outputs', outputIdToUuid('output-test')); + + expect(output.id).toEqual('output-test'); + }); + }); + + describe('getDefaultOutputId', () => { + it('work with a predefined id', async () => { + const soClient = getMockedSoClient(); + soClient.find.mockResolvedValue({ + page: 1, + per_page: 100, + total: 1, + saved_objects: [ + { + id: outputIdToUuid('output-test'), + type: 'ingest-outputs', + references: [], + score: 0, + attributes: { + output_id: 'output-test', + is_default: true, + }, + }, + ], + }); + const defaultId = await outputService.getDefaultOutputId(soClient); + + expect(soClient.find).toHaveBeenCalled(); + + expect(defaultId).toEqual('output-test'); + }); + }); + describe('getDefaultESHosts', () => { afterEach(() => { mockedAppContextService.getConfig.mockReset(); diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index 8c6bc7eca040..5a7ba1e2c122 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -5,7 +5,8 @@ * 2.0. */ -import type { SavedObjectsClientContract } from 'src/core/server'; +import type { SavedObject, SavedObjectsClientContract } from 'src/core/server'; +import uuid from 'uuid/v5'; import type { NewOutput, Output, OutputSOAttributes } from '../types'; import { DEFAULT_OUTPUT, OUTPUT_SAVED_OBJECT_TYPE } from '../constants'; @@ -17,8 +18,33 @@ const SAVED_OBJECT_TYPE = OUTPUT_SAVED_OBJECT_TYPE; const DEFAULT_ES_HOSTS = ['http://localhost:9200']; +// differentiate +function isUUID(val: string) { + return ( + typeof val === 'string' && + val.match(/[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}/) + ); +} + +export function outputIdToUuid(id: string) { + if (isUUID(id)) { + return id; + } + + // UUID v5 need a namespace (uuid.DNS), changing this params will result in loosing the ability to generate predicable uuid + return uuid(id, uuid.DNS); +} + +function outputSavedObjectToOutput(so: SavedObject) { + const { output_id: outputId, ...atributes } = so.attributes; + return { + id: outputId ?? so.id, + ...atributes, + }; +} + class OutputService { - public async getDefaultOutput(soClient: SavedObjectsClientContract) { + private async _getDefaultOutputsSO(soClient: SavedObjectsClientContract) { return await soClient.find({ type: OUTPUT_SAVED_OBJECT_TYPE, searchFields: ['is_default'], @@ -27,7 +53,7 @@ class OutputService { } public async ensureDefaultOutput(soClient: SavedObjectsClientContract) { - const outputs = await this.getDefaultOutput(soClient); + const outputs = await this._getDefaultOutputsSO(soClient); if (!outputs.saved_objects.length) { const newDefaultOutput = { @@ -39,10 +65,7 @@ class OutputService { return await this.create(soClient, newDefaultOutput); } - return { - id: outputs.saved_objects[0].id, - ...outputs.saved_objects[0].attributes, - }; + return outputSavedObjectToOutput(outputs.saved_objects[0]); } public getDefaultESHosts(): string[] { @@ -60,49 +83,84 @@ class OutputService { } public async getDefaultOutputId(soClient: SavedObjectsClientContract) { - const outputs = await this.getDefaultOutput(soClient); + const outputs = await this._getDefaultOutputsSO(soClient); if (!outputs.saved_objects.length) { return null; } - return outputs.saved_objects[0].id; + return outputSavedObjectToOutput(outputs.saved_objects[0]).id; } public async create( soClient: SavedObjectsClientContract, output: NewOutput, - options?: { id?: string } + options?: { id?: string; overwrite?: boolean } ): Promise { - const data = { ...output }; + const data: OutputSOAttributes = { ...output }; + + // ensure only default output exists + if (data.is_default) { + const defaultOuput = await this.getDefaultOutputId(soClient); + if (defaultOuput) { + throw new Error(`A default output already exists (${defaultOuput})`); + } + } if (data.hosts) { data.hosts = data.hosts.map(normalizeHostsForAgents); } - const newSo = await soClient.create( - SAVED_OBJECT_TYPE, - data as Output, - options - ); + if (options?.id) { + data.output_id = options?.id; + } + + const newSo = await soClient.create(SAVED_OBJECT_TYPE, data, { + ...options, + id: options?.id ? outputIdToUuid(options.id) : undefined, + }); return { - id: newSo.id, + id: options?.id ?? newSo.id, ...newSo.attributes, }; } + public async bulkGet( + soClient: SavedObjectsClientContract, + ids: string[], + { ignoreNotFound = false } = { ignoreNotFound: true } + ) { + const res = await soClient.bulkGet( + ids.map((id) => ({ id: outputIdToUuid(id), type: SAVED_OBJECT_TYPE })) + ); + + return res.saved_objects + .map((so) => { + if (so.error) { + if (!ignoreNotFound || so.error.statusCode !== 404) { + throw so.error; + } + return undefined; + } + + return outputSavedObjectToOutput(so); + }) + .filter((output): output is Output => typeof output !== 'undefined'); + } + public async get(soClient: SavedObjectsClientContract, id: string): Promise { - const outputSO = await soClient.get(SAVED_OBJECT_TYPE, id); + const outputSO = await soClient.get(SAVED_OBJECT_TYPE, outputIdToUuid(id)); if (outputSO.error) { throw new Error(outputSO.error.message); } - return { - id: outputSO.id, - ...outputSO.attributes, - }; + return outputSavedObjectToOutput(outputSO); + } + + public async delete(soClient: SavedObjectsClientContract, id: string) { + return soClient.delete(SAVED_OBJECT_TYPE, outputIdToUuid(id)); } public async update(soClient: SavedObjectsClientContract, id: string, data: Partial) { @@ -111,8 +169,11 @@ class OutputService { if (updateData.hosts) { updateData.hosts = updateData.hosts.map(normalizeHostsForAgents); } - - const outputSO = await soClient.update(SAVED_OBJECT_TYPE, id, updateData); + const outputSO = await soClient.update( + SAVED_OBJECT_TYPE, + outputIdToUuid(id), + updateData + ); if (outputSO.error) { throw new Error(outputSO.error.message); @@ -127,12 +188,7 @@ class OutputService { }); return { - items: outputs.saved_objects.map((outputSO) => { - return { - id: outputSO.id, - ...outputSO.attributes, - }; - }), + items: outputs.saved_objects.map(outputSavedObjectToOutput), total: outputs.total, page: 1, perPage: 1000, diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 86fdd2f0aa80..43887bc2787f 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -9,7 +9,7 @@ import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/serve import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; -import type { PreconfiguredAgentPolicy } from '../../common/types'; +import type { PreconfiguredAgentPolicy, PreconfiguredOutput } from '../../common/types'; import type { AgentPolicy, NewPackagePolicy, Output } from '../types'; import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../constants'; @@ -19,9 +19,15 @@ import * as agentPolicy from './agent_policy'; import { ensurePreconfiguredPackagesAndPolicies, comparePreconfiguredPolicyToCurrent, + ensurePreconfiguredOutputs, + cleanPreconfiguredOutputs, } from './preconfiguration'; +import { outputService } from './output'; jest.mock('./agent_policy_update'); +jest.mock('./output'); + +const mockedOutputService = outputService as jest.Mocked; const mockInstalledPackages = new Map(); const mockConfiguredPolicies = new Map(); @@ -156,12 +162,17 @@ jest.mock('./app_context', () => ({ })); const spyAgentPolicyServiceUpdate = jest.spyOn(agentPolicy.agentPolicyService, 'update'); +const spyAgentPolicyServicBumpAllAgentPoliciesForOutput = jest.spyOn( + agentPolicy.agentPolicyService, + 'bumpAllAgentPoliciesForOutput' +); describe('policy preconfiguration', () => { beforeEach(() => { mockInstalledPackages.clear(); mockConfiguredPolicies.clear(); spyAgentPolicyServiceUpdate.mockClear(); + spyAgentPolicyServicBumpAllAgentPoliciesForOutput.mockClear(); }); it('should perform a no-op when passed no policies or packages', async () => { @@ -480,3 +491,168 @@ describe('comparePreconfiguredPolicyToCurrent', () => { expect(hasChanged).toBe(false); }); }); + +describe('output preconfiguration', () => { + beforeEach(() => { + mockedOutputService.create.mockReset(); + mockedOutputService.update.mockReset(); + mockedOutputService.getDefaultESHosts.mockReturnValue(['http://default-es:9200']); + mockedOutputService.bulkGet.mockImplementation(async (soClient, id): Promise => { + return [ + { + id: 'existing-output-1', + is_default: false, + name: 'Output 1', + // @ts-ignore + type: 'elasticsearch', + hosts: ['http://es.co:80'], + is_preconfigured: true, + }, + ]; + }); + }); + + it('should create preconfigured output that does not exists', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await ensurePreconfiguredOutputs(soClient, esClient, [ + { + id: 'non-existing-output-1', + name: 'Output 1', + type: 'elasticsearch', + is_default: false, + hosts: ['http://test.fr'], + }, + ]); + + expect(mockedOutputService.create).toBeCalled(); + expect(mockedOutputService.update).not.toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); + }); + + it('should set default hosts if hosts is not set output that does not exists', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await ensurePreconfiguredOutputs(soClient, esClient, [ + { + id: 'non-existing-output-1', + name: 'Output 1', + type: 'elasticsearch', + is_default: false, + }, + ]); + + expect(mockedOutputService.create).toBeCalled(); + expect(mockedOutputService.create.mock.calls[0][1].hosts).toEqual(['http://default-es:9200']); + }); + + it('should update output if preconfigured output exists and changed', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); + await ensurePreconfiguredOutputs(soClient, esClient, [ + { + id: 'existing-output-1', + is_default: false, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://newhostichanged.co:9201'], // field that changed + }, + ]); + + expect(mockedOutputService.create).not.toBeCalled(); + expect(mockedOutputService.update).toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled(); + }); + + const SCENARIOS: Array<{ name: string; data: PreconfiguredOutput }> = [ + { + name: 'no changes', + data: { + id: 'existing-output-1', + is_default: false, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://es.co:80'], + }, + }, + { + name: 'hosts without port', + data: { + id: 'existing-output-1', + is_default: false, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://es.co'], + }, + }, + ]; + SCENARIOS.forEach((scenario) => { + const { data, name } = scenario; + it(`should do nothing if preconfigured output exists and did not changed (${name})`, async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await ensurePreconfiguredOutputs(soClient, esClient, [data]); + + expect(mockedOutputService.create).not.toBeCalled(); + expect(mockedOutputService.update).not.toBeCalled(); + }); + }); + + it('should not delete non deleted preconfigured output', async () => { + const soClient = savedObjectsClientMock.create(); + mockedOutputService.list.mockResolvedValue({ + items: [ + { id: 'output1', is_preconfigured: true } as Output, + { id: 'output2', is_preconfigured: true } as Output, + ], + page: 1, + perPage: 10000, + total: 1, + }); + await cleanPreconfiguredOutputs(soClient, [ + { + id: 'output1', + is_default: false, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://es.co:9201'], + }, + { + id: 'output2', + is_default: false, + name: 'Output 2', + type: 'elasticsearch', + hosts: ['http://es.co:9201'], + }, + ]); + + expect(mockedOutputService.delete).not.toBeCalled(); + }); + + it('should delete deleted preconfigured output', async () => { + const soClient = savedObjectsClientMock.create(); + mockedOutputService.list.mockResolvedValue({ + items: [ + { id: 'output1', is_preconfigured: true } as Output, + { id: 'output2', is_preconfigured: true } as Output, + ], + page: 1, + perPage: 10000, + total: 1, + }); + await cleanPreconfiguredOutputs(soClient, [ + { + id: 'output1', + is_default: false, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://es.co:9201'], + }, + ]); + + expect(mockedOutputService.delete).toBeCalled(); + expect(mockedOutputService.delete).toBeCalledTimes(1); + expect(mockedOutputService.delete.mock.calls[0][1]).toEqual('output2'); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index 37ed98a6f4aa..30c5c27c6891 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -8,6 +8,7 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; import { i18n } from '@kbn/i18n'; import { groupBy, omit, pick, isEqual } from 'lodash'; +import { safeDump } from 'js-yaml'; import type { NewPackagePolicy, @@ -17,16 +18,15 @@ import type { PreconfiguredAgentPolicy, PreconfiguredPackage, PreconfigurationError, + PreconfiguredOutput, } from '../../common'; -import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../common'; - +import { AGENT_POLICY_SAVED_OBJECT_TYPE, normalizeHostsForAgents } from '../../common'; import { PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, PRECONFIGURATION_LATEST_KEYWORD, } from '../constants'; import { escapeSearchQueryPhrase } from './saved_object'; - import { pkgToPkgKey } from './epm/registry'; import { getInstallation, getPackageInfo } from './epm/packages'; import { ensurePackagesCompletedInstall } from './epm/packages/install'; @@ -35,6 +35,7 @@ import { agentPolicyService, addPackageToAgentPolicy } from './agent_policy'; import type { InputsOverride } from './package_policy'; import { overridePackageInputs } from './package_policy'; import { appContextService } from './app_context'; +import { outputService } from './output'; interface PreconfigurationResult { policies: Array<{ id: string; updated_at: string }>; @@ -42,6 +43,89 @@ interface PreconfigurationResult { nonFatalErrors: PreconfigurationError[]; } +function isPreconfiguredOutputDifferentFromCurrent( + existingOutput: Output, + preconfiguredOutput: Partial +): boolean { + return ( + existingOutput.is_default !== preconfiguredOutput.is_default || + existingOutput.name !== preconfiguredOutput.name || + existingOutput.type !== preconfiguredOutput.type || + (preconfiguredOutput.hosts && + !isEqual( + existingOutput.hosts?.map(normalizeHostsForAgents), + preconfiguredOutput.hosts.map(normalizeHostsForAgents) + )) || + existingOutput.ca_sha256 !== preconfiguredOutput.ca_sha256 || + existingOutput.config_yaml !== preconfiguredOutput.config_yaml + ); +} + +export async function ensurePreconfiguredOutputs( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + outputs: PreconfiguredOutput[] +) { + if (outputs.length === 0) { + return; + } + + const existingOutputs = await outputService.bulkGet( + soClient, + outputs.map(({ id }) => id), + { ignoreNotFound: true } + ); + + await Promise.all( + outputs.map(async (output) => { + const existingOutput = existingOutputs.find((o) => o.id === output.id); + + const { id, config, ...outputData } = output; + + const configYaml = config ? safeDump(config) : undefined; + + const data = { + ...outputData, + config_yaml: configYaml, + is_preconfigured: true, + }; + + if (!data.hosts || data.hosts.length === 0) { + data.hosts = outputService.getDefaultESHosts(); + } + + if (!existingOutput) { + await outputService.create(soClient, data, { id, overwrite: true }); + } else if (isPreconfiguredOutputDifferentFromCurrent(existingOutput, data)) { + await outputService.update(soClient, id, data); + // Bump revision of all policies using that output + if (outputData.is_default) { + await agentPolicyService.bumpAllAgentPolicies(soClient, esClient); + } else { + await agentPolicyService.bumpAllAgentPoliciesForOutput(soClient, esClient, id); + } + } + }) + ); +} + +export async function cleanPreconfiguredOutputs( + soClient: SavedObjectsClientContract, + outputs: PreconfiguredOutput[] +) { + const existingPreconfiguredOutput = (await outputService.list(soClient)).items.filter( + (o) => o.is_preconfigured === true + ); + const logger = appContextService.getLogger(); + + for (const output of existingPreconfiguredOutput) { + if (!outputs.find(({ id }) => output.id === id)) { + logger.info(`Deleting preconfigured output ${output.id}`); + await outputService.delete(soClient, output.id); + } + } +} + export async function ensurePreconfiguredPackagesAndPolicies( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, @@ -224,7 +308,7 @@ export async function ensurePreconfiguredPackagesAndPolicies( } // Add the is_managed flag after configuring package policies to avoid errors if (shouldAddIsManagedFlag) { - agentPolicyService.update(soClient, esClient, policy!.id, { is_managed: true }); + await agentPolicyService.update(soClient, esClient, policy!.id, { is_managed: true }); } } } diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index 1f3c3c5082b3..8c49bffdbf25 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -15,7 +15,11 @@ import { SO_SEARCH_LIMIT, DEFAULT_PACKAGES } from '../constants'; import { appContextService } from './app_context'; import { agentPolicyService } from './agent_policy'; -import { ensurePreconfiguredPackagesAndPolicies } from './preconfiguration'; +import { + cleanPreconfiguredOutputs, + ensurePreconfiguredOutputs, + ensurePreconfiguredPackagesAndPolicies, +} from './preconfiguration'; import { outputService } from './output'; import { generateEnrollmentAPIKey, hasEnrollementAPIKeysForPolicy } from './api_keys'; @@ -45,23 +49,27 @@ async function createSetupSideEffects( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient ): Promise { - const [defaultOutput] = await Promise.all([ - outputService.ensureDefaultOutput(soClient), + const { + agentPolicies: policiesOrUndefined, + packages: packagesOrUndefined, + outputs: outputsOrUndefined, + } = appContextService.getConfig() ?? {}; + + const policies = policiesOrUndefined ?? []; + let packages = packagesOrUndefined ?? []; + + await Promise.all([ + ensurePreconfiguredOutputs(soClient, esClient, outputsOrUndefined ?? []), settingsService.settingsSetup(soClient), ]); + const defaultOutput = await outputService.ensureDefaultOutput(soClient); + await awaitIfFleetServerSetupPending(); if (appContextService.getConfig()?.agentIdVerificationEnabled) { await ensureFleetGlobalEsAssets(soClient, esClient); } - const { agentPolicies: policiesOrUndefined, packages: packagesOrUndefined } = - appContextService.getConfig() ?? {}; - - const policies = policiesOrUndefined ?? []; - - let packages = packagesOrUndefined ?? []; - // Ensure that required packages are always installed even if they're left out of the config const preconfiguredPackageNames = new Set(packages.map((pkg) => pkg.name)); @@ -90,6 +98,8 @@ async function createSetupSideEffects( defaultOutput ); + await cleanPreconfiguredOutputs(soClient, outputsOrUndefined ?? []); + await ensureDefaultEnrollmentAPIKeysExists(soClient, esClient); await ensureAgentActionPolicyChangeExists(soClient, esClient); diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index f686b969fd03..63e6c277ed71 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -27,6 +27,7 @@ export { PackagePolicySOAttributes, FullAgentPolicyInput, FullAgentPolicy, + FullAgentPolicyOutput, AgentPolicy, AgentPolicySOAttributes, NewAgentPolicy, diff --git a/x-pack/plugins/fleet/server/types/models/preconfiguration.test.ts b/x-pack/plugins/fleet/server/types/models/preconfiguration.test.ts new file mode 100644 index 000000000000..eb349e0d0f82 --- /dev/null +++ b/x-pack/plugins/fleet/server/types/models/preconfiguration.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PreconfiguredOutputsSchema, PreconfiguredAgentPoliciesSchema } from './preconfiguration'; + +describe('Test preconfiguration schema', () => { + describe('PreconfiguredOutputsSchema', () => { + it('should not allow multiple default output', () => { + expect(() => { + PreconfiguredOutputsSchema.validate([ + { + id: 'output-1', + name: 'Output 1', + type: 'elasticsearch', + is_default: true, + }, + { + id: 'output-2', + name: 'Output 2', + type: 'elasticsearch', + is_default: true, + }, + ]); + }).toThrowError('preconfigured outputs need to have only one default output.'); + }); + it('should not allow multiple output with same ids', () => { + expect(() => { + PreconfiguredOutputsSchema.validate([ + { + id: 'nonuniqueid', + name: 'Output 1', + type: 'elasticsearch', + }, + { + id: 'nonuniqueid', + name: 'Output 2', + type: 'elasticsearch', + }, + ]); + }).toThrowError('preconfigured outputs need to have unique ids.'); + }); + it('should not allow multiple output with same names', () => { + expect(() => { + PreconfiguredOutputsSchema.validate([ + { + id: 'output-1', + name: 'nonuniquename', + type: 'elasticsearch', + }, + { + id: 'output-2', + name: 'nonuniquename', + type: 'elasticsearch', + }, + ]); + }).toThrowError('preconfigured outputs need to have unique names.'); + }); + }); + + describe('PreconfiguredAgentPoliciesSchema', () => { + it('should not allow multiple outputs in one policy', () => { + expect(() => { + PreconfiguredAgentPoliciesSchema.validate([ + { + id: 'policy-1', + name: 'Policy 1', + package_policies: [], + data_output_id: 'test1', + monitoring_output_id: 'test2', + }, + ]); + }).toThrowError( + '[0]: Currently Fleet only support one output per agent policy data_output_id should be the same as monitoring_output_id.' + ); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/types/models/preconfiguration.ts b/x-pack/plugins/fleet/server/types/models/preconfiguration.ts index 4ea9f086bda6..b65fa122911d 100644 --- a/x-pack/plugins/fleet/server/types/models/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/types/models/preconfiguration.ts @@ -14,6 +14,8 @@ import { DEFAULT_FLEET_SERVER_AGENT_POLICY, DEFAULT_PACKAGES, } from '../../constants'; +import type { PreconfiguredOutput } from '../../../common'; +import { outputType } from '../../../common'; import { AgentPolicyBaseSchema } from './agent_policy'; import { NamespaceSchema } from './package_policy'; @@ -47,47 +49,94 @@ export const PreconfiguredPackagesSchema = schema.arrayOf( } ); -export const PreconfiguredAgentPoliciesSchema = schema.arrayOf( +function validatePreconfiguredOutputs(outputs: PreconfiguredOutput[]) { + const acc = { names: new Set(), ids: new Set(), is_default: false }; + + for (const output of outputs) { + if (acc.names.has(output.name)) { + return 'preconfigured outputs need to have unique names.'; + } + if (acc.ids.has(output.id)) { + return 'preconfigured outputs need to have unique ids.'; + } + if (acc.is_default && output.is_default) { + return 'preconfigured outputs need to have only one default output.'; + } + + acc.ids.add(output.id); + acc.names.add(output.name); + acc.is_default = acc.is_default || output.is_default; + } +} + +export const PreconfiguredOutputsSchema = schema.arrayOf( schema.object({ - ...AgentPolicyBaseSchema, - namespace: schema.maybe(NamespaceSchema), - id: schema.maybe(schema.oneOf([schema.string(), schema.number()])), - is_default: schema.maybe(schema.boolean()), - is_default_fleet_server: schema.maybe(schema.boolean()), - package_policies: schema.arrayOf( - schema.object({ - name: schema.string(), - package: schema.object({ - name: schema.string(), - }), - description: schema.maybe(schema.string()), - namespace: schema.maybe(NamespaceSchema), - inputs: schema.maybe( - schema.arrayOf( - schema.object({ - type: schema.string(), - enabled: schema.maybe(schema.boolean()), - keep_enabled: schema.maybe(schema.boolean()), - vars: varsSchema, - streams: schema.maybe( - schema.arrayOf( - schema.object({ - data_stream: schema.object({ - type: schema.maybe(schema.string()), - dataset: schema.string(), - }), - enabled: schema.maybe(schema.boolean()), - keep_enabled: schema.maybe(schema.boolean()), - vars: varsSchema, - }) - ) - ), - }) - ) - ), - }) - ), + id: schema.string(), + is_default: schema.boolean({ defaultValue: false }), + name: schema.string(), + type: schema.oneOf([schema.literal(outputType.Elasticsearch)]), + hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))), + ca_sha256: schema.maybe(schema.string()), + config: schema.maybe(schema.object({}, { unknowns: 'allow' })), }), + { + defaultValue: [], + validate: validatePreconfiguredOutputs, + } +); + +export const PreconfiguredAgentPoliciesSchema = schema.arrayOf( + schema.object( + { + ...AgentPolicyBaseSchema, + namespace: schema.maybe(NamespaceSchema), + id: schema.maybe(schema.oneOf([schema.string(), schema.number()])), + is_default: schema.maybe(schema.boolean()), + is_default_fleet_server: schema.maybe(schema.boolean()), + data_output_id: schema.maybe(schema.string()), + monitoring_output_id: schema.maybe(schema.string()), + package_policies: schema.arrayOf( + schema.object({ + name: schema.string(), + package: schema.object({ + name: schema.string(), + }), + description: schema.maybe(schema.string()), + namespace: schema.maybe(NamespaceSchema), + inputs: schema.maybe( + schema.arrayOf( + schema.object({ + type: schema.string(), + enabled: schema.maybe(schema.boolean()), + keep_enabled: schema.maybe(schema.boolean()), + vars: varsSchema, + streams: schema.maybe( + schema.arrayOf( + schema.object({ + data_stream: schema.object({ + type: schema.maybe(schema.string()), + dataset: schema.string(), + }), + enabled: schema.maybe(schema.boolean()), + keep_enabled: schema.maybe(schema.boolean()), + vars: varsSchema, + }) + ) + ), + }) + ) + ), + }) + ), + }, + { + validate: (policy) => { + if (policy.data_output_id !== policy.monitoring_output_id) { + return 'Currently Fleet only support one output per agent policy data_output_id should be the same as monitoring_output_id.'; + } + }, + } + ), { defaultValue: [DEFAULT_AGENT_POLICY, DEFAULT_FLEET_SERVER_AGENT_POLICY], } diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts index 9a9273e43f6f..29b0fb1352e5 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts @@ -27,12 +27,18 @@ interface AxisConfig { hide?: boolean; } -export type YAxisMode = 'auto' | 'left' | 'right'; +export type YAxisMode = 'auto' | 'left' | 'right' | 'bottom'; +export type LineStyle = 'solid' | 'dashed' | 'dotted'; +export type FillStyle = 'none' | 'above' | 'below'; export interface YConfig { forAccessor: string; axisMode?: YAxisMode; color?: string; + icon?: string; + lineWidth?: number; + lineStyle?: LineStyle; + fill?: FillStyle; } export type AxisTitlesVisibilityConfigResult = AxesSettingsConfig & { @@ -161,6 +167,24 @@ export const yAxisConfig: ExpressionFunctionDefinition< types: ['string'], help: 'The color of the series', }, + lineStyle: { + types: ['string'], + options: ['solid', 'dotted', 'dashed'], + help: 'The style of the threshold line', + }, + lineWidth: { + types: ['number'], + help: 'The width of the threshold line', + }, + icon: { + types: ['string'], + help: 'An optional icon used for threshold lines', + }, + fill: { + types: ['string'], + options: ['none', 'above', 'below'], + help: '', + }, }, fn: function fn(input: unknown, args: YConfig) { return { diff --git a/x-pack/plugins/lens/public/assets/chart_bar_threshold.tsx b/x-pack/plugins/lens/public/assets/chart_bar_threshold.tsx new file mode 100644 index 000000000000..88e0a46b5538 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_bar_threshold.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIconProps } from '@elastic/eui'; + +export const LensIconChartBarThreshold = ({ + title, + titleId, + ...props +}: Omit) => ( + + {title ? {title} : null} + + + + + +); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx index 0259acc4dcca..69e4aa629cec 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { EuiToolTip, EuiButton, @@ -38,12 +38,17 @@ export function AddLayerButton({ }: AddLayerButtonProps) { const [showLayersChoice, toggleLayersChoice] = useState(false); - const hasMultipleLayers = Boolean(visualization.appendLayer && visualizationState); - if (!hasMultipleLayers) { + const supportedLayers = useMemo(() => { + if (!visualization.appendLayer || !visualizationState) { + return null; + } + return visualization.getSupportedLayers?.(visualizationState, layersMeta); + }, [visualization, visualizationState, layersMeta]); + + if (supportedLayers == null) { return null; } - const supportedLayers = visualization.getSupportedLayers?.(visualizationState, layersMeta); - if (supportedLayers?.length === 1) { + if (supportedLayers.length === 1) { return ( new Promise((r) => setTimeout(r, time)); + let container: HTMLDivElement | undefined; beforeEach(() => { @@ -137,7 +141,7 @@ describe('ConfigPanel', () => { const updater = () => 'updated'; updateDatasource('mockindexpattern', updater); - await new Promise((r) => setTimeout(r, 0)); + await waitMs(0); expect(lensStore.dispatch).toHaveBeenCalledTimes(1); expect( (lensStore.dispatch as jest.Mock).mock.calls[0][0].payload.updater( @@ -147,7 +151,7 @@ describe('ConfigPanel', () => { updateAll('mockindexpattern', updater, props.visualizationState); // wait for one tick so async updater has a chance to trigger - await new Promise((r) => setTimeout(r, 0)); + await waitMs(0); expect(lensStore.dispatch).toHaveBeenCalledTimes(2); expect( (lensStore.dispatch as jest.Mock).mock.calls[0][0].payload.updater( @@ -293,4 +297,164 @@ describe('ConfigPanel', () => { expect(focusedEl?.children[0].getAttribute('data-test-subj')).toEqual('lns-layerPanel-1'); }); }); + + describe('initial default value', () => { + function prepareAndMountComponent(props: ReturnType) { + (generateId as jest.Mock).mockReturnValue(`newId`); + return mountWithProvider( + , + + { + preloadedState: { + datasourceStates: { + mockindexpattern: { + isLoading: false, + state: 'state', + }, + }, + activeDatasourceId: 'mockindexpattern', + }, + }, + { + attachTo: container, + } + ); + } + function clickToAddLayer(instance: ReactWrapper) { + act(() => { + instance.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click'); + }); + instance.update(); + act(() => { + instance + .find(`[data-test-subj="lnsLayerAddButton-${layerTypes.THRESHOLD}"]`) + .first() + .simulate('click'); + }); + instance.update(); + + return waitMs(0); + } + + function clickToAddDimension(instance: ReactWrapper) { + act(() => { + instance.find('[data-test-subj="lns-empty-dimension"]').last().simulate('click'); + }); + return waitMs(0); + } + + it('should not add an initial dimension when not specified', async () => { + const props = getDefaultProps(); + props.activeVisualization.getSupportedLayers = jest.fn(() => [ + { type: layerTypes.DATA, label: 'Data Layer' }, + { + type: layerTypes.THRESHOLD, + label: 'Threshold layer', + }, + ]); + mockDatasource.initializeDimension = jest.fn(); + + const { instance, lensStore } = await prepareAndMountComponent(props); + await clickToAddLayer(instance); + + expect(lensStore.dispatch).toHaveBeenCalledTimes(1); + expect(mockDatasource.initializeDimension).not.toHaveBeenCalled(); + }); + + it('should not add an initial dimension when initialDimensions are not available for the given layer type', async () => { + const props = getDefaultProps(); + props.activeVisualization.getSupportedLayers = jest.fn(() => [ + { + type: layerTypes.DATA, + label: 'Data Layer', + initialDimensions: [ + { + groupId: 'testGroup', + columnId: 'myColumn', + dataType: 'number', + label: 'Initial value', + staticValue: 100, + }, + ], + }, + { + type: layerTypes.THRESHOLD, + label: 'Threshold layer', + }, + ]); + mockDatasource.initializeDimension = jest.fn(); + + const { instance, lensStore } = await prepareAndMountComponent(props); + await clickToAddLayer(instance); + + expect(lensStore.dispatch).toHaveBeenCalledTimes(1); + expect(mockDatasource.initializeDimension).not.toHaveBeenCalled(); + }); + + it('should use group initial dimension value when adding a new layer if available', async () => { + const props = getDefaultProps(); + props.activeVisualization.getSupportedLayers = jest.fn(() => [ + { type: layerTypes.DATA, label: 'Data Layer' }, + { + type: layerTypes.THRESHOLD, + label: 'Threshold layer', + initialDimensions: [ + { + groupId: 'testGroup', + columnId: 'myColumn', + dataType: 'number', + label: 'Initial value', + staticValue: 100, + }, + ], + }, + ]); + mockDatasource.initializeDimension = jest.fn(); + + const { instance, lensStore } = await prepareAndMountComponent(props); + await clickToAddLayer(instance); + + expect(lensStore.dispatch).toHaveBeenCalledTimes(1); + expect(mockDatasource.initializeDimension).toHaveBeenCalledWith(undefined, 'newId', { + columnId: 'myColumn', + dataType: 'number', + groupId: 'testGroup', + label: 'Initial value', + staticValue: 100, + }); + }); + + it('should add an initial dimension value when clicking on the empty dimension button', async () => { + const props = getDefaultProps(); + props.activeVisualization.getSupportedLayers = jest.fn(() => [ + { + type: layerTypes.DATA, + label: 'Data Layer', + initialDimensions: [ + { + groupId: 'a', + columnId: 'newId', + dataType: 'number', + label: 'Initial value', + staticValue: 100, + }, + ], + }, + ]); + mockDatasource.initializeDimension = jest.fn(); + + const { instance, lensStore } = await prepareAndMountComponent(props); + + await clickToAddDimension(instance); + expect(lensStore.dispatch).toHaveBeenCalledTimes(1); + + expect(mockDatasource.initializeDimension).toHaveBeenCalledWith('state', 'first', { + groupId: 'a', + columnId: 'newId', + dataType: 'number', + label: 'Initial value', + staticValue: 100, + }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index f7fe2beefa96..57e4cf5b8dff 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -26,8 +26,9 @@ import { useLensSelector, selectVisualization, VisualizationState, + LensAppState, } from '../../../state_management'; -import { AddLayerButton } from './add_layer'; +import { AddLayerButton, getLayerType } from './add_layer'; export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { const visualization = useLensSelector(selectVisualization); @@ -177,6 +178,33 @@ export function LayerPanels( layerIds.length ) === 'clear' } + onEmptyDimensionAdd={(columnId, { groupId }) => { + // avoid state update if the datasource does not support initializeDimension + if ( + activeDatasourceId != null && + datasourceMap[activeDatasourceId]?.initializeDimension + ) { + dispatchLens( + updateState({ + subType: 'LAYER_DEFAULT_DIMENSION', + updater: (state) => + addInitialValueIfAvailable({ + ...props, + state, + activeDatasourceId, + layerId, + layerType: getLayerType( + activeVisualization, + state.visualization.state, + layerId + ), + columnId, + groupId, + }), + }) + ); + } + }} onRemoveLayer={() => { dispatchLens( updateState({ @@ -232,21 +260,92 @@ export function LayerPanels( dispatchLens( updateState({ subType: 'ADD_LAYER', - updater: (state) => - appendLayer({ + updater: (state) => { + const newState = appendLayer({ activeVisualization, generateId: () => id, trackUiEvent, activeDatasource: datasourceMap[activeDatasourceId!], state, layerType, - }), + }); + return addInitialValueIfAvailable({ + ...props, + activeDatasourceId: activeDatasourceId!, + state: newState, + layerId: id, + layerType, + }); + }, }) ); - setNextFocusedLayerId(id); }} /> ); } + +function addInitialValueIfAvailable({ + state, + activeVisualization, + framePublicAPI, + layerType, + activeDatasourceId, + datasourceMap, + layerId, + columnId, + groupId, +}: ConfigPanelWrapperProps & { + state: LensAppState; + activeDatasourceId: string; + activeVisualization: Visualization; + layerId: string; + layerType: string; + columnId?: string; + groupId?: string; +}) { + const layerInfo = activeVisualization + .getSupportedLayers(state.visualization.state, framePublicAPI) + .find(({ type }) => type === layerType); + + const activeDatasource = datasourceMap[activeDatasourceId]; + + if (layerInfo?.initialDimensions && activeDatasource?.initializeDimension) { + const info = groupId + ? layerInfo.initialDimensions.find(({ groupId: id }) => id === groupId) + : // pick the first available one if not passed + layerInfo.initialDimensions[0]; + + if (info) { + return { + ...state, + datasourceStates: { + ...state.datasourceStates, + [activeDatasourceId]: { + ...state.datasourceStates[activeDatasourceId], + state: activeDatasource.initializeDimension( + state.datasourceStates[activeDatasourceId].state, + layerId, + { + ...info, + columnId: columnId || info.columnId, + } + ), + }, + }, + visualization: { + ...state.visualization, + state: activeVisualization.setDimension({ + groupId: info.groupId, + layerId, + columnId: columnId || info.columnId, + prevState: state.visualization.state, + frame: framePublicAPI, + }), + }, + }; + } + } + return state; +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index 13b7b8cfecf5..f777fd0976df 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -83,6 +83,7 @@ describe('LayerPanel', () => { registerNewLayerRef: jest.fn(), isFullscreen: false, toggleFullscreen: jest.fn(), + onEmptyDimensionAdd: jest.fn(), }; } @@ -920,4 +921,33 @@ describe('LayerPanel', () => { expect(updateVisualization).toHaveBeenCalledTimes(1); }); }); + + describe('add a new dimension', () => { + it('should call onEmptyDimensionAdd callback on new dimension creation', async () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + }, + ], + }); + const props = getDefaultProps(); + const { instance } = await mountWithProvider(); + + act(() => { + instance.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); + }); + instance.update(); + + expect(props.onEmptyDimensionAdd).toHaveBeenCalledWith( + 'newid', + expect.objectContaining({ groupId: 'a' }) + ); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 520c2bc837c6..8c947d3502f9 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -57,6 +57,7 @@ export function LayerPanel( onRemoveLayer: () => void; registerNewLayerRef: (layerId: string, instance: HTMLDivElement | null) => void; toggleFullscreen: () => void; + onEmptyDimensionAdd: (columnId: string, group: { groupId: string }) => void; } ) { const [activeDimension, setActiveDimension] = useState( @@ -124,7 +125,11 @@ export function LayerPanel( dateRange, }; - const { groups, supportStaticValue } = useMemo( + const { + groups, + supportStaticValue, + supportFieldFormat = true, + } = useMemo( () => activeVisualization.getConfiguration(layerVisualizationConfigProps), // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -227,13 +232,25 @@ export function LayerPanel( const isDimensionPanelOpen = Boolean(activeId); const updateDataLayerState = useCallback( - (newState: unknown, { isDimensionComplete = true }: { isDimensionComplete?: boolean } = {}) => { + ( + newState: unknown, + { + isDimensionComplete = true, + // this flag is a hack to force a sync render where it was planned an async/setTimeout state update + // TODO: revisit this once we get rid of updateDatasourceAsync upstream + forceRender = false, + }: { isDimensionComplete?: boolean; forceRender?: boolean } = {} + ) => { if (!activeGroup || !activeId) { return; } if (allAccessors.includes(activeId)) { if (isDimensionComplete) { - updateDatasourceAsync(datasourceId, newState); + if (forceRender) { + updateDatasource(datasourceId, newState); + } else { + updateDatasourceAsync(datasourceId, newState); + } } else { // The datasource can indicate that the previously-valid column is no longer // complete, which clears the visualization. This keeps the flyout open and reuses @@ -263,7 +280,11 @@ export function LayerPanel( ); setActiveDimension({ ...activeDimension, isNew: false }); } else { - updateDatasourceAsync(datasourceId, newState); + if (forceRender) { + updateDatasource(datasourceId, newState); + } else { + updateDatasourceAsync(datasourceId, newState); + } } }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -295,11 +316,10 @@ export function LayerPanel( hasBorder hasShadow > -
+
)} -
+
{groups.map((group, groupIndex) => { const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; @@ -460,6 +480,8 @@ export function LayerPanel( columnId: accessorConfig.columnId, groupId: group.groupId, filterOperations: group.filterOperations, + invalid: group.invalid, + invalidMessage: group.invalidMessage, }} /> @@ -478,6 +500,7 @@ export function LayerPanel( layerDatasource={layerDatasource} layerDatasourceDropProps={layerDatasourceDropProps} onClick={(id) => { + props.onEmptyDimensionAdd(id, group); setActiveDimension({ activeGroup: group, activeId: id, @@ -538,6 +561,8 @@ export function LayerPanel( toggleFullscreen, isFullscreen, setState: updateDataLayerState, + supportStaticValue: Boolean(supportStaticValue), + supportFieldFormat: Boolean(supportFieldFormat), layerType: activeVisualization.getLayerType(layerId, visualizationState), }} /> diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.test.tsx new file mode 100644 index 000000000000..04c430143a3c --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + createMockFramePublicAPI, + createMockVisualization, + mountWithProvider, +} from '../../../mocks'; +import { Visualization } from '../../../types'; +import { LayerSettings } from './layer_settings'; + +describe('LayerSettings', () => { + let mockVisualization: jest.Mocked; + const frame = createMockFramePublicAPI(); + + function getDefaultProps() { + return { + activeVisualization: mockVisualization, + layerConfigProps: { + layerId: 'myLayer', + state: {}, + frame, + dateRange: { fromDate: 'now-7d', toDate: 'now' }, + activeData: frame.activeData, + setState: jest.fn(), + }, + }; + } + + beforeEach(() => { + mockVisualization = { + ...createMockVisualization(), + id: 'testVis', + visualizationTypes: [ + { + icon: 'empty', + id: 'testVis', + label: 'TEST1', + groupLabel: 'testVisGroup', + }, + ], + }; + }); + + it('should render nothing with no custom renderer nor description', async () => { + // @ts-expect-error + mockVisualization.getDescription.mockReturnValue(undefined); + const { instance } = await mountWithProvider(); + expect(instance.html()).toBe(null); + }); + + it('should render a static header if visualization has only a description value', async () => { + mockVisualization.getDescription.mockReturnValue({ + icon: 'myIcon', + label: 'myVisualizationType', + }); + const { instance } = await mountWithProvider(); + expect(instance.find('StaticHeader').first().prop('label')).toBe('myVisualizationType'); + }); + + it('should call the custom renderer if available', async () => { + mockVisualization.renderLayerHeader = jest.fn(); + await mountWithProvider(); + expect(mockVisualization.renderLayerHeader).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx index 467b1ecfe1b5..fc88ff2af8bb 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx @@ -6,44 +6,23 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle } from '@elastic/eui'; import { NativeRenderer } from '../../../native_renderer'; import { Visualization, VisualizationLayerWidgetProps } from '../../../types'; +import { StaticHeader } from '../../../shared_components'; export function LayerSettings({ - layerId, activeVisualization, layerConfigProps, }: { - layerId: string; activeVisualization: Visualization; layerConfigProps: VisualizationLayerWidgetProps; }) { - const description = activeVisualization.getDescription(layerConfigProps.state); - if (!activeVisualization.renderLayerHeader) { + const description = activeVisualization.getDescription(layerConfigProps.state); if (!description) { return null; } - return ( - - {description.icon && ( - - {' '} - - )} - - -
{description.label}
-
-
-
- ); + return ; } return ( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts index 632989057b48..90fa2ab080dd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts @@ -45,21 +45,22 @@ describe('suggestion helpers', () => { generateSuggestion(), ]); const suggestedState = {}; - const suggestions = getSuggestions({ - visualizationMap: { - vis1: { - ...mockVisualization, - getSuggestions: () => [ - { - score: 0.5, - title: 'Test', - state: suggestedState, - previewIcon: 'empty', - }, - ], - }, + const visualizationMap = { + vis1: { + ...mockVisualization, + getSuggestions: () => [ + { + score: 0.5, + title: 'Test', + state: suggestedState, + previewIcon: 'empty', + }, + ], }, - activeVisualizationId: 'vis1', + }; + const suggestions = getSuggestions({ + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -74,38 +75,39 @@ describe('suggestion helpers', () => { datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ generateSuggestion(), ]); - const suggestions = getSuggestions({ - visualizationMap: { - vis1: { - ...mockVisualization1, - getSuggestions: () => [ - { - score: 0.5, - title: 'Test', - state: {}, - previewIcon: 'empty', - }, - { - score: 0.5, - title: 'Test2', - state: {}, - previewIcon: 'empty', - }, - ], - }, - vis2: { - ...mockVisualization2, - getSuggestions: () => [ - { - score: 0.5, - title: 'Test3', - state: {}, - previewIcon: 'empty', - }, - ], - }, + const visualizationMap = { + vis1: { + ...mockVisualization1, + getSuggestions: () => [ + { + score: 0.5, + title: 'Test', + state: {}, + previewIcon: 'empty', + }, + { + score: 0.5, + title: 'Test2', + state: {}, + previewIcon: 'empty', + }, + ], + }, + vis2: { + ...mockVisualization2, + getSuggestions: () => [ + { + score: 0.5, + title: 'Test3', + state: {}, + previewIcon: 'empty', + }, + ], }, - activeVisualizationId: 'vis1', + }; + const suggestions = getSuggestions({ + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -116,11 +118,12 @@ describe('suggestion helpers', () => { it('should call getDatasourceSuggestionsForField when a field is passed', () => { datasourceMap.mock.getDatasourceSuggestionsForField.mockReturnValue([generateSuggestion()]); const droppedField = {}; + const visualizationMap = { + vis1: createMockVisualization(), + }; getSuggestions({ - visualizationMap: { - vis1: createMockVisualization(), - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -128,7 +131,8 @@ describe('suggestion helpers', () => { }); expect(datasourceMap.mock.getDatasourceSuggestionsForField).toHaveBeenCalledWith( datasourceStates.mock.state, - droppedField + droppedField, + expect.any(Function) ); }); @@ -148,12 +152,13 @@ describe('suggestion helpers', () => { mock2: createMockDatasource('a'), mock3: createMockDatasource('a'), }; + const visualizationMap = { + vis1: createMockVisualization(), + }; const droppedField = {}; getSuggestions({ - visualizationMap: { - vis1: createMockVisualization(), - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap: multiDatasourceMap, datasourceStates: multiDatasourceStates, @@ -161,11 +166,13 @@ describe('suggestion helpers', () => { }); expect(multiDatasourceMap.mock.getDatasourceSuggestionsForField).toHaveBeenCalledWith( multiDatasourceStates.mock.state, - droppedField + droppedField, + expect.any(Function) ); expect(multiDatasourceMap.mock2.getDatasourceSuggestionsForField).toHaveBeenCalledWith( multiDatasourceStates.mock2.state, - droppedField + droppedField, + expect.any(Function) ); expect(multiDatasourceMap.mock3.getDatasourceSuggestionsForField).not.toHaveBeenCalled(); }); @@ -174,11 +181,14 @@ describe('suggestion helpers', () => { datasourceMap.mock.getDatasourceSuggestionsForVisualizeField.mockReturnValue([ generateSuggestion(), ]); + + const visualizationMap = { + vis1: createMockVisualization(), + }; + getSuggestions({ - visualizationMap: { - vis1: createMockVisualization(), - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -214,11 +224,13 @@ describe('suggestion helpers', () => { indexPatternId: '1', fieldName: 'test', }; + + const visualizationMap = { + vis1: createMockVisualization(), + }; getSuggestions({ - visualizationMap: { - vis1: createMockVisualization(), - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap: multiDatasourceMap, datasourceStates: multiDatasourceStates, @@ -245,38 +257,39 @@ describe('suggestion helpers', () => { datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ generateSuggestion(), ]); - const suggestions = getSuggestions({ - visualizationMap: { - vis1: { - ...mockVisualization1, - getSuggestions: () => [ - { - score: 0.2, - title: 'Test', - state: {}, - previewIcon: 'empty', - }, - { - score: 0.8, - title: 'Test2', - state: {}, - previewIcon: 'empty', - }, - ], - }, - vis2: { - ...mockVisualization2, - getSuggestions: () => [ - { - score: 0.6, - title: 'Test3', - state: {}, - previewIcon: 'empty', - }, - ], - }, + const visualizationMap = { + vis1: { + ...mockVisualization1, + getSuggestions: () => [ + { + score: 0.2, + title: 'Test', + state: {}, + previewIcon: 'empty', + }, + { + score: 0.8, + title: 'Test2', + state: {}, + previewIcon: 'empty', + }, + ], }, - activeVisualizationId: 'vis1', + vis2: { + ...mockVisualization2, + getSuggestions: () => [ + { + score: 0.6, + title: 'Test3', + state: {}, + previewIcon: 'empty', + }, + ], + }, + }; + const suggestions = getSuggestions({ + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -305,12 +318,13 @@ describe('suggestion helpers', () => { { state: {}, table: table1, keptLayerIds: ['first'] }, { state: {}, table: table2, keptLayerIds: ['first'] }, ]); + const visualizationMap = { + vis1: mockVisualization1, + vis2: mockVisualization2, + }; getSuggestions({ - visualizationMap: { - vis1: mockVisualization1, - vis2: mockVisualization2, - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -357,18 +371,20 @@ describe('suggestion helpers', () => { previewIcon: 'empty', }, ]); - const suggestions = getSuggestions({ - visualizationMap: { - vis1: { - ...mockVisualization1, - getSuggestions: vis1Suggestions, - }, - vis2: { - ...mockVisualization2, - getSuggestions: vis2Suggestions, - }, + const visualizationMap = { + vis1: { + ...mockVisualization1, + getSuggestions: vis1Suggestions, }, - activeVisualizationId: 'vis1', + vis2: { + ...mockVisualization2, + getSuggestions: vis2Suggestions, + }, + }; + + const suggestions = getSuggestions({ + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -389,12 +405,15 @@ describe('suggestion helpers', () => { generateSuggestion(0), generateSuggestion(1), ]); + + const visualizationMap = { + vis1: mockVisualization1, + vis2: mockVisualization2, + }; + getSuggestions({ - visualizationMap: { - vis1: mockVisualization1, - vis2: mockVisualization2, - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -419,12 +438,13 @@ describe('suggestion helpers', () => { generateSuggestion(0), generateSuggestion(1), ]); + const visualizationMap = { + vis1: mockVisualization1, + vis2: mockVisualization2, + }; getSuggestions({ - visualizationMap: { - vis1: mockVisualization1, - vis2: mockVisualization2, - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -451,12 +471,14 @@ describe('suggestion helpers', () => { generateSuggestion(0), generateSuggestion(1), ]); + const visualizationMap = { + vis1: mockVisualization1, + vis2: mockVisualization2, + }; + getSuggestions({ - visualizationMap: { - vis1: mockVisualization1, - vis2: mockVisualization2, - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -538,7 +560,8 @@ describe('suggestion helpers', () => { humanData: { label: 'myfieldLabel', }, - } + }, + expect.any(Function) ); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index 2f3fe3795a88..a5c7871f33df 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -58,7 +58,7 @@ export function getSuggestions({ datasourceMap, datasourceStates, visualizationMap, - activeVisualizationId, + activeVisualization, subVisualizationId, visualizationState, field, @@ -69,7 +69,7 @@ export function getSuggestions({ datasourceMap: DatasourceMap; datasourceStates: DatasourceStates; visualizationMap: VisualizationMap; - activeVisualizationId: string | null; + activeVisualization?: Visualization; subVisualizationId?: string; visualizationState: unknown; field?: unknown; @@ -83,16 +83,12 @@ export function getSuggestions({ const layerTypesMap = datasources.reduce((memo, [datasourceId, datasource]) => { const datasourceState = datasourceStates[datasourceId].state; - if (!activeVisualizationId || !datasourceState || !visualizationMap[activeVisualizationId]) { + if (!activeVisualization || !datasourceState) { return memo; } const layers = datasource.getLayers(datasourceState); for (const layerId of layers) { - const type = getLayerType( - visualizationMap[activeVisualizationId], - visualizationState, - layerId - ); + const type = getLayerType(activeVisualization, visualizationState, layerId); memo[layerId] = type; } return memo; @@ -112,7 +108,11 @@ export function getSuggestions({ visualizeTriggerFieldContext.fieldName ); } else if (field) { - dataSourceSuggestions = datasource.getDatasourceSuggestionsForField(datasourceState, field); + dataSourceSuggestions = datasource.getDatasourceSuggestionsForField( + datasourceState, + field, + (layerId) => isLayerSupportedByVisualization(layerId, [layerTypes.DATA]) // a field dragged to workspace should added to data layer + ); } else { dataSourceSuggestions = datasource.getDatasourceSuggestionsFromCurrentState( datasourceState, @@ -121,7 +121,6 @@ export function getSuggestions({ } return dataSourceSuggestions.map((suggestion) => ({ ...suggestion, datasourceId })); }); - // Pass all table suggestions to all visualization extensions to get visualization suggestions // and rank them by score return Object.entries(visualizationMap) @@ -139,12 +138,8 @@ export function getSuggestions({ .flatMap((datasourceSuggestion) => { const table = datasourceSuggestion.table; const currentVisualizationState = - visualizationId === activeVisualizationId ? visualizationState : undefined; - const palette = - mainPalette || - (activeVisualizationId && visualizationMap[activeVisualizationId]?.getMainPalette - ? visualizationMap[activeVisualizationId].getMainPalette?.(visualizationState) - : undefined); + visualizationId === activeVisualization?.id ? visualizationState : undefined; + const palette = mainPalette || activeVisualization?.getMainPalette?.(visualizationState); return getVisualizationSuggestions( visualization, @@ -169,14 +164,14 @@ export function getVisualizeFieldSuggestions({ datasourceMap, datasourceStates, visualizationMap, - activeVisualizationId, + activeVisualization, visualizationState, visualizeTriggerFieldContext, }: { datasourceMap: DatasourceMap; datasourceStates: DatasourceStates; visualizationMap: VisualizationMap; - activeVisualizationId: string | null; + activeVisualization: Visualization; subVisualizationId?: string; visualizationState: unknown; visualizeTriggerFieldContext?: VisualizeFieldContext; @@ -185,12 +180,12 @@ export function getVisualizeFieldSuggestions({ datasourceMap, datasourceStates, visualizationMap, - activeVisualizationId, + activeVisualization, visualizationState, visualizeTriggerFieldContext, }); if (suggestions.length) { - return suggestions.find((s) => s.visualizationId === activeVisualizationId) || suggestions[0]; + return suggestions.find((s) => s.visualizationId === activeVisualization?.id) || suggestions[0]; } } @@ -263,18 +258,19 @@ export function getTopSuggestionForField( (datasourceLayer) => datasourceLayer.getTableSpec().length > 0 ); - const mainPalette = - visualization.activeId && visualizationMap[visualization.activeId]?.getMainPalette - ? visualizationMap[visualization.activeId].getMainPalette?.(visualization.state) - : undefined; + const activeVisualization = visualization.activeId + ? visualizationMap[visualization.activeId] + : undefined; + + const mainPalette = activeVisualization?.getMainPalette?.(visualization.state); const suggestions = getSuggestions({ datasourceMap: { [datasource.id]: datasource }, datasourceStates, visualizationMap: hasData && visualization.activeId - ? { [visualization.activeId]: visualizationMap[visualization.activeId] } + ? { [visualization.activeId]: activeVisualization! } : visualizationMap, - activeVisualizationId: visualization.activeId, + activeVisualization, visualizationState: visualization.state, field, mainPalette, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index 858fcedf215e..5e5e19ea29e8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -201,7 +201,9 @@ export function SuggestionPanel({ datasourceMap, datasourceStates: currentDatasourceStates, visualizationMap, - activeVisualizationId: currentVisualization.activeId, + activeVisualization: currentVisualization.activeId + ? visualizationMap[currentVisualization.activeId] + : undefined, visualizationState: currentVisualization.state, activeData, }) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index 28c0567d784e..51d4f2955a52 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -515,11 +515,14 @@ function getTopSuggestion( props.visualizationMap[visualization.activeId].getMainPalette ? props.visualizationMap[visualization.activeId].getMainPalette!(visualization.state) : undefined; + const unfilteredSuggestions = getSuggestions({ datasourceMap: props.datasourceMap, datasourceStates, visualizationMap: { [visualizationId]: newVisualization }, - activeVisualizationId: visualization.activeId, + activeVisualization: visualization.activeId + ? props.visualizationMap[visualization.activeId] + : undefined, visualizationState: visualization.state, subVisualizationId, activeData: props.framePublicAPI.activeData, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index e386bac026fd..d25e6754fe03 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -11,15 +11,11 @@ import { i18n } from '@kbn/i18n'; import { EuiListGroup, EuiFormRow, - EuiFieldText, EuiSpacer, EuiListGroupItemProps, EuiFormLabel, EuiToolTip, EuiText, - EuiTabs, - EuiTab, - EuiCallOut, } from '@elastic/eui'; import { IndexPatternDimensionEditorProps } from './dimension_panel'; import { OperationSupportMatrix } from './operation_support'; @@ -47,41 +43,29 @@ import { setTimeScaling, TimeScaling } from './time_scaling'; import { defaultFilter, Filtering, setFilter } from './filtering'; import { AdvancedOptions } from './advanced_options'; import { setTimeShift, TimeShift } from './time_shift'; -import { useDebouncedValue } from '../../shared_components'; +import { LayerType } from '../../../common'; +import { + quickFunctionsName, + staticValueOperationName, + isQuickFunction, + getParamEditor, + formulaOperationName, + DimensionEditorTabs, + CalloutWarning, + LabelInput, + getErrorMessage, +} from './dimensions_editor_helpers'; +import type { TemporaryState } from './dimensions_editor_helpers'; const operationPanels = getOperationDisplay(); export interface DimensionEditorProps extends IndexPatternDimensionEditorProps { selectedColumn?: IndexPatternColumn; + layerType: LayerType; operationSupportMatrix: OperationSupportMatrix; currentIndexPattern: IndexPattern; } -const LabelInput = ({ value, onChange }: { value: string; onChange: (value: string) => void }) => { - const { inputValue, handleInputChange, initialValue } = useDebouncedValue({ onChange, value }); - - return ( - - { - handleInputChange(e.target.value); - }} - placeholder={initialValue} - /> - - ); -}; - export function DimensionEditor(props: DimensionEditorProps) { const { selectedColumn, @@ -96,6 +80,8 @@ export function DimensionEditor(props: DimensionEditorProps) { dimensionGroups, toggleFullscreen, isFullscreen, + supportStaticValue, + supportFieldFormat = true, layerType, } = props; const services = { @@ -110,6 +96,11 @@ export function DimensionEditor(props: DimensionEditorProps) { const selectedOperationDefinition = selectedColumn && operationDefinitionMap[selectedColumn.operationType]; + const [temporaryState, setTemporaryState] = useState('none'); + + const temporaryQuickFunction = Boolean(temporaryState === quickFunctionsName); + const temporaryStaticValue = Boolean(temporaryState === staticValueOperationName); + const updateLayer = useCallback( (newLayer) => setState((prevState) => mergeLayer({ state: prevState, layerId, newLayer })), [layerId, setState] @@ -141,9 +132,64 @@ export function DimensionEditor(props: DimensionEditorProps) { ...incompleteParams } = incompleteInfo || {}; - const ParamEditor = selectedOperationDefinition?.paramEditor; + const isQuickFunctionSelected = Boolean( + supportStaticValue + ? selectedOperationDefinition && isQuickFunction(selectedOperationDefinition.type) + : !selectedOperationDefinition || isQuickFunction(selectedOperationDefinition.type) + ); + const showQuickFunctions = temporaryQuickFunction || isQuickFunctionSelected; + + const showStaticValueFunction = + temporaryStaticValue || + (temporaryState === 'none' && + supportStaticValue && + (!selectedColumn || selectedColumn?.operationType === staticValueOperationName)); + + const addStaticValueColumn = (prevLayer = props.state.layers[props.layerId]) => { + if (selectedColumn?.operationType !== staticValueOperationName) { + trackUiEvent(`indexpattern_dimension_operation_static_value`); + return insertOrReplaceColumn({ + layer: prevLayer, + indexPattern: currentIndexPattern, + columnId, + op: staticValueOperationName, + visualizationGroups: dimensionGroups, + }); + } + return prevLayer; + }; + + // this function intercepts the state update for static value function + // and. if in temporary state, it merges the "add new static value column" state with the incoming + // changes from the static value operation (which has to be a function) + // Note: it forced a rerender at this point to avoid UI glitches in async updates (another hack upstream) + // TODO: revisit this once we get rid of updateDatasourceAsync upstream + const moveDefinetelyToStaticValueAndUpdate = ( + setter: IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) + ) => { + if (temporaryStaticValue) { + setTemporaryState('none'); + if (typeof setter === 'function') { + return setState( + (prevState) => { + const layer = setter(addStaticValueColumn(prevState.layers[layerId])); + return mergeLayer({ state: prevState, layerId, newLayer: layer }); + }, + { + isDimensionComplete: true, + forceRender: true, + } + ); + } + } + return setStateWrapper(setter); + }; - const [temporaryQuickFunction, setQuickFunction] = useState(false); + const ParamEditor = getParamEditor( + temporaryStaticValue, + selectedOperationDefinition, + supportStaticValue && !showQuickFunctions + ); const possibleOperations = useMemo(() => { return Object.values(operationDefinitionMap) @@ -245,9 +291,9 @@ export function DimensionEditor(props: DimensionEditorProps) { [`aria-pressed`]: isActive, onClick() { if ( - operationDefinitionMap[operationType].input === 'none' || - operationDefinitionMap[operationType].input === 'managedReference' || - operationDefinitionMap[operationType].input === 'fullReference' + ['none', 'fullReference', 'managedReference'].includes( + operationDefinitionMap[operationType].input + ) ) { // Clear invalid state because we are reseting to a valid column if (selectedColumn?.operationType === operationType) { @@ -264,9 +310,12 @@ export function DimensionEditor(props: DimensionEditorProps) { visualizationGroups: dimensionGroups, targetGroup: props.groupId, }); - if (temporaryQuickFunction && newLayer.columns[columnId].operationType !== 'formula') { + if ( + temporaryQuickFunction && + isQuickFunction(newLayer.columns[columnId].operationType) + ) { // Only switch the tab once the formula is fully removed - setQuickFunction(false); + setTemporaryState('none'); } setStateWrapper(newLayer); trackUiEvent(`indexpattern_dimension_operation_${operationType}`); @@ -297,9 +346,12 @@ export function DimensionEditor(props: DimensionEditorProps) { }); // ); } - if (temporaryQuickFunction && newLayer.columns[columnId].operationType !== 'formula') { + if ( + temporaryQuickFunction && + isQuickFunction(newLayer.columns[columnId].operationType) + ) { // Only switch the tab once the formula is fully removed - setQuickFunction(false); + setTemporaryState('none'); } setStateWrapper(newLayer); trackUiEvent(`indexpattern_dimension_operation_${operationType}`); @@ -314,7 +366,7 @@ export function DimensionEditor(props: DimensionEditorProps) { } if (temporaryQuickFunction) { - setQuickFunction(false); + setTemporaryState('none'); } const newLayer = replaceColumn({ layer: props.state.layers[props.layerId], @@ -348,29 +400,10 @@ export function DimensionEditor(props: DimensionEditorProps) { !currentFieldIsInvalid && !incompleteInfo && selectedColumn && - selectedColumn.operationType !== 'formula'; + isQuickFunction(selectedColumn.operationType); const quickFunctions = ( <> - {temporaryQuickFunction && selectedColumn?.operationType === 'formula' && ( - <> - -

- {i18n.translate('xpack.lens.indexPattern.formulaWarningText', { - defaultMessage: 'To overwrite your formula, select a quick function', - })} -

-
- - )}
{i18n.translate('xpack.lens.indexPattern.functionsLabel', { @@ -608,24 +641,28 @@ export function DimensionEditor(props: DimensionEditorProps) { ); - const formulaTab = ParamEditor ? ( - + const customParamEditor = ParamEditor ? ( + <> + + ) : null; + const TabContent = showQuickFunctions ? quickFunctions : customParamEditor; + const onFormatChange = useCallback( (newFormat) => { updateLayer( @@ -640,58 +677,69 @@ export function DimensionEditor(props: DimensionEditorProps) { [columnId, layerId, state.layers, updateLayer] ); + const hasFormula = + !isFullscreen && operationSupportMatrix.operationWithoutField.has(formulaOperationName); + + const hasTabs = hasFormula || supportStaticValue; + return (
- {!isFullscreen && operationSupportMatrix.operationWithoutField.has('formula') ? ( - - { - if (selectedColumn?.operationType === 'formula') { - setQuickFunction(true); + {hasTabs ? ( + { + if (tabClicked === 'quickFunctions') { + if (selectedColumn && !isQuickFunction(selectedColumn.operationType)) { + setTemporaryState(quickFunctionsName); + return; } - }} - > - {i18n.translate('xpack.lens.indexPattern.quickFunctionsLabel', { - defaultMessage: 'Quick functions', - })} - - { - if (selectedColumn?.operationType !== 'formula') { - setQuickFunction(false); + } + + if (tabClicked === 'static_value') { + // when coming from a formula, set a temporary state + if (selectedColumn?.operationType === formulaOperationName) { + return setTemporaryState(staticValueOperationName); + } + setTemporaryState('none'); + setStateWrapper(addStaticValueColumn()); + return; + } + + if (tabClicked === 'formula') { + setTemporaryState('none'); + if (selectedColumn?.operationType !== formulaOperationName) { const newLayer = insertOrReplaceColumn({ layer: props.state.layers[props.layerId], indexPattern: currentIndexPattern, columnId, - op: 'formula', + op: formulaOperationName, visualizationGroups: dimensionGroups, }); setStateWrapper(newLayer); trackUiEvent(`indexpattern_dimension_operation_formula`); - return; - } else { - setQuickFunction(false); } - }} - > - {i18n.translate('xpack.lens.indexPattern.formulaLabel', { - defaultMessage: 'Formula', - })} - - + } + }} + /> ) : null} - {isFullscreen - ? formulaTab - : selectedOperationDefinition?.type === 'formula' && !temporaryQuickFunction - ? formulaTab - : quickFunctions} + + {TabContent} - {!isFullscreen && !currentFieldIsInvalid && !temporaryQuickFunction && ( + {!isFullscreen && !currentFieldIsInvalid && temporaryState === 'none' && (
{!incompleteInfo && selectedColumn && ( )} - {!isFullscreen && + {supportFieldFormat && + !isFullscreen && selectedColumn && (selectedColumn.dataType === 'number' || selectedColumn.operationType === 'range') ? ( @@ -735,26 +784,3 @@ export function DimensionEditor(props: DimensionEditorProps) {
); } - -function getErrorMessage( - selectedColumn: IndexPatternColumn | undefined, - incompleteOperation: boolean, - input: 'none' | 'field' | 'fullReference' | 'managedReference' | undefined, - fieldInvalid: boolean -) { - if (selectedColumn && incompleteOperation) { - if (input === 'field') { - return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', { - defaultMessage: 'This field does not work with the selected function.', - }); - } - return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', { - defaultMessage: 'To use this function, select a field.', - }); - } - if (fieldInvalid) { - return i18n.translate('xpack.lens.indexPattern.invalidFieldLabel', { - defaultMessage: 'Invalid field. Check your index pattern or pick another field.', - }); - } -} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 5d56661f1591..d823def1da11 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -52,6 +52,13 @@ jest.mock('lodash', () => { }; }); jest.mock('../../id_generator'); +// Mock the Monaco Editor component +jest.mock('../operations/definitions/formula/editor/formula_editor', () => { + return { + WrappedFormulaEditor: () =>
, + FormulaEditor: () =>
, + }; +}); const fields = [ { @@ -211,6 +218,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dimensionGroups: [], groupId: 'a', isFullscreen: false, + supportStaticValue: false, toggleFullscreen: jest.fn(), }; @@ -402,8 +410,9 @@ describe('IndexPatternDimensionEditorPanel', () => { const items: EuiListGroupItemProps[] = wrapper.find(EuiListGroup).prop('listItems') || []; - expect(items.find(({ id }) => id === 'math')).toBeUndefined(); - expect(items.find(({ id }) => id === 'formula')).toBeUndefined(); + ['math', 'formula', 'static_value'].forEach((hiddenOp) => { + expect(items.some(({ id }) => id === hiddenOp)).toBe(false); + }); }); it('should indicate that reference-based operations are not compatible when they are incomplete', () => { @@ -2217,4 +2226,130 @@ describe('IndexPatternDimensionEditorPanel', () => { 0 ); }); + + it('should not show tabs when formula and static_value operations are not available', () => { + const stateWithInvalidCol: IndexPatternPrivateState = getStateWithColumns({ + col1: { + label: 'Average of memory', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'average', + sourceField: 'memory', + params: { + format: { id: 'bytes', params: { decimals: 2 } }, + }, + }, + }); + + const props = { + ...defaultProps, + filterOperations: jest.fn((op) => { + // the formula operation will fall into this metadata category + return !(op.dataType === 'number' && op.scale === 'ratio'); + }), + }; + + wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="lens-dimensionTabs"]').exists()).toBeFalsy(); + }); + + it('should show the formula tab when supported', () => { + const stateWithFormulaColumn: IndexPatternPrivateState = getStateWithColumns({ + col1: { + label: 'Formula', + dataType: 'number', + isBucketed: false, + operationType: 'formula', + references: ['ref1'], + params: {}, + }, + }); + + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="lens-dimensionTabs-formula"]').first().prop('isSelected') + ).toBeTruthy(); + }); + + it('should now show the static_value tab when not supported', () => { + const stateWithFormulaColumn: IndexPatternPrivateState = getStateWithColumns({ + col1: { + label: 'Formula', + dataType: 'number', + isBucketed: false, + operationType: 'formula', + references: ['ref1'], + params: {}, + }, + }); + + wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="lens-dimensionTabs-static_value"]').exists()).toBeFalsy(); + }); + + it('should show the static value tab when supported', () => { + const staticWithFormulaColumn: IndexPatternPrivateState = getStateWithColumns({ + col1: { + label: 'Formula', + dataType: 'number', + isBucketed: false, + operationType: 'formula', + references: ['ref1'], + params: {}, + }, + }); + + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="lens-dimensionTabs-static_value"]').exists() + ).toBeTruthy(); + }); + + it('should select the quick function tab by default', () => { + const stateWithNoColumn: IndexPatternPrivateState = getStateWithColumns({}); + + wrapper = mount( + + ); + + expect( + wrapper + .find('[data-test-subj="lens-dimensionTabs-quickFunctions"]') + .first() + .prop('isSelected') + ).toBeTruthy(); + }); + + it('should select the static value tab when supported by default', () => { + const stateWithNoColumn: IndexPatternPrivateState = getStateWithColumns({}); + + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="lens-dimensionTabs-static_value"]').first().prop('isSelected') + ).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx index f3e51516d161..ac8296cca968 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx @@ -16,7 +16,7 @@ import { IndexPatternColumn } from '../indexpattern'; import { isColumnInvalid } from '../utils'; import { IndexPatternPrivateState } from '../types'; import { DimensionEditor } from './dimension_editor'; -import type { DateRange } from '../../../common'; +import { DateRange, layerTypes } from '../../../common'; import { getOperationSupportMatrix } from './operation_support'; export type IndexPatternDimensionTriggerProps = @@ -49,11 +49,11 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens const layerId = props.layerId; const layer = props.state.layers[layerId]; const currentIndexPattern = props.state.indexPatterns[layer.indexPatternId]; - const { columnId, uniqueLabel } = props; + const { columnId, uniqueLabel, invalid, invalidMessage } = props; const currentColumnHasErrors = useMemo( - () => isColumnInvalid(layer, columnId, currentIndexPattern), - [layer, columnId, currentIndexPattern] + () => invalid || isColumnInvalid(layer, columnId, currentIndexPattern), + [layer, columnId, currentIndexPattern, invalid] ); const selectedColumn: IndexPatternColumn | null = layer.columns[props.columnId] ?? null; @@ -67,15 +67,17 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens return ( - {i18n.translate('xpack.lens.configure.invalidConfigTooltip', { - defaultMessage: 'Invalid configuration.', - })} -
- {i18n.translate('xpack.lens.configure.invalidConfigTooltipClick', { - defaultMessage: 'Click for more details.', - })} -

+ invalidMessage ?? ( +

+ {i18n.translate('xpack.lens.configure.invalidConfigTooltip', { + defaultMessage: 'Invalid configuration.', + })} +
+ {i18n.translate('xpack.lens.configure.invalidConfigTooltipClick', { + defaultMessage: 'Click for more details.', + })} +

+ ) } anchorClassName="eui-displayBlock" > @@ -127,6 +129,7 @@ export const IndexPatternDimensionEditorComponent = function IndexPatternDimensi return ( void; +}) => { + const { inputValue, handleInputChange, initialValue } = useDebouncedValue({ onChange, value }); + + return ( + + { + handleInputChange(e.target.value); + }} + placeholder={initialValue} + /> + + ); +}; + +export function getParamEditor( + temporaryStaticValue: boolean, + selectedOperationDefinition: typeof operationDefinitionMap[string] | undefined, + showDefaultStaticValue: boolean +) { + if (temporaryStaticValue) { + return operationDefinitionMap[staticValueOperationName].paramEditor; + } + if (selectedOperationDefinition?.paramEditor) { + return selectedOperationDefinition.paramEditor; + } + if (showDefaultStaticValue) { + return operationDefinitionMap[staticValueOperationName].paramEditor; + } + return null; +} + +export const CalloutWarning = ({ + currentOperationType, + temporaryStateType, +}: { + currentOperationType: keyof typeof operationDefinitionMap | undefined; + temporaryStateType: TemporaryState; +}) => { + if ( + temporaryStateType === 'none' || + (currentOperationType != null && isQuickFunction(currentOperationType)) + ) { + return null; + } + if ( + currentOperationType === staticValueOperationName && + temporaryStateType === 'quickFunctions' + ) { + return ( + <> + +

+ {i18n.translate('xpack.lens.indexPattern.staticValueWarningText', { + defaultMessage: 'To overwrite your static value, select a quick function', + })} +

+
+ + ); + } + return ( + <> + + {temporaryStateType !== 'quickFunctions' ? ( +

+ {i18n.translate('xpack.lens.indexPattern.formulaWarningStaticValueText', { + defaultMessage: 'To overwrite your formula, change the value in the input field', + })} +

+ ) : ( +

+ {i18n.translate('xpack.lens.indexPattern.formulaWarningText', { + defaultMessage: 'To overwrite your formula, select a quick function', + })} +

+ )} +
+ + ); +}; + +type DimensionEditorTabsType = + | typeof quickFunctionsName + | typeof staticValueOperationName + | typeof formulaOperationName; + +export const DimensionEditorTabs = ({ + tabsEnabled, + tabsState, + onClick, +}: { + tabsEnabled: Record; + tabsState: Record; + onClick: (tabClicked: DimensionEditorTabsType) => void; +}) => { + return ( + + {tabsEnabled.static_value ? ( + onClick(staticValueOperationName)} + > + {i18n.translate('xpack.lens.indexPattern.staticValueLabel', { + defaultMessage: 'Static value', + })} + + ) : null} + onClick(quickFunctionsName)} + > + {i18n.translate('xpack.lens.indexPattern.quickFunctionsLabel', { + defaultMessage: 'Quick functions', + })} + + {tabsEnabled.formula ? ( + onClick(formulaOperationName)} + > + {i18n.translate('xpack.lens.indexPattern.formulaLabel', { + defaultMessage: 'Formula', + })} + + ) : null} + + ); +}; + +export function getErrorMessage( + selectedColumn: IndexPatternColumn | undefined, + incompleteOperation: boolean, + input: 'none' | 'field' | 'fullReference' | 'managedReference' | undefined, + fieldInvalid: boolean +) { + if (selectedColumn && incompleteOperation) { + if (input === 'field') { + return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', { + defaultMessage: 'This field does not work with the selected function.', + }); + } + return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', { + defaultMessage: 'To use this function, select a field.', + }); + } + if (fieldInvalid) { + return i18n.translate('xpack.lens.indexPattern.invalidFieldLabel', { + defaultMessage: 'Invalid field. Check your index pattern or pick another field.', + }); + } +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts index 26aac5dab31e..85807721f80f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -17,6 +17,7 @@ import { OperationMetadata, DropType } from '../../../types'; import { IndexPatternColumn, MedianIndexPatternColumn } from '../../operations'; import { getFieldByNameFactory } from '../../pure_helpers'; import { generateId } from '../../../id_generator'; +import { layerTypes } from '../../../../common'; jest.mock('../../../id_generator'); @@ -263,7 +264,6 @@ describe('IndexPatternDimensionEditorPanel', () => { dateRange: { fromDate: 'now-1d', toDate: 'now' }, columnId: 'col1', layerId: 'first', - layerType: 'data', uniqueLabel: 'stuff', groupId: 'group1', filterOperations: () => true, @@ -287,6 +287,8 @@ describe('IndexPatternDimensionEditorPanel', () => { dimensionGroups: [], isFullscreen: false, toggleFullscreen: () => {}, + supportStaticValue: false, + layerType: layerTypes.DATA, }; jest.clearAllMocks(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts index e09c3e904f53..b518f667a0bf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts @@ -121,8 +121,12 @@ function onMoveCompatible( indexPattern, }); - let updatedColumnOrder = getColumnOrder(modifiedLayer); - updatedColumnOrder = reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); + const updatedColumnOrder = reorderByGroups( + dimensionGroups, + groupId, + getColumnOrder(modifiedLayer), + columnId + ); // Time to replace setState( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 06c8a50cd2df..1dfc7d40f6f3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -1623,4 +1623,87 @@ describe('IndexPattern Data Source', () => { expect(indexPatternDatasource.isTimeBased(state)).toEqual(false); }); }); + + describe('#initializeDimension', () => { + it('should return the same state if no static value is passed', () => { + const state = enrichBaseState({ + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['metric'], + columns: { + metric: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + }, + }); + expect( + indexPatternDatasource.initializeDimension!(state, 'first', { + columnId: 'newStatic', + label: 'MyNewColumn', + groupId: 'a', + dataType: 'number', + }) + ).toBe(state); + }); + + it('should add a new static value column if a static value is passed', () => { + const state = enrichBaseState({ + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['metric'], + columns: { + metric: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + }, + }); + expect( + indexPatternDatasource.initializeDimension!(state, 'first', { + columnId: 'newStatic', + label: 'MyNewColumn', + groupId: 'a', + dataType: 'number', + staticValue: 0, // use a falsy value to check also this corner case + }) + ).toEqual({ + ...state, + layers: { + ...state.layers, + first: { + ...state.layers.first, + incompleteColumns: {}, + columnOrder: ['metric', 'newStatic'], + columns: { + ...state.layers.first.columns, + newStatic: { + dataType: 'number', + isBucketed: false, + label: 'Static value: 0', + operationType: 'static_value', + params: { value: 0 }, + references: [], + scale: 'ratio', + }, + }, + }, + }, + }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 6a45e3c987f3..2138b06a4c34 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -44,7 +44,7 @@ import { import { isDraggedField, normalizeOperationDataType } from './utils'; import { LayerPanel } from './layerpanel'; -import { IndexPatternColumn, getErrorMessages } from './operations'; +import { IndexPatternColumn, getErrorMessages, insertNewColumn } from './operations'; import { IndexPatternField, IndexPatternPrivateState, IndexPatternPersistedState } from './types'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; @@ -192,6 +192,27 @@ export function getIndexPatternDatasource({ }); }, + initializeDimension(state, layerId, { columnId, groupId, label, dataType, staticValue }) { + const indexPattern = state.indexPatterns[state.layers[layerId]?.indexPatternId]; + if (staticValue == null) { + return state; + } + return mergeLayer({ + state, + layerId, + newLayer: insertNewColumn({ + layer: state.layers[layerId], + op: 'static_value', + columnId, + field: undefined, + indexPattern, + visualizationGroups: [], + initialParams: { params: { value: staticValue } }, + targetGroup: groupId, + }), + }); + }, + toExpression: (state, layerId) => toExpression(state, layerId, uiSettings), renderDataPanel( @@ -404,9 +425,14 @@ export function getIndexPatternDatasource({ }, }; }, - getDatasourceSuggestionsForField(state, draggedField) { + getDatasourceSuggestionsForField(state, draggedField, filterLayers) { return isDraggedField(draggedField) - ? getDatasourceSuggestionsForField(state, draggedField.indexPatternId, draggedField.field) + ? getDatasourceSuggestionsForField( + state, + draggedField.indexPatternId, + draggedField.field, + filterLayers + ) : []; }, getDatasourceSuggestionsFromCurrentState, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 4b8bbc09c679..a5d6db4be331 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -1198,6 +1198,91 @@ describe('IndexPattern Data Source suggestions', () => { }) ); }); + + it('should apply layers filter if passed and model the suggestion based on that', () => { + (generateId as jest.Mock).mockReturnValue('newid'); + const initialState = stateWithNonEmptyTables(); + + const modifiedState: IndexPatternPrivateState = { + ...initialState, + layers: { + thresholdLayer: { + indexPatternId: '1', + columnOrder: ['threshold'], + columns: { + threshold: { + dataType: 'number', + isBucketed: false, + label: 'Static Value: 0', + operationType: 'static_value', + params: { value: '0' }, + references: [], + scale: 'ratio', + }, + }, + }, + currentLayer: { + indexPatternId: '1', + columnOrder: ['metric', 'ref'], + columns: { + metric: { + label: '', + customLabel: true, + dataType: 'number', + isBucketed: false, + operationType: 'average', + sourceField: 'bytes', + }, + ref: { + label: '', + customLabel: true, + dataType: 'number', + isBucketed: false, + operationType: 'cumulative_sum', + references: ['metric'], + }, + }, + }, + }, + }; + + const suggestions = getSuggestionSubset( + getDatasourceSuggestionsForField( + modifiedState, + '1', + documentField, + (layerId) => layerId !== 'thresholdLayer' + ) + ); + // should ignore the threshold layer + expect(suggestions).toContainEqual( + expect.objectContaining({ + table: expect.objectContaining({ + changeType: 'extended', + columns: [ + { + columnId: 'ref', + operation: { + dataType: 'number', + isBucketed: false, + label: '', + scale: undefined, + }, + }, + { + columnId: 'newid', + operation: { + dataType: 'number', + isBucketed: false, + label: 'Count of records', + scale: 'ratio', + }, + }, + ], + }), + }) + ); + }); }); describe('finding the layer that is using the current index pattern', () => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts index b0793bf912bb..0fe0ef617dc2 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -95,10 +95,14 @@ function buildSuggestion({ export function getDatasourceSuggestionsForField( state: IndexPatternPrivateState, indexPatternId: string, - field: IndexPatternField + field: IndexPatternField, + filterLayers?: (layerId: string) => boolean ): IndexPatternSuggestion[] { const layers = Object.keys(state.layers); - const layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId); + let layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId); + if (filterLayers) { + layerIds = layerIds.filter(filterLayers); + } if (layerIds.length === 0) { // The field we're suggesting on does not match any existing layer. diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx index c2ba893a9b90..499170349c3d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx @@ -355,6 +355,33 @@ describe('formula', () => { references: [], }); }); + + it('should move into Formula previous static_value operation', () => { + expect( + formulaOperation.buildColumn({ + previousColumn: { + label: 'Static value: 0', + dataType: 'number', + isBucketed: false, + operationType: 'static_value', + references: [], + params: { + value: '0', + }, + }, + layer, + indexPattern, + }) + ).toEqual({ + label: '0', + dataType: 'number', + operationType: 'formula', + isBucketed: false, + scale: 'ratio', + params: { isFormulaBroken: false, formula: '0' }, + references: [], + }); + }); }); describe('regenerateLayerFromAst()', () => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts index 589f547434b9..3db9ebc6f969 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts @@ -38,6 +38,11 @@ export function generateFormula( previousFormula: string, operationDefinitionMap: Record | undefined ) { + if (previousColumn.operationType === 'static_value') { + if (previousColumn.params && 'value' in previousColumn.params) { + return String(previousColumn.params.value); // make sure it's a string + } + } if ('references' in previousColumn) { const metric = layer.columns[previousColumn.references[0]]; if (metric && 'sourceField' in metric && metric.dataType === 'number') { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx index 45abbcd3d9cf..a39918369486 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { IndexPatternColumn, operationDefinitionMap } from '.'; -import { FieldBasedIndexPatternColumn } from './column_types'; +import { FieldBasedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types'; import { IndexPattern } from '../../types'; export function getInvalidFieldMessage( @@ -81,8 +81,7 @@ export function isValidNumber( const inputValueAsNumber = Number(inputValue); return ( inputValue !== '' && - inputValue !== null && - inputValue !== undefined && + inputValue != null && !Number.isNaN(inputValueAsNumber) && Number.isFinite(inputValueAsNumber) && (!integer || Number.isInteger(inputValueAsNumber)) && @@ -91,7 +90,9 @@ export function isValidNumber( ); } -export function getFormatFromPreviousColumn(previousColumn: IndexPatternColumn | undefined) { +export function getFormatFromPreviousColumn( + previousColumn: IndexPatternColumn | ReferenceBasedIndexPatternColumn | undefined +) { return previousColumn?.dataType === 'number' && previousColumn.params && 'format' in previousColumn.params && diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index 326b71f72c06..0212c73f4687 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -49,6 +49,7 @@ import { formulaOperation, FormulaIndexPatternColumn, } from './formula'; +import { staticValueOperation, StaticValueIndexPatternColumn } from './static_value'; import { lastValueOperation, LastValueIndexPatternColumn } from './last_value'; import { FrameDatasourceAPI, OperationMetadata } from '../../../types'; import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types'; @@ -87,7 +88,8 @@ export type IndexPatternColumn = | DerivativeIndexPatternColumn | MovingAverageIndexPatternColumn | MathIndexPatternColumn - | FormulaIndexPatternColumn; + | FormulaIndexPatternColumn + | StaticValueIndexPatternColumn; export type FieldBasedIndexPatternColumn = Extract; @@ -119,6 +121,7 @@ export { CountIndexPatternColumn } from './count'; export { LastValueIndexPatternColumn } from './last_value'; export { RangeIndexPatternColumn } from './ranges'; export { FormulaIndexPatternColumn, MathIndexPatternColumn } from './formula'; +export { StaticValueIndexPatternColumn } from './static_value'; // List of all operation definitions registered to this data source. // If you want to implement a new operation, add the definition to this array and @@ -147,6 +150,7 @@ const internalOperationDefinitions = [ overallMinOperation, overallMaxOperation, overallAverageOperation, + staticValueOperation, ]; export { termsOperation } from './terms'; @@ -168,6 +172,7 @@ export { overallMinOperation, } from './calculations'; export { formulaOperation } from './formula/formula'; +export { staticValueOperation } from './static_value'; /** * Properties passed to the operation-specific part of the popover editor diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx new file mode 100644 index 000000000000..0a6620eecf30 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx @@ -0,0 +1,404 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { createMockedIndexPattern } from '../../mocks'; +import { staticValueOperation } from './index'; +import { IndexPattern, IndexPatternLayer } from '../../types'; +import { StaticValueIndexPatternColumn } from './static_value'; +import { EuiFieldNumber } from '@elastic/eui'; +import { act } from 'react-dom/test-utils'; + +jest.mock('lodash', () => { + const original = jest.requireActual('lodash'); + + return { + ...original, + debounce: (fn: unknown) => fn, + }; +}); + +const uiSettingsMock = {} as IUiSettingsClient; + +const defaultProps = { + storage: {} as IStorageWrapper, + uiSettings: uiSettingsMock, + savedObjectsClient: {} as SavedObjectsClientContract, + dateRange: { fromDate: 'now-1d', toDate: 'now' }, + data: dataPluginMock.createStartContract(), + http: {} as HttpSetup, + indexPattern: { + ...createMockedIndexPattern(), + hasRestrictions: false, + } as IndexPattern, + operationDefinitionMap: {}, + isFullscreen: false, + toggleFullscreen: jest.fn(), + setIsCloseable: jest.fn(), + layerId: '1', +}; + +describe('static_value', () => { + let layer: IndexPatternLayer; + + beforeEach(() => { + layer = { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'category', + }, + col2: { + label: 'Static value: 23', + dataType: 'number', + isBucketed: false, + operationType: 'static_value', + references: [], + params: { + value: '23', + }, + }, + }, + }; + }); + + function getLayerWithStaticValue(newValue: string): IndexPatternLayer { + return { + ...layer, + columns: { + ...layer.columns, + col2: { + ...layer.columns.col2, + label: `Static value: ${newValue}`, + params: { + value: newValue, + }, + } as StaticValueIndexPatternColumn, + }, + }; + } + + describe('getDefaultLabel', () => { + it('should return the label for the given value', () => { + expect( + staticValueOperation.getDefaultLabel( + { + label: 'Static value: 23', + dataType: 'number', + isBucketed: false, + operationType: 'static_value', + references: [], + params: { + value: '23', + }, + }, + createMockedIndexPattern(), + layer.columns + ) + ).toBe('Static value: 23'); + }); + + it('should return the default label for non valid value', () => { + expect( + staticValueOperation.getDefaultLabel( + { + label: 'Static value', + dataType: 'number', + isBucketed: false, + operationType: 'static_value', + references: [], + params: { + value: '', + }, + }, + createMockedIndexPattern(), + layer.columns + ) + ).toBe('Static value'); + }); + }); + + describe('getErrorMessage', () => { + it('should return no error for valid values', () => { + expect( + staticValueOperation.getErrorMessage!( + getLayerWithStaticValue('23'), + 'col2', + createMockedIndexPattern() + ) + ).toBeUndefined(); + // test for potential falsy value + expect( + staticValueOperation.getErrorMessage!( + getLayerWithStaticValue('0'), + 'col2', + createMockedIndexPattern() + ) + ).toBeUndefined(); + }); + + it('should return error for invalid values', () => { + for (const value of ['NaN', 'Infinity', 'string']) { + expect( + staticValueOperation.getErrorMessage!( + getLayerWithStaticValue(value), + 'col2', + createMockedIndexPattern() + ) + ).toEqual(expect.arrayContaining([expect.stringMatching('is not a valid number')])); + } + }); + }); + + describe('toExpression', () => { + it('should return a mathColumn operation with valid value', () => { + for (const value of ['23', '0', '-1']) { + expect( + staticValueOperation.toExpression( + getLayerWithStaticValue(value), + 'col2', + createMockedIndexPattern() + ) + ).toEqual([ + { + type: 'function', + function: 'mathColumn', + arguments: { + id: ['col2'], + name: [`Static value: ${value}`], + expression: [value], + }, + }, + ]); + } + }); + + it('should fallback to mapColumn for invalid value', () => { + for (const value of ['NaN', '', 'Infinity']) { + expect( + staticValueOperation.toExpression( + getLayerWithStaticValue(value), + 'col2', + createMockedIndexPattern() + ) + ).toEqual([ + { + type: 'function', + function: 'mapColumn', + arguments: { + id: ['col2'], + name: [`Static value`], + expression: ['100'], + }, + }, + ]); + } + }); + }); + + describe('buildColumn', () => { + it('should set default static value', () => { + expect( + staticValueOperation.buildColumn({ + indexPattern: createMockedIndexPattern(), + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, + }) + ).toEqual({ + label: 'Static value', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: '100' }, + references: [], + }); + }); + + it('should merge a previousColumn', () => { + expect( + staticValueOperation.buildColumn({ + indexPattern: createMockedIndexPattern(), + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, + previousColumn: { + label: 'Static value', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: '23' }, + references: [], + }, + }) + ).toEqual({ + label: 'Static value: 23', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: '23' }, + references: [], + }); + }); + + it('should create a static_value from passed arguments', () => { + expect( + staticValueOperation.buildColumn( + { + indexPattern: createMockedIndexPattern(), + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, + }, + { value: '23' } + ) + ).toEqual({ + label: 'Static value: 23', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: '23' }, + references: [], + }); + }); + + it('should prioritize passed arguments over previousColumn', () => { + expect( + staticValueOperation.buildColumn( + { + indexPattern: createMockedIndexPattern(), + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, + previousColumn: { + label: 'Static value', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: '23' }, + references: [], + }, + }, + { value: '53' } + ) + ).toEqual({ + label: 'Static value: 53', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: '53' }, + references: [], + }); + }); + }); + + describe('paramEditor', () => { + const ParamEditor = staticValueOperation.paramEditor!; + it('should render current static_value', () => { + const updateLayerSpy = jest.fn(); + const instance = shallow( + + ); + + const input = instance.find('[data-test-subj="lns-indexPattern-static_value-input"]'); + + expect(input.prop('value')).toEqual('23'); + }); + + it('should update state on change', async () => { + const updateLayerSpy = jest.fn(); + const instance = mount( + + ); + + const input = instance + .find('[data-test-subj="lns-indexPattern-static_value-input"]') + .find(EuiFieldNumber); + + await act(async () => { + input.prop('onChange')!({ + currentTarget: { value: '27' }, + } as React.ChangeEvent); + }); + + instance.update(); + + expect(updateLayerSpy.mock.calls[0]).toEqual([expect.any(Function)]); + // check that the result of the setter call is correct + expect(updateLayerSpy.mock.calls[0][0](layer)).toEqual({ + ...layer, + columns: { + ...layer.columns, + col2: { + ...layer.columns.col2, + params: { + value: '27', + }, + label: 'Static value: 27', + }, + }, + }); + }); + + it('should not update on invalid input, but show invalid value locally', async () => { + const updateLayerSpy = jest.fn(); + const instance = mount( + + ); + + const input = instance + .find('[data-test-subj="lns-indexPattern-static_value-input"]') + .find(EuiFieldNumber); + + await act(async () => { + input.prop('onChange')!({ + currentTarget: { value: '' }, + } as React.ChangeEvent); + }); + + instance.update(); + + expect(updateLayerSpy).not.toHaveBeenCalled(); + expect( + instance + .find('[data-test-subj="lns-indexPattern-static_value-input"]') + .find(EuiFieldNumber) + .prop('value') + ).toEqual(''); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx new file mode 100644 index 000000000000..a76c5f64d175 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFieldNumber, EuiFormLabel, EuiSpacer } from '@elastic/eui'; +import { OperationDefinition } from './index'; +import { ReferenceBasedIndexPatternColumn } from './column_types'; +import type { IndexPattern } from '../../types'; +import { useDebouncedValue } from '../../../shared_components'; +import { getFormatFromPreviousColumn, isValidNumber } from './helpers'; + +const defaultLabel = i18n.translate('xpack.lens.indexPattern.staticValueLabelDefault', { + defaultMessage: 'Static value', +}); + +const defaultValue = 100; + +function isEmptyValue(value: number | string | undefined) { + return value == null || value === ''; +} + +function ofName(value: number | string | undefined) { + if (isEmptyValue(value)) { + return defaultLabel; + } + return i18n.translate('xpack.lens.indexPattern.staticValueLabelWithValue', { + defaultMessage: 'Static value: {value}', + values: { value }, + }); +} + +export interface StaticValueIndexPatternColumn extends ReferenceBasedIndexPatternColumn { + operationType: 'static_value'; + params: { + value?: string; + format?: { + id: string; + params?: { + decimals: number; + }; + }; + }; +} + +export const staticValueOperation: OperationDefinition< + StaticValueIndexPatternColumn, + 'managedReference' +> = { + type: 'static_value', + displayName: defaultLabel, + getDefaultLabel: (column) => ofName(column.params.value), + input: 'managedReference', + hidden: true, + getDisabledStatus(indexPattern: IndexPattern) { + return undefined; + }, + getErrorMessage(layer, columnId) { + const column = layer.columns[columnId] as StaticValueIndexPatternColumn; + + return !isValidNumber(column.params.value) + ? [ + i18n.translate('xpack.lens.indexPattern.staticValueError', { + defaultMessage: 'The static value of {value} is not a valid number', + values: { value: column.params.value }, + }), + ] + : undefined; + }, + getPossibleOperation() { + return { + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }; + }, + toExpression: (layer, columnId) => { + const currentColumn = layer.columns[columnId] as StaticValueIndexPatternColumn; + const params = currentColumn.params; + // TODO: improve this logic + const useDisplayLabel = currentColumn.label !== defaultLabel; + const label = isValidNumber(params.value) + ? useDisplayLabel + ? currentColumn.label + : params?.value ?? defaultLabel + : defaultLabel; + + return [ + { + type: 'function', + function: isValidNumber(params.value) ? 'mathColumn' : 'mapColumn', + arguments: { + id: [columnId], + name: [label || defaultLabel], + expression: [isValidNumber(params.value) ? params.value! : String(defaultValue)], + }, + }, + ]; + }, + buildColumn({ previousColumn, layer, indexPattern }, columnParams, operationDefinitionMap) { + const existingStaticValue = + previousColumn?.params && + 'value' in previousColumn.params && + isValidNumber(previousColumn.params.value) + ? previousColumn.params.value + : undefined; + const previousParams: StaticValueIndexPatternColumn['params'] = { + ...{ value: existingStaticValue }, + ...getFormatFromPreviousColumn(previousColumn), + ...columnParams, + }; + return { + label: ofName(previousParams.value), + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { ...previousParams, value: previousParams.value ?? String(defaultValue) }, + references: [], + }; + }, + isTransferable: (column) => { + return true; + }, + createCopy(layer, sourceId, targetId, indexPattern, operationDefinitionMap) { + const currentColumn = layer.columns[sourceId] as StaticValueIndexPatternColumn; + return { + ...layer, + columns: { + ...layer.columns, + [targetId]: { ...currentColumn }, + }, + }; + }, + + paramEditor: function StaticValueEditor({ + layer, + updateLayer, + currentColumn, + columnId, + activeData, + layerId, + indexPattern, + }) { + const onChange = useCallback( + (newValue) => { + // even if debounced it's triggering for empty string with the previous valid value + if (currentColumn.params.value === newValue) { + return; + } + // Because of upstream specific UX flows, we need fresh layer state here + // so need to use the updater pattern + updateLayer((newLayer) => { + const newColumn = newLayer.columns[columnId] as StaticValueIndexPatternColumn; + return { + ...newLayer, + columns: { + ...newLayer.columns, + [columnId]: { + ...newColumn, + label: newColumn?.customLabel ? newColumn.label : ofName(newValue), + params: { + ...newColumn.params, + value: newValue, + }, + }, + }, + }; + }); + }, + [columnId, updateLayer, currentColumn?.params?.value] + ); + + // Pick the data from the current activeData (to be used when the current operation is not static_value) + const activeDataValue = + activeData && + activeData[layerId] && + activeData[layerId]?.rows?.length === 1 && + activeData[layerId].rows[0][columnId]; + + const fallbackValue = + currentColumn?.operationType !== 'static_value' && activeDataValue != null + ? activeDataValue + : String(defaultValue); + + const { inputValue, handleInputChange } = useDebouncedValue( + { + value: currentColumn?.params?.value || fallbackValue, + onChange, + }, + { allowFalsyValue: true } + ); + + const onChangeHandler = useCallback( + (e: React.ChangeEvent) => { + const value = e.currentTarget.value; + handleInputChange(isValidNumber(value) ? value : undefined); + }, + [handleInputChange] + ); + + return ( +
+ + {i18n.translate('xpack.lens.indexPattern.staticValue.label', { + defaultMessage: 'Threshold value', + })} + + + +
+ ); + }, +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 11c8206fee02..baacc7bb64d1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -184,6 +184,7 @@ export function insertNewColumn({ targetGroup, shouldResetLabel, incompleteParams, + initialParams, }: ColumnChange): IndexPatternLayer { const operationDefinition = operationDefinitionMap[op]; @@ -197,7 +198,7 @@ export function insertNewColumn({ const baseOptions = { indexPattern, - previousColumn: { ...incompleteParams, ...layer.columns[columnId] }, + previousColumn: { ...incompleteParams, ...initialParams, ...layer.columns[columnId] }, }; if (operationDefinition.input === 'none' || operationDefinition.input === 'managedReference') { @@ -396,9 +397,17 @@ export function replaceColumn({ tempLayer = resetIncomplete(tempLayer, columnId); - if (previousDefinition.input === 'managedReference') { + if ( + previousDefinition.input === 'managedReference' && + operationDefinition.input !== previousDefinition.input + ) { // If the transition is incomplete, leave the managed state until it's finished. - tempLayer = deleteColumn({ layer: tempLayer, columnId, indexPattern }); + tempLayer = removeOrphanedColumns( + previousDefinition, + previousColumn, + tempLayer, + indexPattern + ); const hypotheticalLayer = insertNewColumn({ layer: tempLayer, @@ -641,21 +650,31 @@ function removeOrphanedColumns( previousDefinition: | OperationDefinition | OperationDefinition - | OperationDefinition, + | OperationDefinition + | OperationDefinition, previousColumn: IndexPatternColumn, tempLayer: IndexPatternLayer, indexPattern: IndexPattern ) { + let newLayer: IndexPatternLayer = tempLayer; + if (previousDefinition.input === 'managedReference') { + const [columnId] = + Object.entries(tempLayer.columns).find(([_, currColumn]) => currColumn === previousColumn) || + []; + if (columnId != null) { + newLayer = deleteColumn({ layer: tempLayer, columnId, indexPattern }); + } + } if (previousDefinition.input === 'fullReference') { (previousColumn as ReferenceBasedIndexPatternColumn).references.forEach((id: string) => { - tempLayer = deleteColumn({ + newLayer = deleteColumn({ layer: tempLayer, columnId: id, indexPattern, }); }); } - return tempLayer; + return newLayer; } export function canTransition({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index 2ed6e2b3a7bc..08136ed501cf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -378,6 +378,10 @@ describe('getOperationTypesForField', () => { "operationType": "formula", "type": "managedReference", }, + Object { + "operationType": "static_value", + "type": "managedReference", + }, ], }, Object { diff --git a/x-pack/plugins/lens/public/mocks.tsx b/x-pack/plugins/lens/public/mocks.tsx index 47a2e00055ce..402440f3302f 100644 --- a/x-pack/plugins/lens/public/mocks.tsx +++ b/x-pack/plugins/lens/public/mocks.tsx @@ -59,9 +59,9 @@ export function mockDatasourceStates() { }; } -export function createMockVisualization(): jest.Mocked { +export function createMockVisualization(id = 'vis1'): jest.Mocked { return { - id: 'TEST_VIS', + id, clearLayer: jest.fn((state, _layerId) => state), removeLayer: jest.fn(), getLayerIds: jest.fn((_state) => ['layer1']), @@ -70,9 +70,9 @@ export function createMockVisualization(): jest.Mocked { visualizationTypes: [ { icon: 'empty', - id: 'TEST_VIS', + id, label: 'TEST', - groupLabel: 'TEST_VISGroup', + groupLabel: `${id}Group`, }, ], getVisualizationTypeId: jest.fn((_state) => 'empty'), @@ -122,7 +122,7 @@ export function createMockDatasource(id: string): DatasourceMock { return { id: 'mockindexpattern', clearLayer: jest.fn((state, _layerId) => state), - getDatasourceSuggestionsForField: jest.fn((_state, _item) => []), + getDatasourceSuggestionsForField: jest.fn((_state, _item, filterFn) => []), getDatasourceSuggestionsForVisualizeField: jest.fn((_state, _indexpatternId, _fieldName) => []), getDatasourceSuggestionsFromCurrentState: jest.fn((_state) => []), getPersistableState: jest.fn((x) => ({ diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 5326927d2c6c..7891b5990989 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -233,10 +233,10 @@ export class LensPlugin { const getPresentationUtilContext = () => startServices().plugins.presentationUtil.ContextProvider; - const ensureDefaultIndexPattern = async () => { + const ensureDefaultDataView = async () => { // make sure a default index pattern exists // if not, the page will be redirected to management and visualize won't be rendered - await startServices().plugins.data.indexPatterns.ensureDefaultIndexPattern(); + await startServices().plugins.data.indexPatterns.ensureDefaultDataView(); }; core.application.register({ @@ -261,7 +261,7 @@ export class LensPlugin { const frameStart = this.editorFrameService!.start(coreStart, deps); this.stopReportManager = stopReportManager; - await ensureDefaultIndexPattern(); + await ensureDefaultDataView(); return mountApp(core, params, { createEditorFrame: frameStart.createInstance, attributeService: getLensAttributeService(coreStart, deps), diff --git a/x-pack/plugins/lens/public/shared_components/debounced_value.ts b/x-pack/plugins/lens/public/shared_components/debounced_value.ts index fa8fc22dedd5..412199a371f1 100644 --- a/x-pack/plugins/lens/public/shared_components/debounced_value.ts +++ b/x-pack/plugins/lens/public/shared_components/debounced_value.ts @@ -11,6 +11,10 @@ import { debounce } from 'lodash'; /** * Debounces value changes and updates inputValue on root state changes if no debounced changes * are in flight because the user is currently modifying the value. + * + * * allowFalsyValue: update upstream with all falsy values but null or undefined + * + * When testing this function mock the "debounce" function in lodash (see this module test for an example) */ export const useDebouncedValue = ( diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index c200a18a25ca..f947ce699dce 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -14,3 +14,4 @@ export * from './coloring'; export { useDebouncedValue } from './debounced_value'; export * from './helpers'; export { LegendActionPopover } from './legend_action_popover'; +export * from './static_header'; diff --git a/x-pack/plugins/lens/public/shared_components/static_header.tsx b/x-pack/plugins/lens/public/shared_components/static_header.tsx new file mode 100644 index 000000000000..2250358234a7 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/static_header.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle, IconType } from '@elastic/eui'; + +export const StaticHeader = ({ label, icon }: { label: string; icon?: IconType }) => { + return ( + + {icon && ( + + {' '} + + )} + + +
{label}
+
+
+
+ ); +}; diff --git a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts index 0f5f1c15d4fa..7db03a17a3a8 100644 --- a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts +++ b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts @@ -149,7 +149,7 @@ export function loadInitial( datasourceMap, datasourceStates, visualizationMap, - activeVisualizationId: Object.keys(visualizationMap)[0] || null, + activeVisualization: visualizationMap?.[Object.keys(visualizationMap)[0]] || null, visualizationState: null, visualizeTriggerFieldContext: initialContext, }); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index a4a483fa95d3..cf6634c200d5 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -234,7 +234,11 @@ export interface Datasource { toExpression: (state: T, layerId: string) => ExpressionAstExpression | string | null; - getDatasourceSuggestionsForField: (state: T, field: unknown) => Array>; + getDatasourceSuggestionsForField: ( + state: T, + field: unknown, + filterFn: (layerId: string) => boolean + ) => Array>; getDatasourceSuggestionsForVisualizeField: ( state: T, indexPatternId: string, @@ -326,6 +330,8 @@ export type DatasourceDimensionProps = SharedDimensionProps & { onRemove?: (accessor: string) => void; state: T; activeData?: Record; + invalid?: boolean; + invalidMessage?: string; }; // The only way a visualization has to restrict the query building @@ -335,6 +341,7 @@ export type DatasourceDimensionEditorProps = DatasourceDimensionPro newState: Parameters>[0], publishToVisualization?: { isDimensionComplete?: boolean; + forceRender?: boolean; } ) => void; core: Pick; @@ -343,6 +350,8 @@ export type DatasourceDimensionEditorProps = DatasourceDimensionPro toggleFullscreen: () => void; isFullscreen: boolean; layerType: LayerType | undefined; + supportStaticValue: boolean; + supportFieldFormat?: boolean; }; export type DatasourceDimensionTriggerProps = DatasourceDimensionProps; @@ -434,7 +443,7 @@ export interface VisualizationToolbarProps { export type VisualizationDimensionEditorProps = VisualizationConfigProps & { groupId: string; accessor: string; - setState: (newState: T) => void; + setState(newState: T | ((currState: T) => T)): void; panelRef: MutableRefObject; }; @@ -466,13 +475,16 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & { // this dimension group in the hierarchy. If not specified, the position of the dimension in the array is used. specified nesting // orders are always higher in the hierarchy than non-specified ones. nestingOrder?: number; + // some type of layers can produce groups even if invalid. Keep this information to visually show the user that. + invalid?: boolean; + invalidMessage?: string; }; interface VisualizationDimensionChangeProps { layerId: string; columnId: string; prevState: T; - frame: Pick; + frame: Pick; } /** @@ -655,6 +667,7 @@ export interface Visualization { getConfiguration: (props: VisualizationConfigProps) => { groups: VisualizationDimensionGroupConfig[]; supportStaticValue?: boolean; + supportFieldFormat?: boolean; }; /** diff --git a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts index 95c9140624e6..9c83e2c58146 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts +++ b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts @@ -30,16 +30,17 @@ export function isFormatterCompatible( return formatter1.id === formatter2.id; } -export function getAxesConfiguration( - layers: XYLayerConfig[], - shouldRotate: boolean, - tables?: Record, - formatFactory?: FormatFactory -): GroupsConfiguration { - const series: { auto: FormattedMetric[]; left: FormattedMetric[]; right: FormattedMetric[] } = { +export function groupAxesByType(layers: XYLayerConfig[], tables?: Record) { + const series: { + auto: FormattedMetric[]; + left: FormattedMetric[]; + right: FormattedMetric[]; + bottom: FormattedMetric[]; + } = { auto: [], left: [], right: [], + bottom: [], }; layers?.forEach((layer) => { @@ -89,6 +90,16 @@ export function getAxesConfiguration( series.right.push(currentSeries); } }); + return series; +} + +export function getAxesConfiguration( + layers: XYLayerConfig[], + shouldRotate: boolean, + tables?: Record, + formatFactory?: FormatFactory +): GroupsConfiguration { + const series = groupAxesByType(layers, tables); const axisGroups: GroupsConfiguration = []; diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 026d9da71bee..863289c31bba 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -59,6 +59,7 @@ import { getAxesConfiguration, GroupsConfiguration, validateExtent } from './axe import { getColorAssignments } from './color_assignment'; import { getXDomain, XyEndzones } from './x_domain'; import { getLegendAction } from './get_legend_action'; +import { ThresholdAnnotations } from './expression_thresholds'; declare global { interface Window { @@ -251,6 +252,7 @@ export function XYChart({ const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar'; return ; } + const thresholdLayers = layers.filter((layer) => layer.layerType === layerTypes.THRESHOLD); // use formatting hint of first x axis column to format ticks const xAxisColumn = data.tables[filteredLayers[0].layerId].columns.find( @@ -832,6 +834,20 @@ export function XYChart({ } }) )} + {thresholdLayers.length ? ( + groupId === 'left')?.formatter, + right: yAxesConfiguration.find(({ groupId }) => groupId === 'right')?.formatter, + bottom: xAxisFormatter, + }} + /> + ) : null} ); } diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx b/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx new file mode 100644 index 000000000000..171e2f1cfba9 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx @@ -0,0 +1,193 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { groupBy } from 'lodash'; +import { EuiIcon } from '@elastic/eui'; +import { RectAnnotation, AnnotationDomainType, LineAnnotation } from '@elastic/charts'; +import type { PaletteRegistry, SeriesLayer } from 'src/plugins/charts/public'; +import type { FieldFormat } from 'src/plugins/field_formats/common'; +import type { LayerArgs } from '../../common/expressions'; +import type { LensMultiTable } from '../../common/types'; +import type { ColorAssignments } from './color_assignment'; + +export const ThresholdAnnotations = ({ + thresholdLayers, + data, + colorAssignments, + formatters, + paletteService, + syncColors, +}: { + thresholdLayers: LayerArgs[]; + data: LensMultiTable; + colorAssignments: ColorAssignments; + formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + paletteService: PaletteRegistry; + syncColors: boolean; +}) => { + return ( + <> + {thresholdLayers.flatMap((thresholdLayer) => { + if (!thresholdLayer.yConfig) { + return []; + } + const { columnToLabel, palette, yConfig: yConfigs, layerId } = thresholdLayer; + const columnToLabelMap: Record = columnToLabel + ? JSON.parse(columnToLabel) + : {}; + const table = data.tables[layerId]; + const colorAssignment = colorAssignments[palette.name]; + + const row = table.rows[0]; + + const yConfigByValue = yConfigs.sort( + ({ forAccessor: idA }, { forAccessor: idB }) => row[idA] - row[idB] + ); + + const groupedByDirection = groupBy(yConfigByValue, 'fill'); + + return yConfigByValue.flatMap((yConfig, i) => { + // Find the formatter for the given axis + const groupId = + yConfig.axisMode === 'bottom' + ? undefined + : yConfig.axisMode === 'right' + ? 'right' + : 'left'; + + const formatter = formatters[groupId || 'bottom']; + + const seriesLayers: SeriesLayer[] = [ + { + name: columnToLabelMap[yConfig.forAccessor], + totalSeriesAtDepth: colorAssignment.totalSeriesCount, + rankAtDepth: colorAssignment.getRank( + thresholdLayer, + String(yConfig.forAccessor), + String(yConfig.forAccessor) + ), + }, + ]; + const defaultColor = paletteService.get(palette.name).getCategoricalColor( + seriesLayers, + { + maxDepth: 1, + behindText: false, + totalSeries: colorAssignment.totalSeriesCount, + syncColors, + }, + palette.params + ); + + const props = { + groupId, + marker: yConfig.icon ? : undefined, + }; + const annotations = []; + + const dashStyle = + yConfig.lineStyle === 'dashed' + ? [(yConfig.lineWidth || 1) * 3, yConfig.lineWidth || 1] + : yConfig.lineStyle === 'dotted' + ? [yConfig.lineWidth || 1, yConfig.lineWidth || 1] + : undefined; + + const sharedStyle = { + strokeWidth: yConfig.lineWidth || 1, + stroke: (yConfig.color || defaultColor) ?? '#f00', + dash: dashStyle, + }; + + annotations.push( + ({ + dataValue: row[yConfig.forAccessor], + header: columnToLabelMap[yConfig.forAccessor], + details: formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], + }))} + domainType={ + yConfig.axisMode === 'bottom' + ? AnnotationDomainType.XDomain + : AnnotationDomainType.YDomain + } + style={{ + line: { + ...sharedStyle, + opacity: 1, + }, + }} + /> + ); + + if (yConfig.fill && yConfig.fill !== 'none') { + const isFillAbove = yConfig.fill === 'above'; + const indexFromSameType = groupedByDirection[yConfig.fill].findIndex( + ({ forAccessor }) => forAccessor === yConfig.forAccessor + ); + const shouldCheckNextThreshold = + indexFromSameType < groupedByDirection[yConfig.fill].length - 1; + annotations.push( + { + if (yConfig.axisMode === 'bottom') { + return { + coordinates: { + x0: isFillAbove ? row[yConfig.forAccessor] : undefined, + y0: undefined, + x1: isFillAbove + ? shouldCheckNextThreshold + ? row[ + groupedByDirection[yConfig.fill!][indexFromSameType + 1].forAccessor + ] + : undefined + : row[yConfig.forAccessor], + y1: undefined, + }, + header: columnToLabelMap[yConfig.forAccessor], + details: + formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], + }; + } + return { + coordinates: { + x0: undefined, + y0: isFillAbove ? row[yConfig.forAccessor] : undefined, + x1: undefined, + y1: isFillAbove + ? shouldCheckNextThreshold + ? row[ + groupedByDirection[yConfig.fill!][indexFromSameType + 1].forAccessor + ] + : undefined + : row[yConfig.forAccessor], + }, + header: columnToLabelMap[yConfig.forAccessor], + details: + formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], + }; + })} + style={{ + ...sharedStyle, + fill: (yConfig.color || defaultColor) ?? '#f00', + opacity: 0.1, + }} + /> + ); + } + return annotations; + }); + })} + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts index e3b16f5981f8..4edf7fdf5e51 100644 --- a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts +++ b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts @@ -18,6 +18,14 @@ export function isHorizontalSeries(seriesType: SeriesType) { ); } +export function isPercentageSeries(seriesType: SeriesType) { + return ( + seriesType === 'bar_percentage_stacked' || + seriesType === 'bar_horizontal_percentage_stacked' || + seriesType === 'area_percentage_stacked' + ); +} + export function isHorizontalChart(layers: Array<{ seriesType: SeriesType }>) { return layers.every((l) => isHorizontalSeries(l.seriesType)); } diff --git a/x-pack/plugins/lens/public/xy_visualization/threshold_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/threshold_helpers.tsx new file mode 100644 index 000000000000..ec4735070947 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/threshold_helpers.tsx @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { layerTypes } from '../../common'; +import type { XYLayerConfig, YConfig } from '../../common/expressions'; +import { Datatable } from '../../../../../src/plugins/expressions/public'; +import type { DatasourcePublicAPI, FramePublicAPI } from '../types'; +import { groupAxesByType } from './axes_configuration'; +import { isPercentageSeries } from './state_helpers'; +import type { XYState } from './types'; +import { checkScaleOperation } from './visualization_helpers'; + +export interface ThresholdBase { + label: 'x' | 'yRight' | 'yLeft'; +} + +/** + * Return the threshold layers groups to show based on multiple criteria: + * * what groups are current defined in data layers + * * what existing threshold are currently defined in data thresholds + */ +export function getGroupsToShow( + thresholdLayers: T[], + state: XYState | undefined, + datasourceLayers: Record, + tables: Record | undefined +): Array { + if (!state) { + return []; + } + const dataLayers = state.layers.filter( + ({ layerType = layerTypes.DATA }) => layerType === layerTypes.DATA + ); + const groupsAvailable = getGroupsAvailableInData(dataLayers, datasourceLayers, tables); + return thresholdLayers + .filter(({ label, config }: T) => groupsAvailable[label] || config?.length) + .map((layer) => ({ ...layer, valid: groupsAvailable[layer.label] })); +} + +/** + * Returns the threshold layers groups to show based on what groups are current defined in data layers. + */ +export function getGroupsRelatedToData( + thresholdLayers: T[], + state: XYState | undefined, + datasourceLayers: Record, + tables: Record | undefined +): T[] { + if (!state) { + return []; + } + const dataLayers = state.layers.filter( + ({ layerType = layerTypes.DATA }) => layerType === layerTypes.DATA + ); + const groupsAvailable = getGroupsAvailableInData(dataLayers, datasourceLayers, tables); + return thresholdLayers.filter(({ label }: T) => groupsAvailable[label]); +} +/** + * Returns a dictionary with the groups filled in all the data layers + */ +export function getGroupsAvailableInData( + dataLayers: XYState['layers'], + datasourceLayers: Record, + tables: Record | undefined +) { + const hasNumberHistogram = dataLayers.some( + checkScaleOperation('interval', 'number', datasourceLayers) + ); + const { right, left } = groupAxesByType(dataLayers, tables); + return { + x: dataLayers.some(({ xAccessor }) => xAccessor != null) && hasNumberHistogram, + yLeft: left.length > 0, + yRight: right.length > 0, + }; +} + +export function getStaticValue( + dataLayers: XYState['layers'], + groupId: 'x' | 'yLeft' | 'yRight', + { activeData }: Pick, + layerHasNumberHistogram: (layer: XYLayerConfig) => boolean +) { + const fallbackValue = 100; + if (!activeData) { + return fallbackValue; + } + + // filter and organize data dimensions into threshold groups + // now pick the columnId in the active data + const { dataLayer, accessor } = getAccessorCriteriaForGroup(groupId, dataLayers, activeData); + if (groupId === 'x' && dataLayer && !layerHasNumberHistogram(dataLayer)) { + return fallbackValue; + } + return ( + computeStaticValueForGroup( + dataLayer, + accessor, + activeData, + groupId !== 'x' // histogram axis should compute the min based on the current data + ) || fallbackValue + ); +} + +function getAccessorCriteriaForGroup( + groupId: 'x' | 'yLeft' | 'yRight', + dataLayers: XYState['layers'], + activeData: FramePublicAPI['activeData'] +) { + switch (groupId) { + case 'x': + const dataLayer = dataLayers.find(({ xAccessor }) => xAccessor); + return { + dataLayer, + accessor: dataLayer?.xAccessor, + }; + case 'yLeft': + const { left } = groupAxesByType(dataLayers, activeData); + return { + dataLayer: dataLayers.find(({ layerId }) => layerId === left[0]?.layer), + accessor: left[0]?.accessor, + }; + case 'yRight': + const { right } = groupAxesByType(dataLayers, activeData); + return { + dataLayer: dataLayers.find(({ layerId }) => layerId === right[0]?.layer), + accessor: right[0]?.accessor, + }; + } +} + +function computeStaticValueForGroup( + dataLayer: XYLayerConfig | undefined, + accessorId: string | undefined, + activeData: NonNullable, + minZeroBased: boolean +) { + const defaultThresholdFactor = 3 / 4; + + if (dataLayer && accessorId) { + if (isPercentageSeries(dataLayer?.seriesType)) { + return defaultThresholdFactor; + } + const tableId = Object.keys(activeData).find((key) => + activeData[key].columns.some(({ id }) => id === accessorId) + ); + if (tableId) { + const columnMax = activeData[tableId].rows.reduce( + (max, row) => Math.max(row[accessorId], max), + -Infinity + ); + const columnMin = activeData[tableId].rows.reduce( + (max, row) => Math.min(row[accessorId], max), + Infinity + ); + // Custom axis bounds can go below 0, so consider also lower values than 0 + const finalMinValue = minZeroBased ? Math.min(0, columnMin) : columnMin; + const interval = columnMax - finalMinValue; + return Number((finalMinValue + interval * defaultThresholdFactor).toFixed(2)); + } + } +} diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 5290e0298ae5..aa99aa9b7b31 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -322,6 +322,10 @@ export const buildExpression = ( forAccessor: [yConfig.forAccessor], axisMode: yConfig.axisMode ? [yConfig.axisMode] : [], color: yConfig.color ? [yConfig.color] : [], + lineStyle: yConfig.lineStyle ? [yConfig.lineStyle] : [], + lineWidth: yConfig.lineWidth ? [yConfig.lineWidth] : [], + fill: [yConfig.fill || 'none'], + icon: yConfig.icon ? [yConfig.icon] : [], }, }, ], diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 7aef40b1481d..8907db4954f9 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -15,6 +15,7 @@ import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { LensIconChartBar } from '../assets/chart_bar'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; +import { Datatable } from 'src/plugins/expressions'; function exampleState(): State { return { @@ -216,8 +217,8 @@ describe('xy_visualization', () => { }); describe('#getSupportedLayers', () => { - it('should return a single layer type', () => { - expect(xyVisualization.getSupportedLayers()).toHaveLength(1); + it('should return a double layer types', () => { + expect(xyVisualization.getSupportedLayers()).toHaveLength(2); }); it('should return the icon for the visualization type', () => { @@ -317,6 +318,42 @@ describe('xy_visualization', () => { accessors: [], }); }); + + it('should add a dimension to a threshold layer', () => { + expect( + xyVisualization.setDimension({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'threshold', + layerType: layerTypes.THRESHOLD, + seriesType: 'line', + accessors: [], + }, + ], + }, + layerId: 'threshold', + groupId: 'xThreshold', + columnId: 'newCol', + }).layers[0] + ).toEqual({ + layerId: 'threshold', + layerType: layerTypes.THRESHOLD, + seriesType: 'line', + accessors: ['newCol'], + yConfig: [ + { + axisMode: 'bottom', + forAccessor: 'newCol', + icon: undefined, + lineStyle: 'solid', + lineWidth: 1, + }, + ], + }); + }); }); describe('#removeDimension', () => { @@ -504,6 +541,300 @@ describe('xy_visualization', () => { expect(ops.filter(filterOperations).map((x) => x.dataType)).toEqual(['number']); }); + describe('thresholds', () => { + beforeEach(() => { + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + threshold: mockDatasource.publicAPIMock, + }; + }); + + function getStateWithBaseThreshold(): State { + return { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'area', + splitAccessor: undefined, + xAccessor: undefined, + accessors: ['a'], + }, + { + layerId: 'threshold', + layerType: layerTypes.THRESHOLD, + seriesType: 'line', + accessors: [], + yConfig: [{ axisMode: 'left', forAccessor: 'a' }], + }, + ], + }; + } + + it('should support static value', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].accessors = []; + state.layers[1].yConfig = undefined; + + expect( + xyVisualization.getConfiguration({ + state: getStateWithBaseThreshold(), + frame, + layerId: 'threshold', + }).supportStaticValue + ).toBeTruthy(); + }); + + it('should return no threshold groups for a empty data layer', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].accessors = []; + state.layers[1].yConfig = undefined; + + const options = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'threshold', + }).groups; + + expect(options).toHaveLength(0); + }); + + it('should return a group for the vertical left axis', () => { + const options = xyVisualization.getConfiguration({ + state: getStateWithBaseThreshold(), + frame, + layerId: 'threshold', + }).groups; + + expect(options).toHaveLength(1); + expect(options[0].groupId).toBe('yThresholdLeft'); + }); + + it('should return a group for the vertical right axis', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].yConfig = [{ axisMode: 'right', forAccessor: 'a' }]; + state.layers[1].yConfig![0].axisMode = 'right'; + + const options = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'threshold', + }).groups; + + expect(options).toHaveLength(1); + expect(options[0].groupId).toBe('yThresholdRight'); + }); + + it('should compute no groups for thresholds when the only data accessor available is a date histogram', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].xAccessor = 'b'; + state.layers[0].accessors = []; + state.layers[1].yConfig = []; // empty the configuration + // set the xAccessor as date_histogram + frame.datasourceLayers.threshold.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'b') { + return { + dataType: 'date', + isBucketed: true, + scale: 'interval', + label: 'date_histogram', + }; + } + return null; + }); + + const options = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'threshold', + }).groups; + + expect(options).toHaveLength(0); + }); + + it('should mark horizontal group is invalid when xAccessor is changed to a date histogram', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].xAccessor = 'b'; + state.layers[0].accessors = []; + state.layers[1].yConfig![0].axisMode = 'bottom'; + // set the xAccessor as date_histogram + frame.datasourceLayers.threshold.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'b') { + return { + dataType: 'date', + isBucketed: true, + scale: 'interval', + label: 'date_histogram', + }; + } + return null; + }); + + const options = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'threshold', + }).groups; + + expect(options[0]).toEqual( + expect.objectContaining({ + invalid: true, + groupId: 'xThreshold', + }) + ); + }); + + it('should return groups in a specific order (left, right, bottom)', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].xAccessor = 'c'; + state.layers[0].accessors = ['a', 'b']; + // invert them on purpose + state.layers[0].yConfig = [ + { axisMode: 'right', forAccessor: 'b' }, + { axisMode: 'left', forAccessor: 'a' }, + ]; + state.layers[1].yConfig = [ + { forAccessor: 'c', axisMode: 'bottom' }, + { forAccessor: 'b', axisMode: 'right' }, + { forAccessor: 'a', axisMode: 'left' }, + ]; + // set the xAccessor as number histogram + frame.datasourceLayers.threshold.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'c') { + return { + dataType: 'number', + isBucketed: true, + scale: 'interval', + label: 'histogram', + }; + } + return null; + }); + + const [left, right, bottom] = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'threshold', + }).groups; + + expect(left.groupId).toBe('yThresholdLeft'); + expect(right.groupId).toBe('yThresholdRight'); + expect(bottom.groupId).toBe('xThreshold'); + }); + + it('should ignore terms operation for xAccessor', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].xAccessor = 'b'; + state.layers[0].accessors = []; + state.layers[1].yConfig = []; // empty the configuration + // set the xAccessor as top values + frame.datasourceLayers.threshold.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'b') { + return { + dataType: 'string', + isBucketed: true, + scale: 'ordinal', + label: 'top values', + }; + } + return null; + }); + + const options = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'threshold', + }).groups; + + expect(options).toHaveLength(0); + }); + + it('should mark horizontal group is invalid when accessor is changed to a terms operation', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].xAccessor = 'b'; + state.layers[0].accessors = []; + state.layers[1].yConfig![0].axisMode = 'bottom'; + // set the xAccessor as date_histogram + frame.datasourceLayers.threshold.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'b') { + return { + dataType: 'string', + isBucketed: true, + scale: 'ordinal', + label: 'top values', + }; + } + return null; + }); + + const options = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'threshold', + }).groups; + + expect(options[0]).toEqual( + expect.objectContaining({ + invalid: true, + groupId: 'xThreshold', + }) + ); + }); + + it('differ vertical axis if the formatters are not compatibles between each other', () => { + const tables: Record = { + first: { + type: 'datatable', + rows: [], + columns: [ + { + id: 'xAccessorId', + name: 'horizontal axis', + meta: { + type: 'date', + params: { params: { id: 'date', params: { pattern: 'HH:mm' } } }, + }, + }, + { + id: 'yAccessorId', + name: 'left axis', + meta: { + type: 'number', + params: { id: 'number' }, + }, + }, + { + id: 'yAccessorId2', + name: 'right axis', + meta: { + type: 'number', + params: { id: 'bytes' }, + }, + }, + ], + }, + }; + + const state = getStateWithBaseThreshold(); + state.layers[0].accessors = ['yAccessorId', 'yAccessorId2']; + state.layers[1].yConfig = []; // empty the configuration + + const options = xyVisualization.getConfiguration({ + state, + frame: { ...frame, activeData: tables }, + layerId: 'threshold', + }).groups; + + expect(options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ groupId: 'yThresholdLeft' }), + expect.objectContaining({ groupId: 'yThresholdRight' }), + ]) + ); + }); + }); + describe('color assignment', () => { function callConfig(layerConfigOverride: Partial) { const baseState = exampleState(); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 026c2827cedb..ed1cc015806c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { uniq } from 'lodash'; +import { groupBy, uniq } from 'lodash'; import { render } from 'react-dom'; import { Position } from '@elastic/charts'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; @@ -14,15 +14,10 @@ import { i18n } from '@kbn/i18n'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { FieldFormatsStart } from 'src/plugins/field_formats/public'; import { getSuggestions } from './xy_suggestions'; -import { XyToolbar, DimensionEditor, LayerHeader } from './xy_config_panel'; -import type { - Visualization, - OperationMetadata, - VisualizationType, - AccessorConfig, - DatasourcePublicAPI, -} from '../types'; -import { State, visualizationTypes, XYState } from './types'; +import { XyToolbar, DimensionEditor } from './xy_config_panel'; +import { LayerHeader } from './xy_config_panel/layer_header'; +import type { Visualization, OperationMetadata, VisualizationType, AccessorConfig } from '../types'; +import { State, visualizationTypes } from './types'; import { SeriesType, XYLayerConfig } from '../../common/expressions'; import { LayerType, layerTypes } from '../../common'; import { isHorizontalChart } from './state_helpers'; @@ -32,6 +27,19 @@ import { LensIconChartMixedXy } from '../assets/chart_mixed_xy'; import { LensIconChartBarHorizontal } from '../assets/chart_bar_horizontal'; import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; import { getColumnToLabelMap } from './state_helpers'; +import { LensIconChartBarThreshold } from '../assets/chart_bar_threshold'; +import { generateId } from '../id_generator'; +import { + getGroupsAvailableInData, + getGroupsRelatedToData, + getGroupsToShow, + getStaticValue, +} from './threshold_helpers'; +import { + checkScaleOperation, + checkXAccessorCompatibility, + getAxisName, +} from './visualization_helpers'; const defaultIcon = LensIconChartBarStacked; const defaultSeriesType = 'bar_stacked'; @@ -186,6 +194,39 @@ export const getXyVisualization = ({ }, getSupportedLayers(state, frame) { + const thresholdGroupIds = [ + { + id: 'yThresholdLeft', + label: 'yLeft' as const, + }, + { + id: 'yThresholdRight', + label: 'yRight' as const, + }, + { + id: 'xThreshold', + label: 'x' as const, + }, + ]; + + const dataLayers = + state?.layers.filter(({ layerType = layerTypes.DATA }) => layerType === layerTypes.DATA) || + []; + const filledDataLayers = dataLayers.filter( + ({ accessors, xAccessor }) => accessors.length || xAccessor + ); + const layerHasNumberHistogram = checkScaleOperation( + 'interval', + 'number', + frame?.datasourceLayers || {} + ); + const thresholdGroups = getGroupsRelatedToData( + thresholdGroupIds, + state, + frame?.datasourceLayers || {}, + frame?.activeData + ); + const layers = [ { type: layerTypes.DATA, @@ -194,6 +235,36 @@ export const getXyVisualization = ({ }), icon: LensIconChartMixedXy, }, + { + type: layerTypes.THRESHOLD, + label: i18n.translate('xpack.lens.xyChart.addThresholdLayerLabel', { + defaultMessage: 'Add threshold layer', + }), + icon: LensIconChartBarThreshold, + disabled: + !filledDataLayers.length || + (!dataLayers.some(layerHasNumberHistogram) && + dataLayers.every(({ accessors }) => !accessors.length)), + tooltipContent: filledDataLayers.length + ? undefined + : i18n.translate('xpack.lens.xyChart.addThresholdLayerLabelDisabledHelp', { + defaultMessage: 'Add some data to enable threshold layer', + }), + initialDimensions: state + ? thresholdGroups.map(({ id, label }) => ({ + groupId: id, + columnId: generateId(), + dataType: 'number', + label: getAxisName(label, { isHorizontal: isHorizontalChart(state?.layers || []) }), + staticValue: getStaticValue( + dataLayers, + label, + { activeData: frame?.activeData }, + layerHasNumberHistogram + ), + })) + : undefined, + }, ]; return layers; @@ -233,8 +304,70 @@ export const getXyVisualization = ({ const isDataLayer = !layer.layerType || layer.layerType === layerTypes.DATA; if (!isDataLayer) { + const idToIndex = sortedAccessors.reduce>((memo, id, index) => { + memo[id] = index; + return memo; + }, {}); + const { bottom, left, right } = groupBy( + [...(layer.yConfig || [])].sort( + ({ forAccessor: forA }, { forAccessor: forB }) => idToIndex[forA] - idToIndex[forB] + ), + ({ axisMode }) => { + return axisMode; + } + ); + const groupsToShow = getGroupsToShow( + [ + // When a threshold layer panel is added, a static threshold should automatically be included by default + // in the first available axis, in the following order: vertical left, vertical right, horizontal. + { + config: left, + id: 'yThresholdLeft', + label: 'yLeft', + dataTestSubj: 'lnsXY_yThresholdLeftPanel', + }, + { + config: right, + id: 'yThresholdRight', + label: 'yRight', + dataTestSubj: 'lnsXY_yThresholdRightPanel', + }, + { + config: bottom, + id: 'xThreshold', + label: 'x', + dataTestSubj: 'lnsXY_xThresholdPanel', + }, + ], + state, + frame.datasourceLayers, + frame?.activeData + ); return { - groups: [], + supportFieldFormat: false, + supportStaticValue: true, + // Each thresholds layer panel will have sections for each available axis + // (horizontal axis, vertical axis left, vertical axis right). + // Only axes that support numeric thresholds should be shown + groups: groupsToShow.map(({ config = [], id, label, dataTestSubj, valid }) => ({ + groupId: id, + groupLabel: getAxisName(label, { isHorizontal }), + accessors: config.map(({ forAccessor, color }) => ({ + columnId: forAccessor, + color: color || mappedAccessors.find(({ columnId }) => columnId === forAccessor)?.color, + triggerIcon: 'color', + })), + filterOperations: isNumericMetric, + supportsMoreColumns: true, + required: false, + enableDimensionEditor: true, + dataTestSubj, + invalid: !valid, + invalidMessage: i18n.translate('xpack.lens.configure.invalidThresholdDimension', { + defaultMessage: + 'This threshold is assigned to an axis that no longer exists. You may move this threshold to another available axis or remove it.', + }), + })), }; } @@ -305,6 +438,30 @@ export const getXyVisualization = ({ newLayer.splitAccessor = columnId; } + if (newLayer.layerType === layerTypes.THRESHOLD) { + newLayer.accessors = [...newLayer.accessors.filter((a) => a !== columnId), columnId]; + const hasYConfig = newLayer.yConfig?.some(({ forAccessor }) => forAccessor === columnId); + if (!hasYConfig) { + newLayer.yConfig = [ + ...(newLayer.yConfig || []), + // TODO: move this + // add a default config if none is available + { + forAccessor: columnId, + axisMode: + groupId === 'xThreshold' + ? 'bottom' + : groupId === 'yThresholdRight' + ? 'right' + : 'left', + icon: undefined, + lineStyle: 'solid', + lineWidth: 1, + }, + ]; + } + } + return { ...prevState, layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)), @@ -331,7 +488,24 @@ export const getXyVisualization = ({ newLayer.yConfig = newLayer.yConfig.filter(({ forAccessor }) => forAccessor !== columnId); } - const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); + let newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); + // // check if there's any threshold layer and pull it off if all data layers have no dimensions set + const layersByType = groupBy(newLayers, ({ layerType }) => layerType); + // // check for data layers if they all still have xAccessors + const groupsAvailable = getGroupsAvailableInData( + layersByType[layerTypes.DATA], + frame.datasourceLayers, + frame?.activeData + ); + if ( + (Object.keys(groupsAvailable) as Array<'x' | 'yLeft' | 'yRight'>).every( + (id) => !groupsAvailable[id] + ) + ) { + newLayers = newLayers.filter( + ({ layerType, accessors }) => layerType === layerTypes.DATA || accessors.length + ); + } return { ...prevState, @@ -510,19 +684,6 @@ function validateLayersForDimension( }; } -function getAxisName(axis: 'x' | 'y', { isHorizontal }: { isHorizontal: boolean }) { - const vertical = i18n.translate('xpack.lens.xyChart.verticalAxisLabel', { - defaultMessage: 'Vertical axis', - }); - const horizontal = i18n.translate('xpack.lens.xyChart.horizontalAxisLabel', { - defaultMessage: 'Horizontal axis', - }); - if (axis === 'x') { - return isHorizontal ? vertical : horizontal; - } - return isHorizontal ? horizontal : vertical; -} - // i18n ids cannot be dynamically generated, hence the function below function getMessageIdsForDimension(dimension: string, layers: number[], isHorizontal: boolean) { const layersList = layers.map((i: number) => i + 1).join(', '); @@ -566,76 +727,6 @@ function newLayerState( }; } -// min requirement for the bug: -// * 2 or more layers -// * at least one with date histogram -// * at least one with interval function -function checkXAccessorCompatibility( - state: XYState, - datasourceLayers: Record -) { - const errors = []; - const hasDateHistogramSet = state.layers.some( - checkScaleOperation('interval', 'date', datasourceLayers) - ); - const hasNumberHistogram = state.layers.some( - checkScaleOperation('interval', 'number', datasourceLayers) - ); - const hasOrdinalAxis = state.layers.some( - checkScaleOperation('ordinal', undefined, datasourceLayers) - ); - if (state.layers.length > 1 && hasDateHistogramSet && hasNumberHistogram) { - errors.push({ - shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', { - defaultMessage: `Wrong data type for {axis}.`, - values: { - axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), - }, - }), - longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXLong', { - defaultMessage: `Data type mismatch for the {axis}. Cannot mix date and number interval types.`, - values: { - axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), - }, - }), - }); - } - if (state.layers.length > 1 && (hasDateHistogramSet || hasNumberHistogram) && hasOrdinalAxis) { - errors.push({ - shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', { - defaultMessage: `Wrong data type for {axis}.`, - values: { - axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), - }, - }), - longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXOrdinalLong', { - defaultMessage: `Data type mismatch for the {axis}, use a different function.`, - values: { - axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), - }, - }), - }); - } - return errors; -} - -function checkScaleOperation( - scaleType: 'ordinal' | 'interval' | 'ratio', - dataType: 'date' | 'number' | 'string' | undefined, - datasourceLayers: Record -) { - return (layer: XYLayerConfig) => { - const datasourceAPI = datasourceLayers[layer.layerId]; - if (!layer.xAccessor) { - return false; - } - const operation = datasourceAPI?.getOperationForColumnId(layer.xAccessor); - return Boolean( - operation && (!dataType || operation.dataType === dataType) && operation.scale === scaleType - ); - }; -} - function getLayersByType(state: State, byType?: string) { return state.layers.filter(({ layerType = layerTypes.DATA }) => byType ? layerType === byType : true diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx new file mode 100644 index 000000000000..22c3c7e89532 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { DatasourcePublicAPI } from '../types'; +import { XYState } from './types'; +import { isHorizontalChart } from './state_helpers'; +import { XYLayerConfig } from '../../common/expressions'; + +export function getAxisName( + axis: 'x' | 'y' | 'yLeft' | 'yRight', + { isHorizontal }: { isHorizontal: boolean } +) { + const vertical = i18n.translate('xpack.lens.xyChart.verticalAxisLabel', { + defaultMessage: 'Vertical axis', + }); + const horizontal = i18n.translate('xpack.lens.xyChart.horizontalAxisLabel', { + defaultMessage: 'Horizontal axis', + }); + if (axis === 'x') { + return isHorizontal ? vertical : horizontal; + } + if (axis === 'y') { + return isHorizontal ? horizontal : vertical; + } + const verticalLeft = i18n.translate('xpack.lens.xyChart.verticalLeftAxisLabel', { + defaultMessage: 'Vertical left axis', + }); + const verticalRight = i18n.translate('xpack.lens.xyChart.verticalRightAxisLabel', { + defaultMessage: 'Vertical right axis', + }); + const horizontalTop = i18n.translate('xpack.lens.xyChart.horizontalLeftAxisLabel', { + defaultMessage: 'Horizontal top axis', + }); + const horizontalBottom = i18n.translate('xpack.lens.xyChart.horizontalRightAxisLabel', { + defaultMessage: 'Horizontal bottom axis', + }); + if (axis === 'yLeft') { + return isHorizontal ? horizontalTop : verticalLeft; + } + return isHorizontal ? horizontalBottom : verticalRight; +} + +// min requirement for the bug: +// * 2 or more layers +// * at least one with date histogram +// * at least one with interval function +export function checkXAccessorCompatibility( + state: XYState, + datasourceLayers: Record +) { + const errors = []; + const hasDateHistogramSet = state.layers.some( + checkScaleOperation('interval', 'date', datasourceLayers) + ); + const hasNumberHistogram = state.layers.some( + checkScaleOperation('interval', 'number', datasourceLayers) + ); + const hasOrdinalAxis = state.layers.some( + checkScaleOperation('ordinal', undefined, datasourceLayers) + ); + if (state.layers.length > 1 && hasDateHistogramSet && hasNumberHistogram) { + errors.push({ + shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', { + defaultMessage: `Wrong data type for {axis}.`, + values: { + axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), + }, + }), + longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXLong', { + defaultMessage: `Data type mismatch for the {axis}. Cannot mix date and number interval types.`, + values: { + axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), + }, + }), + }); + } + if (state.layers.length > 1 && (hasDateHistogramSet || hasNumberHistogram) && hasOrdinalAxis) { + errors.push({ + shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', { + defaultMessage: `Wrong data type for {axis}.`, + values: { + axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), + }, + }), + longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXOrdinalLong', { + defaultMessage: `Data type mismatch for the {axis}, use a different function.`, + values: { + axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), + }, + }), + }); + } + return errors; +} + +export function checkScaleOperation( + scaleType: 'ordinal' | 'interval' | 'ratio', + dataType: 'date' | 'number' | 'string' | undefined, + datasourceLayers: Record +) { + return (layer: XYLayerConfig) => { + const datasourceAPI = datasourceLayers[layer.layerId]; + if (!layer.xAccessor) { + return false; + } + const operation = datasourceAPI?.getOperationForColumnId(layer.xAccessor); + return Boolean( + operation && (!dataType || operation.dataType === dataType) && operation.scale === scaleType + ); + }; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.test.tsx similarity index 98% rename from x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.test.tsx index aa287795c818..ebe0e536a4d7 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.test.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { shallowWithIntl as shallow } from '@kbn/test/jest'; import { AxisSettingsPopover, AxisSettingsPopoverProps } from './axis_settings_popover'; -import { ToolbarPopover } from '../shared_components'; -import { layerTypes } from '../../common'; +import { ToolbarPopover } from '../../shared_components'; +import { layerTypes } from '../../../common'; describe('Axes Settings', () => { let props: AxisSettingsPopoverProps; diff --git a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx similarity index 96% rename from x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx index 2285cd1a7a43..e0a30bdb2c51 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx @@ -20,15 +20,15 @@ import { EuiFieldNumber, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { XYLayerConfig, AxesSettingsConfig, AxisExtentConfig } from '../../common/expressions'; -import { ToolbarPopover, useDebouncedValue } from '../shared_components'; -import { isHorizontalChart } from './state_helpers'; -import { EuiIconAxisBottom } from '../assets/axis_bottom'; -import { EuiIconAxisLeft } from '../assets/axis_left'; -import { EuiIconAxisRight } from '../assets/axis_right'; -import { EuiIconAxisTop } from '../assets/axis_top'; -import { ToolbarButtonProps } from '../../../../../src/plugins/kibana_react/public'; -import { validateExtent } from './axes_configuration'; +import { XYLayerConfig, AxesSettingsConfig, AxisExtentConfig } from '../../../common/expressions'; +import { ToolbarPopover, useDebouncedValue } from '../../shared_components'; +import { isHorizontalChart } from '../state_helpers'; +import { EuiIconAxisBottom } from '../../assets/axis_bottom'; +import { EuiIconAxisLeft } from '../../assets/axis_left'; +import { EuiIconAxisRight } from '../../assets/axis_right'; +import { EuiIconAxisTop } from '../../assets/axis_top'; +import { ToolbarButtonProps } from '../../../../../../src/plugins/kibana_react/public'; +import { validateExtent } from '../axes_configuration'; type AxesSettingsConfigKeys = keyof AxesSettingsConfig; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx new file mode 100644 index 000000000000..5a6458a4654d --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import './xy_config_panel.scss'; +import React, { useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { debounce } from 'lodash'; +import { EuiFormRow, EuiColorPicker, EuiColorPickerProps, EuiToolTip, EuiIcon } from '@elastic/eui'; +import type { PaletteRegistry } from 'src/plugins/charts/public'; +import type { VisualizationDimensionEditorProps } from '../../types'; +import { State } from '../types'; +import { FormatFactory } from '../../../common'; +import { getSeriesColor } from '../state_helpers'; +import { getAccessorColorConfig, getColorAssignments } from '../color_assignment'; +import { getSortedAccessors } from '../to_expression'; +import { updateLayer } from '.'; +import { TooltipWrapper } from '../../shared_components'; + +const tooltipContent = { + auto: i18n.translate('xpack.lens.configPanel.color.tooltip.auto', { + defaultMessage: 'Lens automatically picks colors for you unless you specify a custom color.', + }), + custom: i18n.translate('xpack.lens.configPanel.color.tooltip.custom', { + defaultMessage: 'Clear the custom color to return to “Auto” mode.', + }), + disabled: i18n.translate('xpack.lens.configPanel.color.tooltip.disabled', { + defaultMessage: + 'Individual series cannot be custom colored when the layer includes a “Break down by.“', + }), +}; + +export const ColorPicker = ({ + state, + setState, + layerId, + accessor, + frame, + formatFactory, + paletteService, + label, + disableHelpTooltip, +}: VisualizationDimensionEditorProps & { + formatFactory: FormatFactory; + paletteService: PaletteRegistry; + label?: string; + disableHelpTooltip?: boolean; +}) => { + const index = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[index]; + const disabled = Boolean(layer.splitAccessor); + + const overwriteColor = getSeriesColor(layer, accessor); + const currentColor = useMemo(() => { + if (overwriteColor || !frame.activeData) return overwriteColor; + + const datasource = frame.datasourceLayers[layer.layerId]; + const sortedAccessors: string[] = getSortedAccessors(datasource, layer); + + const colorAssignments = getColorAssignments( + state.layers, + { tables: frame.activeData }, + formatFactory + ); + const mappedAccessors = getAccessorColorConfig( + colorAssignments, + frame, + { + ...layer, + accessors: sortedAccessors.filter((sorted) => layer.accessors.includes(sorted)), + }, + paletteService + ); + + return mappedAccessors.find((a) => a.columnId === accessor)?.color || null; + }, [overwriteColor, frame, paletteService, state.layers, accessor, formatFactory, layer]); + + const [color, setColor] = useState(currentColor); + + const handleColor: EuiColorPickerProps['onChange'] = (text, output) => { + setColor(text); + if (output.isValid || text === '') { + updateColorInState(text, output); + } + }; + + const updateColorInState: EuiColorPickerProps['onChange'] = useMemo( + () => + debounce((text, output) => { + const newYConfigs = [...(layer.yConfig || [])]; + const existingIndex = newYConfigs.findIndex((yConfig) => yConfig.forAccessor === accessor); + if (existingIndex !== -1) { + if (text === '') { + newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: undefined }; + } else { + newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: output.hex }; + } + } else { + newYConfigs.push({ + forAccessor: accessor, + color: output.hex, + }); + } + setState(updateLayer(state, { ...layer, yConfig: newYConfigs }, index)); + }, 256), + [state, setState, layer, accessor, index] + ); + + const inputLabel = + label ?? + i18n.translate('xpack.lens.xyChart.seriesColor.label', { + defaultMessage: 'Series color', + }); + + const colorPicker = ( + + ); + + return ( + + + {inputLabel} + {!disableHelpTooltip && ( + <> + {''} + + + )} + + + } + > + {disabled ? ( + + {colorPicker} + + ) : ( + colorPicker + )} + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx similarity index 72% rename from x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx index cd90fa52cd40..1427a3d28ea3 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx @@ -6,24 +6,15 @@ */ import './xy_config_panel.scss'; -import React, { useMemo, useState, memo, useCallback } from 'react'; +import React, { memo, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { Position, ScaleType, VerticalAlignment, HorizontalAlignment } from '@elastic/charts'; -import { debounce } from 'lodash'; import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem, EuiFormRow, htmlIdGenerator, - EuiColorPicker, - EuiColorPickerProps, - EuiToolTip, - EuiIcon, - EuiPopover, - EuiSelectable, - EuiText, - EuiPopoverTitle, } from '@elastic/eui'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { @@ -31,30 +22,34 @@ import type { VisualizationToolbarProps, VisualizationDimensionEditorProps, FramePublicAPI, -} from '../types'; -import { State, visualizationTypes, XYState } from './types'; -import type { FormatFactory } from '../../common'; +} from '../../types'; +import { State, visualizationTypes, XYState } from '../types'; +import type { FormatFactory } from '../../../common'; import { SeriesType, YAxisMode, AxesSettingsConfig, AxisExtentConfig, -} from '../../common/expressions'; -import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers'; -import { trackUiEvent } from '../lens_ui_telemetry'; -import { LegendSettingsPopover } from '../shared_components'; +} from '../../../common/expressions'; +import { isHorizontalChart, isHorizontalSeries } from '../state_helpers'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { LegendSettingsPopover } from '../../shared_components'; import { AxisSettingsPopover } from './axis_settings_popover'; -import { getAxesConfiguration, GroupsConfiguration } from './axes_configuration'; -import { PalettePicker, TooltipWrapper } from '../shared_components'; -import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; -import { getScaleType, getSortedAccessors } from './to_expression'; -import { VisualOptionsPopover } from './visual_options_popover/visual_options_popover'; -import { ToolbarButton } from '../../../../../src/plugins/kibana_react/public'; +import { getAxesConfiguration, GroupsConfiguration } from '../axes_configuration'; +import { VisualOptionsPopover } from './visual_options_popover'; +import { getScaleType } from '../to_expression'; +import { ColorPicker } from './color_picker'; +import { ThresholdPanel } from './threshold_panel'; +import { PalettePicker, TooltipWrapper } from '../../shared_components'; type UnwrapArray = T extends Array ? P : T; type AxesSettingsConfigKeys = keyof AxesSettingsConfig; -function updateLayer(state: State, layer: UnwrapArray, index: number): State { +export function updateLayer( + state: State, + layer: UnwrapArray, + index: number +): State { const newLayers = [...state.layers]; newLayers[index] = layer; @@ -92,90 +87,6 @@ const legendOptions: Array<{ }, ]; -export function LayerHeader(props: VisualizationLayerWidgetProps) { - const [isPopoverOpen, setPopoverIsOpen] = useState(false); - const { state, layerId } = props; - const horizontalOnly = isHorizontalChart(state.layers); - const index = state.layers.findIndex((l) => l.layerId === layerId); - const layer = state.layers[index]; - if (!layer) { - return null; - } - - const currentVisType = visualizationTypes.find(({ id }) => id === layer.seriesType)!; - - const createTrigger = function () { - return ( - setPopoverIsOpen(!isPopoverOpen)} - fullWidth - size="s" - > - <> - - - {currentVisType.fullLabel || currentVisType.label} - - - - ); - }; - - return ( - <> - setPopoverIsOpen(false)} - display="block" - panelPaddingSize="s" - ownFocus - > - - {i18n.translate('xpack.lens.layerPanel.layerVisualizationType', { - defaultMessage: 'Layer visualization type', - })} - -
- - singleSelection="always" - options={visualizationTypes - .filter((t) => isHorizontalSeries(t.id as SeriesType) === horizontalOnly) - .map((t) => ({ - value: t.id, - key: t.id, - checked: t.id === currentVisType.id ? 'on' : undefined, - prepend: , - label: t.fullLabel || t.label, - 'data-test-subj': `lnsXY_seriesType-${t.id}`, - }))} - onChange={(newOptions) => { - const chosenType = newOptions.find(({ checked }) => checked === 'on'); - if (!chosenType) { - return; - } - const id = chosenType.value!; - trackUiEvent('xy_change_layer_display'); - props.setState(updateLayer(state, { ...layer, seriesType: id as SeriesType }, index)); - setPopoverIsOpen(false); - }} - > - {(list) => <>{list}} - -
-
- - ); -} - export function LayerContextMenu(props: VisualizationLayerWidgetProps) { const { state, layerId } = props; const horizontalOnly = isHorizontalChart(state.layers); @@ -622,7 +533,7 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp ); }); -const idPrefix = htmlIdGenerator()(); +export const idPrefix = htmlIdGenerator()(); export function DimensionEditor( props: VisualizationDimensionEditorProps & { @@ -653,6 +564,10 @@ export function DimensionEditor( ); } + if (layer.layerType === 'threshold') { + return ; + } + return ( <> @@ -728,140 +643,3 @@ export function DimensionEditor( ); } - -const tooltipContent = { - auto: i18n.translate('xpack.lens.configPanel.color.tooltip.auto', { - defaultMessage: 'Lens automatically picks colors for you unless you specify a custom color.', - }), - custom: i18n.translate('xpack.lens.configPanel.color.tooltip.custom', { - defaultMessage: 'Clear the custom color to return to “Auto” mode.', - }), - disabled: i18n.translate('xpack.lens.configPanel.color.tooltip.disabled', { - defaultMessage: - 'Individual series cannot be custom colored when the layer includes a “Break down by.“', - }), -}; - -const ColorPicker = ({ - state, - setState, - layerId, - accessor, - frame, - formatFactory, - paletteService, -}: VisualizationDimensionEditorProps & { - formatFactory: FormatFactory; - paletteService: PaletteRegistry; -}) => { - const index = state.layers.findIndex((l) => l.layerId === layerId); - const layer = state.layers[index]; - const disabled = !!layer.splitAccessor; - - const overwriteColor = getSeriesColor(layer, accessor); - const currentColor = useMemo(() => { - if (overwriteColor || !frame.activeData) return overwriteColor; - - const datasource = frame.datasourceLayers[layer.layerId]; - const sortedAccessors: string[] = getSortedAccessors(datasource, layer); - - const colorAssignments = getColorAssignments( - state.layers, - { tables: frame.activeData }, - formatFactory - ); - const mappedAccessors = getAccessorColorConfig( - colorAssignments, - frame, - { - ...layer, - accessors: sortedAccessors.filter((sorted) => layer.accessors.includes(sorted)), - }, - paletteService - ); - - return mappedAccessors.find((a) => a.columnId === accessor)?.color || null; - }, [overwriteColor, frame, paletteService, state.layers, accessor, formatFactory, layer]); - - const [color, setColor] = useState(currentColor); - - const handleColor: EuiColorPickerProps['onChange'] = (text, output) => { - setColor(text); - if (output.isValid || text === '') { - updateColorInState(text, output); - } - }; - - const updateColorInState: EuiColorPickerProps['onChange'] = useMemo( - () => - debounce((text, output) => { - const newYConfigs = [...(layer.yConfig || [])]; - const existingIndex = newYConfigs.findIndex((yConfig) => yConfig.forAccessor === accessor); - if (existingIndex !== -1) { - if (text === '') { - newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: undefined }; - } else { - newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: output.hex }; - } - } else { - newYConfigs.push({ - forAccessor: accessor, - color: output.hex, - }); - } - setState(updateLayer(state, { ...layer, yConfig: newYConfigs }, index)); - }, 256), - [state, setState, layer, accessor, index] - ); - - const colorPicker = ( - - ); - - return ( - - - {i18n.translate('xpack.lens.xyChart.seriesColor.label', { - defaultMessage: 'Series color', - })}{' '} - - -
- } - > - {disabled ? ( - - {colorPicker} - - ) : ( - colorPicker - )} - - ); -}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx new file mode 100644 index 000000000000..dde4de0dd4bc --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import './xy_config_panel.scss'; +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiIcon, EuiPopover, EuiSelectable, EuiText, EuiPopoverTitle } from '@elastic/eui'; +import type { VisualizationLayerWidgetProps } from '../../types'; +import { State, visualizationTypes } from '../types'; +import { layerTypes } from '../../../common'; +import { SeriesType } from '../../../common/expressions'; +import { isHorizontalChart, isHorizontalSeries } from '../state_helpers'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { StaticHeader } from '../../shared_components'; +import { ToolbarButton } from '../../../../../../src/plugins/kibana_react/public'; +import { LensIconChartBarThreshold } from '../../assets/chart_bar_threshold'; +import { updateLayer } from '.'; + +export function LayerHeader(props: VisualizationLayerWidgetProps) { + const [isPopoverOpen, setPopoverIsOpen] = useState(false); + const { state, layerId } = props; + const horizontalOnly = isHorizontalChart(state.layers); + const index = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[index]; + if (!layer) { + return null; + } + // if it's a threshold just draw a static text + if (layer.layerType === layerTypes.THRESHOLD) { + return ( + + ); + } + const currentVisType = visualizationTypes.find(({ id }) => id === layer.seriesType)!; + + const createTrigger = function () { + return ( + setPopoverIsOpen(!isPopoverOpen)} + fullWidth + size="s" + > + <> + + + {currentVisType.fullLabel || currentVisType.label} + + + + ); + }; + + return ( + <> + setPopoverIsOpen(false)} + display="block" + panelPaddingSize="s" + ownFocus + > + + {i18n.translate('xpack.lens.layerPanel.layerVisualizationType', { + defaultMessage: 'Layer visualization type', + })} + +
+ + singleSelection="always" + options={visualizationTypes + .filter((t) => isHorizontalSeries(t.id as SeriesType) === horizontalOnly) + .map((t) => ({ + value: t.id, + key: t.id, + checked: t.id === currentVisType.id ? 'on' : undefined, + prepend: , + label: t.fullLabel || t.label, + 'data-test-subj': `lnsXY_seriesType-${t.id}`, + }))} + onChange={(newOptions) => { + const chosenType = newOptions.find(({ checked }) => checked === 'on'); + if (!chosenType) { + return; + } + const id = chosenType.value!; + trackUiEvent('xy_change_layer_display'); + props.setState(updateLayer(state, { ...layer, seriesType: id as SeriesType }, index)); + setPopoverIsOpen(false); + }} + > + {(list) => <>{list}} + +
+
+ + ); +} diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx new file mode 100644 index 000000000000..1e5b90e41b62 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx @@ -0,0 +1,326 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import './xy_config_panel.scss'; +import React, { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonGroup, EuiComboBox, EuiFormRow, EuiIcon, EuiRange } from '@elastic/eui'; +import type { PaletteRegistry } from 'src/plugins/charts/public'; +import type { VisualizationDimensionEditorProps } from '../../types'; +import { State } from '../types'; +import { FormatFactory } from '../../../common'; +import { YConfig } from '../../../common/expressions'; +import { LineStyle, FillStyle } from '../../../common/expressions/xy_chart'; + +import { ColorPicker } from './color_picker'; +import { updateLayer, idPrefix } from '.'; +import { useDebouncedValue } from '../../shared_components'; + +const icons = [ + { + value: 'none', + label: i18n.translate('xpack.lens.xyChart.thresholds.noIconLabel', { defaultMessage: 'None' }), + }, + { + value: 'asterisk', + label: i18n.translate('xpack.lens.xyChart.thresholds.asteriskIconLabel', { + defaultMessage: 'Asterisk', + }), + }, + { + value: 'bell', + label: i18n.translate('xpack.lens.xyChart.thresholds.bellIconLabel', { + defaultMessage: 'Bell', + }), + }, + { + value: 'bolt', + label: i18n.translate('xpack.lens.xyChart.thresholds.boltIconLabel', { + defaultMessage: 'Bolt', + }), + }, + { + value: 'bug', + label: i18n.translate('xpack.lens.xyChart.thresholds.bugIconLabel', { + defaultMessage: 'Bug', + }), + }, + { + value: 'editorComment', + label: i18n.translate('xpack.lens.xyChart.thresholds.commentIconLabel', { + defaultMessage: 'Comment', + }), + }, + { + value: 'alert', + label: i18n.translate('xpack.lens.xyChart.thresholds.alertIconLabel', { + defaultMessage: 'Alert', + }), + }, + { + value: 'flag', + label: i18n.translate('xpack.lens.xyChart.thresholds.flagIconLabel', { + defaultMessage: 'Flag', + }), + }, + { + value: 'tag', + label: i18n.translate('xpack.lens.xyChart.thresholds.tagIconLabel', { + defaultMessage: 'Tag', + }), + }, +]; + +const IconView = (props: { value?: string; label: string }) => { + if (!props.value) return null; + return ( + + + {` ${props.label}`} + + ); +}; + +const IconSelect = ({ + value, + onChange, +}: { + value?: string; + onChange: (newIcon: string) => void; +}) => { + const selectedIcon = icons.find((option) => value === option.value) || icons[0]; + + return ( + { + onChange(selection[0].value!); + }} + singleSelection={{ asPlainText: true }} + renderOption={IconView} + compressed + /> + ); +}; + +export const ThresholdPanel = ( + props: VisualizationDimensionEditorProps & { + formatFactory: FormatFactory; + paletteService: PaletteRegistry; + } +) => { + const { state, setState, layerId, accessor } = props; + const index = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[index]; + + const setYConfig = useCallback( + (yConfig: Partial | undefined) => { + if (yConfig == null) { + return; + } + setState((currState) => { + const currLayer = currState.layers[index]; + const newYConfigs = [...(currLayer.yConfig || [])]; + const existingIndex = newYConfigs.findIndex( + (yAxisConfig) => yAxisConfig.forAccessor === accessor + ); + if (existingIndex !== -1) { + newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], ...yConfig }; + } else { + newYConfigs.push({ forAccessor: accessor, ...yConfig }); + } + return updateLayer(currState, { ...currLayer, yConfig: newYConfigs }, index); + }); + }, + [accessor, index, setState] + ); + + const currentYConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor); + + return ( + <> + + + { + const newMode = id.replace(idPrefix, '') as LineStyle; + setYConfig({ forAccessor: accessor, lineStyle: newMode }); + }} + /> + + + { + setYConfig({ forAccessor: accessor, lineWidth: value }); + }} + /> + + + { + const newMode = id.replace(idPrefix, '') as FillStyle; + setYConfig({ forAccessor: accessor, fill: newMode }); + }} + /> + + + { + setYConfig({ forAccessor: accessor, icon: newIcon }); + }} + /> + + + ); +}; + +const minRange = 1; +const maxRange = 10; + +function getSafeValue(value: number | '', prevValue: number, min: number, max: number) { + if (value === '') { + return prevValue; + } + return Math.max(minRange, Math.min(value, maxRange)); +} + +const LineThicknessSlider = ({ + value, + onChange, +}: { + value: number; + onChange: (value: number) => void; +}) => { + const onChangeWrapped = useCallback( + (newValue) => { + if (Number.isInteger(newValue)) { + onChange(getSafeValue(newValue, newValue, minRange, maxRange)); + } + }, + [onChange] + ); + const { inputValue, handleInputChange } = useDebouncedValue( + { value, onChange: onChangeWrapped }, + { allowFalsyValue: true } + ); + + return ( + { + const newValue = e.currentTarget.value; + handleInputChange(newValue === '' ? '' : Number(newValue)); + }} + onBlur={() => { + handleInputChange(getSafeValue(inputValue, value, minRange, maxRange)); + }} + /> + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/fill_opacity_option.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/fill_opacity_option.test.tsx similarity index 100% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/fill_opacity_option.test.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/fill_opacity_option.test.tsx diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/fill_opacity_option.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/fill_opacity_option.tsx similarity index 95% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/fill_opacity_option.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/fill_opacity_option.tsx index eb8d35c54a99..09b381dd03f7 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/fill_opacity_option.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/fill_opacity_option.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiRange } from '@elastic/eui'; -import { useDebouncedValue } from '../../shared_components'; +import { useDebouncedValue } from '../../../shared_components'; export interface FillOpacityOptionProps { /** diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/index.tsx similarity index 93% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/index.tsx index 6d0e5c2d55b7..2a19897445e6 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/index.tsx @@ -7,14 +7,14 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { ToolbarPopover, TooltipWrapper } from '../../shared_components'; +import { ToolbarPopover, TooltipWrapper } from '../../../shared_components'; import { MissingValuesOptions } from './missing_values_option'; import { LineCurveOption } from './line_curve_option'; import { FillOpacityOption } from './fill_opacity_option'; -import { XYState } from '../types'; -import { hasHistogramSeries } from '../state_helpers'; -import { ValidLayer } from '../../../common/expressions'; -import type { FramePublicAPI } from '../../types'; +import { XYState } from '../../types'; +import { hasHistogramSeries } from '../../state_helpers'; +import { ValidLayer } from '../../../../common/expressions'; +import type { FramePublicAPI } from '../../../types'; function getValueLabelDisableReason({ isAreaPercentage, diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/line_curve_option.test.tsx similarity index 100% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.test.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/line_curve_option.test.tsx diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/line_curve_option.tsx similarity index 95% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/line_curve_option.tsx index 6080a8c68e57..96926412afb8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/line_curve_option.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiSwitch } from '@elastic/eui'; -import type { XYCurveType } from '../../../common/expressions'; +import type { XYCurveType } from '../../../../common/expressions'; export interface LineCurveOptionProps { /** diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_value_option.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/missing_value_option.test.tsx similarity index 100% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_value_option.test.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/missing_value_option.test.tsx diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/missing_values_option.tsx similarity index 97% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/missing_values_option.tsx index 3dba8757903e..b12e2d2f5711 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/missing_values_option.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonGroup, EuiFormRow, EuiIconTip, EuiSuperSelect, EuiText } from '@elastic/eui'; -import { fittingFunctionDefinitions } from '../../../common/expressions'; -import type { FittingFunction, ValueLabelConfig } from '../../../common/expressions'; +import { fittingFunctionDefinitions } from '../../../../common/expressions'; +import type { FittingFunction, ValueLabelConfig } from '../../../../common/expressions'; export interface MissingValuesOptionProps { valueLabels?: ValueLabelConfig; diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/visual_options_popover.test.tsx similarity index 96% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/visual_options_popover.test.tsx index cd6a20c37dd3..0136612c4670 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/visual_options_popover.test.tsx @@ -8,14 +8,14 @@ import React from 'react'; import { shallowWithIntl as shallow } from '@kbn/test/jest'; import { Position } from '@elastic/charts'; -import type { FramePublicAPI } from '../../types'; -import { createMockDatasource, createMockFramePublicAPI } from '../../mocks'; -import { State } from '../types'; -import { VisualOptionsPopover } from './visual_options_popover'; -import { ToolbarPopover } from '../../shared_components'; +import type { FramePublicAPI } from '../../../types'; +import { createMockDatasource, createMockFramePublicAPI } from '../../../mocks'; +import { State } from '../../types'; +import { VisualOptionsPopover } from '.'; +import { ToolbarPopover } from '../../../shared_components'; import { MissingValuesOptions } from './missing_values_option'; import { FillOpacityOption } from './fill_opacity_option'; -import { layerTypes } from '../../../common'; +import { layerTypes } from '../../../../common'; describe('Visual options popover', () => { let frame: FramePublicAPI; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.scss b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.scss similarity index 100% rename from x-pack/plugins/lens/public/xy_visualization/xy_config_panel.scss rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.scss diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx similarity index 98% rename from x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx index 9ca9021382fd..e5b1870c7340 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx @@ -8,15 +8,15 @@ import React from 'react'; import { mountWithIntl as mount, shallowWithIntl as shallow } from '@kbn/test/jest'; import { EuiButtonGroupProps, EuiButtonGroup } from '@elastic/eui'; -import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; +import { LayerContextMenu, XyToolbar, DimensionEditor } from '.'; import { AxisSettingsPopover } from './axis_settings_popover'; -import { FramePublicAPI } from '../types'; -import { State } from './types'; +import { FramePublicAPI } from '../../types'; +import { State } from '../types'; import { Position } from '@elastic/charts'; -import { createMockFramePublicAPI, createMockDatasource } from '../mocks'; +import { createMockFramePublicAPI, createMockDatasource } from '../../mocks'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; import { EuiColorPicker } from '@elastic/eui'; -import { layerTypes } from '../../common'; +import { layerTypes } from '../../../common'; describe('XY Config panels', () => { let frame: FramePublicAPI; diff --git a/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.test.js b/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.test.js index 067b03d938ee..8ec83d8679e8 100644 --- a/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.test.js +++ b/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.test.js @@ -41,7 +41,7 @@ function prepareTest(messages) { }; const kibana = { services: { - notifications: { toasts: { addDanger: jest.fn() } }, + notifications: { toasts: { addDanger: jest.fn(), addError: jest.fn() } }, }, }; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index c714b388c826..ddb46edc7b92 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -79,8 +79,12 @@ function ExplorerChartContainer({ let isCancelled = false; const generateLink = async () => { if (!isCancelled && series.functionDescription !== ML_JOB_AGGREGATION.LAT_LONG) { - const singleMetricViewerLink = await getExploreSeriesLink(mlLocator, series, timefilter); - setExplorerSeriesLink(singleMetricViewerLink); + try { + const singleMetricViewerLink = await getExploreSeriesLink(mlLocator, series, timefilter); + setExplorerSeriesLink(singleMetricViewerLink); + } catch (error) { + setExplorerSeriesLink(''); + } } }; generateLink(); diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts index fbe0ff650cc2..6ffb74131bf6 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts @@ -12,6 +12,10 @@ import { BucketSpanEstimatorData } from '../../../common/types/job_service'; import { estimateBucketSpanFactory } from './bucket_span_estimator'; +jest.mock('../../lib/log', () => ({ + mlLog: { warn: jest.fn() }, +})); + const callAs = { search: () => Promise.resolve({ body: {} }), cluster: { getSettings: () => Promise.resolve({ body: {} }) }, diff --git a/x-pack/plugins/monitoring/public/application/index.tsx b/x-pack/plugins/monitoring/public/application/index.tsx index 6db934303523..8cd5bc3088ac 100644 --- a/x-pack/plugins/monitoring/public/application/index.tsx +++ b/x-pack/plugins/monitoring/public/application/index.tsx @@ -18,6 +18,7 @@ import { GlobalStateProvider } from './global_state_context'; import { ExternalConfigContext, ExternalConfig } from './external_config_context'; import { createPreserveQueryHistory } from './preserve_query_history'; import { RouteInit } from './route_init'; +import { NoDataPage } from './pages/no_data'; import { ElasticsearchOverviewPage } from './pages/elasticsearch/overview'; import { CODE_PATH_ELASTICSEARCH } from '../../common/constants'; import { MonitoringTimeContainer } from './hooks/use_monitoring_time'; @@ -54,7 +55,7 @@ const MonitoringApp: React.FC<{ - + = () => { - return
No data page
; -}; - const Home: React.FC<{}> = () => { return
Home page (Cluster listing)
; }; diff --git a/x-pack/plugins/monitoring/public/application/pages/no_data/enabler.ts b/x-pack/plugins/monitoring/public/application/pages/no_data/enabler.ts new file mode 100644 index 000000000000..6225e3863b2f --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/no_data/enabler.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// From x-pack/plugins/monitoring/public/lib/elasticsearch_settings/enabler.js +export class Enabler { + http: any; + updateModel: any; + + constructor(http: any, updateModel: (properties: any) => void) { + this.http = http; + this.updateModel = updateModel; + } + + async enableCollectionInterval() { + try { + this.updateModel({ isCollectionIntervalUpdating: true }); + + await this.http.fetch('../api/monitoring/v1/elasticsearch_settings/set/collection_interval', { + method: 'PUT', + }); + this.updateModel({ + isCollectionIntervalUpdated: true, + isCollectionIntervalUpdating: false, + }); + } catch (err) { + this.updateModel({ + errors: (err as any).data, + isCollectionIntervalUpdated: false, + isCollectionIntervalUpdating: false, + }); + } + } + + async enableCollectionEnabled() { + try { + this.updateModel({ isCollectionEnabledUpdating: true }); + await this.http.fetch('../api/monitoring/v1/elasticsearch_settings/set/collection_enabled', { + method: 'PUT', + }); + + this.updateModel({ + isCollectionEnabledUpdated: true, + isCollectionEnabledUpdating: false, + }); + } catch (err) { + this.updateModel({ + errors: (err as any).data, + isCollectionEnabledUpdated: false, + isCollectionEnabledUpdating: false, + }); + } + } +} diff --git a/x-pack/plugins/monitoring/public/application/pages/no_data/index.ts b/x-pack/plugins/monitoring/public/application/pages/no_data/index.ts new file mode 100644 index 000000000000..7fa176d0e6ad --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/no_data/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { NoDataPage } from './no_data_page'; diff --git a/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx b/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx new file mode 100644 index 000000000000..b05bd783b2ff --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useContext, useState } from 'react'; +import { Redirect } from 'react-router-dom'; + +import { i18n } from '@kbn/i18n'; +// @ts-ignore +import { NoData } from '../../../components/no_data'; +import { PageTemplate } from '../page_template'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { CODE_PATH_LICENSE, STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../../common/constants'; +import { Legacy } from '../../../legacy_shims'; +import { Enabler } from './enabler'; +import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; +import { initSetupModeState } from '../../setup_mode/setup_mode'; +import { GlobalStateContext } from '../../global_state_context'; + +const CODE_PATHS = [CODE_PATH_LICENSE]; + +interface NoDataPageSetupDeps { + http: any; + data: any; +} + +interface SettingsChecker { + message: string; + api: string; + next?: SettingsChecker; +} + +const clusterCheckers: SettingsChecker[] = [ + { + message: i18n.translate('xpack.monitoring.noData.checker.clusterSettings', { + defaultMessage: 'Checking cluster settings API on production cluster', + }), + api: '../api/monitoring/v1/elasticsearch_settings/check/cluster', + }, + { + message: i18n.translate('xpack.monitoring.noData.checker.nodesSettings', { + defaultMessage: 'Checking nodes settings API on production cluster', + }), + api: '../api/monitoring/v1/elasticsearch_settings/check/nodes', + }, +]; + +export const NoDataPage = () => { + const title = i18n.translate('xpack.monitoring.noData.routeTitle', { + defaultMessage: 'Setup Monitoring', + }); + + const { services } = useKibana(); + const [shouldRedirect, setShouldRedirect] = useState(false); + + const [model, setModel] = useState({ + errors: [], // errors can happen from trying to check or set ES settings + checkMessage: null, // message to show while waiting for api response + isLoading: true, // flag for in-progress state of checking for no data reason + isCollectionEnabledUpdating: false, // flags to indicate whether to show a spinner while waiting for ajax + isCollectionEnabledUpdated: false, + isCollectionIntervalUpdating: false, + isCollectionIntervalUpdated: false, + } as any); + + const { update: updateBreadcrumbs } = useContext(BreadcrumbContainer.Context); + updateBreadcrumbs([ + { + 'data-test-subj': 'breadcrumbClusters', + text: 'Clusters', + href: '#/home', + ignoreGlobalState: true, + }, + ]); + + const globalState = useContext(GlobalStateContext); + initSetupModeState(globalState, services.http); + + // From x-pack/plugins/monitoring/public/views/no_data/model_updater.js + const updateModel = useCallback( + (properties: any) => { + setModel((previousModel: any) => { + const updated = { ...previousModel }; + const keys = Object.keys(properties); + + keys.forEach((key) => { + if (Array.isArray(updated[key])) { + updated[key].push(properties[key]); + } else { + updated[key] = properties[key]; + } + }); + + return updated; + }); + }, + [setModel] + ); + + const getPageData = useCallback(async () => { + let catchReason; + try { + const clusters = await getClusters(services); + + if (clusters && clusters.length) { + setShouldRedirect(true); + return; + } + } catch (err) { + if (err && err.status === 503) { + catchReason = { + property: 'custom', + message: err.data.message, + }; + } + } + + if (catchReason) { + updateModel({ reason: catchReason }); + } else { + await startChecks(clusterCheckers, services.http, updateModel); + } + }, [services, updateModel]); + + const enabler = new Enabler(services.http, updateModel); + + return ( + + {shouldRedirect ? ( + + ) : ( + + )} + + ); +}; + +async function getClusters(services: NoDataPageSetupDeps): Promise { + const url = '../api/monitoring/v1/clusters'; + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + const min = bounds.min.toISOString(); + const max = bounds.max.toISOString(); + + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + css: undefined, + timeRange: { + min, + max, + }, + codePaths: CODE_PATHS, + }), + }); + + return formatClusters(response); +} + +// From x-pack/plugins/monitoring/public/lib/elasticsearch_settings/start_checks.js +const mapCheckers = (_checkers: SettingsChecker[]) => { + return _checkers.map((current, checkerIndex) => { + const next = _checkers[checkerIndex + 1]; + if (next !== undefined) { + current.next = next; + } + + return current; + }); +}; + +// From x-pack/plugins/monitoring/public/lib/elasticsearch_settings/start_checks.js +function startChecks( + checkers: SettingsChecker[], + http: { fetch: any }, + updateModel: (properties: any) => void +) { + const runCheck = async (currentChecker: SettingsChecker): Promise => { + updateModel({ checkMessage: currentChecker.message }); + + const { found, reason, error, errorReason } = await executeCheck(currentChecker, http); + + if (error) { + updateModel({ errors: errorReason }); + if (currentChecker.next) { + return runCheck(currentChecker.next); + } + } else if (found) { + return updateModel({ + reason, + isLoading: false, + checkMessage: null, + }); + } else if (currentChecker.next) { + return runCheck(currentChecker.next); + } + + // dead end + updateModel({ + reason: null, + isLoading: false, + checkMessage: null, + }); + }; + + const _checkers = mapCheckers(checkers); + return runCheck(_checkers[0]); +} + +async function executeCheck(checker: SettingsChecker, http: { fetch: any }): Promise { + try { + const response = await http.fetch(checker.api, { + method: 'GET', + }); + const { found, reason } = response; + + return { found, reason }; + } catch (err: any) { + const { data } = err; + + return { + error: true, + found: false, + errorReason: data, + }; + } +} + +function formatClusters(clusters: any): any[] { + return clusters.map(formatCluster); +} + +function formatCluster(cluster: any) { + if (cluster.cluster_uuid === STANDALONE_CLUSTER_CLUSTER_UUID) { + cluster.cluster_name = 'Standalone Cluster'; + } + return cluster; +} diff --git a/x-pack/plugins/monitoring/public/components/no_data/no_data.js b/x-pack/plugins/monitoring/public/components/no_data/no_data.js index 1714ace7ceff..97bf7cacf53e 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/no_data.js +++ b/x-pack/plugins/monitoring/public/components/no_data/no_data.js @@ -32,9 +32,9 @@ import { CloudDeployment } from './blurbs'; import { getSafeForExternalLink } from '../../lib/get_safe_for_external_link'; function NoDataMessage(props) { - const { isLoading, reason, checkMessage } = props; + const { isLoading, reason, checkMessage, isCollectionEnabledUpdated } = props; - if (isLoading) { + if ((isCollectionEnabledUpdated && !reason) || isLoading) { return ; } diff --git a/x-pack/plugins/monitoring/public/lib/setup_mode.tsx b/x-pack/plugins/monitoring/public/lib/setup_mode.tsx index f622f2944a31..fca7f94731bc 100644 --- a/x-pack/plugins/monitoring/public/lib/setup_mode.tsx +++ b/x-pack/plugins/monitoring/public/lib/setup_mode.tsx @@ -159,6 +159,8 @@ export const disableElasticsearchInternalCollection = async () => { }; export const toggleSetupMode = (inSetupMode: boolean) => { + if (isReactMigrationEnabled()) return setupModeReact.toggleSetupMode(inSetupMode); + checkAngularState(); const globalState = angularState.injector.get('globalState'); diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts index ceb3ed180cd3..bedf310725ae 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts @@ -11,8 +11,10 @@ import { CoreStart } from 'src/core/public'; import type { SearchSource } from 'src/plugins/data/common'; import type { SavedSearch } from 'src/plugins/discover/public'; import { coreMock } from '../../../../../src/core/public/mocks'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import type { ILicense, LicensingPluginSetup } from '../../../licensing/public'; import { ReportingAPIClient } from '../lib/reporting_api_client'; +import type { ReportingPublicPluginStartDendencies } from '../plugin'; import type { ActionContext } from './get_csv_panel_action'; import { ReportingCsvPanelAction } from './get_csv_panel_action'; @@ -25,8 +27,8 @@ describe('GetCsvReportPanelAction', () => { let context: ActionContext; let mockLicense$: (state?: LicenseResults) => Rx.Observable; let mockSearchSource: SearchSource; - let mockStartServicesPayload: [CoreStart, object, unknown]; - let mockStartServices$: Rx.Subject; + let mockStartServicesPayload: [CoreStart, ReportingPublicPluginStartDendencies, unknown]; + let mockStartServices$: Rx.Observable; beforeAll(() => { if (typeof window.URL.revokeObjectURL === 'undefined') { @@ -48,14 +50,17 @@ describe('GetCsvReportPanelAction', () => { }) as unknown as LicensingPluginSetup['license$']; }; - mockStartServices$ = new Rx.Subject<[CoreStart, object, unknown]>(); mockStartServicesPayload = [ { + ...core, application: { capabilities: { dashboard: { downloadCsv: true } } }, } as unknown as CoreStart, - {}, + { + data: dataPluginMock.createStartContract(), + } as ReportingPublicPluginStartDendencies, null, ]; + mockStartServices$ = Rx.from(Promise.resolve(mockStartServicesPayload)); mockSearchSource = { createCopy: () => mockSearchSource, @@ -93,7 +98,7 @@ describe('GetCsvReportPanelAction', () => { usesUiCapabilities: true, }); - mockStartServices$.next(mockStartServicesPayload); + await mockStartServices$.pipe(first()).toPromise(); await panel.execute(context); @@ -130,7 +135,7 @@ describe('GetCsvReportPanelAction', () => { usesUiCapabilities: true, }); - mockStartServices$.next(mockStartServicesPayload); + await mockStartServices$.pipe(first()).toPromise(); await panel.execute(context); @@ -153,7 +158,7 @@ describe('GetCsvReportPanelAction', () => { usesUiCapabilities: true, }); - mockStartServices$.next(mockStartServicesPayload); + await mockStartServices$.pipe(first()).toPromise(); await panel.execute(context); @@ -169,7 +174,7 @@ describe('GetCsvReportPanelAction', () => { usesUiCapabilities: true, }); - mockStartServices$.next(mockStartServicesPayload); + await mockStartServices$.pipe(first()).toPromise(); await panel.execute(context); @@ -187,7 +192,7 @@ describe('GetCsvReportPanelAction', () => { usesUiCapabilities: true, }); - mockStartServices$.next(mockStartServicesPayload); + await mockStartServices$.pipe(first()).toPromise(); await panel.execute(context); @@ -204,14 +209,13 @@ describe('GetCsvReportPanelAction', () => { usesUiCapabilities: true, }); - mockStartServices$.next(mockStartServicesPayload); - + await mockStartServices$.pipe(first()).toPromise(); await licenseMock$.pipe(first()).toPromise(); expect(await plugin.isCompatible(context)).toEqual(false); }); - it('sets a display and icon type', () => { + it('sets a display and icon type', async () => { const panel = new ReportingCsvPanelAction({ core, apiClient, @@ -220,7 +224,7 @@ describe('GetCsvReportPanelAction', () => { usesUiCapabilities: true, }); - mockStartServices$.next(mockStartServicesPayload); + await mockStartServices$.pipe(first()).toPromise(); expect(panel.getIconType()).toMatchInlineSnapshot(`"document"`); expect(panel.getDisplayName()).toMatchInlineSnapshot(`"Download CSV"`); @@ -228,25 +232,28 @@ describe('GetCsvReportPanelAction', () => { describe('Application UI Capabilities', () => { it(`doesn't allow downloads when UI capability is not enabled`, async () => { + mockStartServicesPayload = [ + { application: { capabilities: {} } } as unknown as CoreStart, + { + data: dataPluginMock.createStartContract(), + } as ReportingPublicPluginStartDendencies, + null, + ]; + const startServices$ = Rx.from(Promise.resolve(mockStartServicesPayload)); const plugin = new ReportingCsvPanelAction({ core, apiClient, license$: mockLicense$(), - startServices$: mockStartServices$, + startServices$, usesUiCapabilities: true, }); - mockStartServices$.next([ - { application: { capabilities: {} } } as unknown as CoreStart, - {}, - null, - ]); + await startServices$.pipe(first()).toPromise(); expect(await plugin.isCompatible(context)).toEqual(false); }); it(`allows downloads when license is valid and UI capability is enabled`, async () => { - mockStartServices$ = new Rx.Subject(); const plugin = new ReportingCsvPanelAction({ core, apiClient, @@ -255,7 +262,7 @@ describe('GetCsvReportPanelAction', () => { usesUiCapabilities: true, }); - mockStartServices$.next(mockStartServicesPayload); + await mockStartServices$.pipe(first()).toPromise(); expect(await plugin.isCompatible(context)).toEqual(true); }); diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx index eb14e3216086..ef32e6474176 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx @@ -7,7 +7,8 @@ import { i18n } from '@kbn/i18n'; import * as Rx from 'rxjs'; -import type { CoreSetup, IUiSettingsClient, NotificationsSetup } from 'src/core/public'; +import { first } from 'rxjs/operators'; +import type { CoreSetup, NotificationsSetup } from 'src/core/public'; import { CoreStart } from 'src/core/public'; import type { ISearchEmbeddable, SavedSearch } from '../../../../../src/plugins/discover/public'; import { @@ -22,6 +23,7 @@ import type { LicensingPluginSetup } from '../../../licensing/public'; import { CSV_REPORTING_ACTION } from '../../common/constants'; import { checkLicense } from '../lib/license_check'; import { ReportingAPIClient } from '../lib/reporting_api_client'; +import type { ReportingPublicPluginStartDendencies } from '../plugin'; function isSavedSearchEmbeddable( embeddable: IEmbeddable | ISearchEmbeddable @@ -36,7 +38,7 @@ export interface ActionContext { interface Params { apiClient: ReportingAPIClient; core: CoreSetup; - startServices$: Rx.Observable<[CoreStart, object, unknown]>; + startServices$: Rx.Observable<[CoreStart, ReportingPublicPluginStartDendencies, unknown]>; license$: LicensingPluginSetup['license$']; usesUiCapabilities: boolean; } @@ -47,16 +49,16 @@ export class ReportingCsvPanelAction implements ActionDefinition public readonly id = CSV_REPORTING_ACTION; private licenseHasDownloadCsv: boolean = false; private capabilityHasDownloadCsv: boolean = false; - private uiSettings: IUiSettingsClient; private notifications: NotificationsSetup; private apiClient: ReportingAPIClient; + private startServices$: Params['startServices$']; constructor({ core, startServices$, license$, usesUiCapabilities, apiClient }: Params) { this.isDownloading = false; - this.uiSettings = core.uiSettings; this.notifications = core.notifications; this.apiClient = apiClient; + this.startServices$ = startServices$; license$.subscribe((license) => { const results = license.check('reporting', 'basic'); @@ -65,7 +67,7 @@ export class ReportingCsvPanelAction implements ActionDefinition }); if (usesUiCapabilities) { - startServices$.subscribe(([{ application }]) => { + this.startServices$.subscribe(([{ application }]) => { this.capabilityHasDownloadCsv = application.capabilities.dashboard?.downloadCsv === true; }); } else { @@ -84,11 +86,12 @@ export class ReportingCsvPanelAction implements ActionDefinition } public async getSearchSource(savedSearch: SavedSearch, embeddable: ISearchEmbeddable) { + const [{ uiSettings }, { data }] = await this.startServices$.pipe(first()).toPromise(); const { getSharingData } = await loadSharingDataHelpers(); return await getSharingData( savedSearch.searchSource, - savedSearch, // TODO: get unsaved state (using embeddale.searchScope): https://github.com/elastic/kibana/issues/43977 - this.uiSettings + savedSearch, // TODO: get unsaved state (using embeddable.searchScope): https://github.com/elastic/kibana/issues/43977 + { uiSettings, data } ); } diff --git a/x-pack/plugins/reporting/public/plugin.ts b/x-pack/plugins/reporting/public/plugin.ts index 28226751975a..7fd6047470a0 100644 --- a/x-pack/plugins/reporting/public/plugin.ts +++ b/x-pack/plugins/reporting/public/plugin.ts @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import * as Rx from 'rxjs'; import { catchError, filter, map, mergeMap, takeUntil } from 'rxjs/operators'; +import type { DataPublicPluginStart } from 'src/plugins/data/public'; import { CoreSetup, CoreStart, @@ -77,6 +78,7 @@ export interface ReportingPublicPluginSetupDendencies { export interface ReportingPublicPluginStartDendencies { home: HomePublicPluginStart; + data: DataPublicPluginStart; management: ManagementStart; licensing: LicensingPluginStart; uiActions: UiActionsStart; @@ -134,7 +136,10 @@ export class ReportingPublicPlugin return this.contract; } - public setup(core: CoreSetup, setupDeps: ReportingPublicPluginSetupDendencies) { + public setup( + core: CoreSetup, + setupDeps: ReportingPublicPluginSetupDendencies + ) { const { getStartServices, uiSettings } = core; const { home, diff --git a/x-pack/plugins/security_solution/common/types/timeline/pinned_event/index.ts b/x-pack/plugins/security_solution/common/types/timeline/pinned_event/index.ts index dbb19df7a6b0..df230615818a 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/pinned_event/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/pinned_event/index.ts @@ -30,6 +30,12 @@ export const SavedPinnedEventRuntimeType = runtimeTypes.intersection([ export interface SavedPinnedEvent extends runtimeTypes.TypeOf {} +/** + * This type represents a pinned event type stored in a saved object that does not include any fields that reference + * other saved objects. + */ +export type PinnedEventWithoutExternalRefs = Omit; + /** * Note Saved object type with metadata */ diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index c62337b2426d..9e1fd3a769ee 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -177,8 +177,7 @@ const StatefulEventsViewerComponent: React.FC = ({ {tGridEnabled ? ( timelinesUi.getTGrid<'embedded'>({ - id, - type: 'embedded', + additionalFilters, browserFields, bulkActions, columns, @@ -189,9 +188,12 @@ const StatefulEventsViewerComponent: React.FC = ({ end, entityType, filters: globalFilters, + filterStatus: currentFilter, globalFullScreen, + graphEventId, graphOverlay, hasAlertsCrud, + id, indexNames: selectedPatterns, indexPattern, isLive, @@ -199,19 +201,17 @@ const StatefulEventsViewerComponent: React.FC = ({ itemsPerPage, itemsPerPageOptions: itemsPerPageOptions!, kqlMode, - query, + leadingControlColumns, onRuleChange, + query, renderCellValue, rowRenderers, setQuery, - start, sort, - additionalFilters, - graphEventId, - filterStatus: currentFilter, - leadingControlColumns, - trailingControlColumns, + start, tGridEventRenderedViewEnabled, + trailingControlColumns, + type: 'embedded', unit, }) ) : ( diff --git a/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx b/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx index 0d6e59483fbc..e1546c5220e2 100644 --- a/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx +++ b/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx @@ -21,6 +21,7 @@ import { useSourcererScope } from '../../../containers/sourcerer'; import { TooltipWithKeyboardShortcut } from '../../accessibility'; import { getAdditionalScreenReaderOnlyContext } from '../utils'; import { SHOW_TOP_N_KEYBOARD_SHORTCUT } from '../keyboard_shortcut_constants'; +import { Filter } from '../../../../../../../../src/plugins/data/public'; const SHOW_TOP = (fieldName: string) => i18n.translate('xpack.securitySolution.hoverActions.showTopTooltip', { @@ -35,11 +36,12 @@ interface Props { Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon | typeof EuiContextMenuItem; enablePopOver?: boolean; field: string; + globalFilters?: Filter[]; onClick: () => void; onFilterAdded?: () => void; ownFocus: boolean; - showTopN: boolean; showTooltip?: boolean; + showTopN: boolean; timelineId?: string | null; value?: string[] | string | null; } @@ -56,6 +58,7 @@ export const ShowTopNButton: React.FC = React.memo( showTopN, timelineId, value, + globalFilters, }) => { const activeScope: SourcererScopeName = timelineId === TimelineId.active @@ -128,9 +131,10 @@ export const ShowTopNButton: React.FC = React.memo( timelineId={timelineId ?? undefined} toggleTopN={onClick} value={value} + globalFilters={globalFilters} /> ), - [browserFields, field, indexPattern, onClick, onFilterAdded, timelineId, value] + [browserFields, field, indexPattern, onClick, onFilterAdded, timelineId, value, globalFilters] ); return showTopN ? ( diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx index 787b7e8f8870..6962ed03e81d 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx @@ -160,6 +160,60 @@ let testProps = { }; describe('StatefulTopN', () => { + describe('rendering globalFilter', () => { + let wrapper: ReactWrapper; + const globalFilters = [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'signal.rule.id', + params: { + query: 'd62249f0-1632-11ec-b035-19607969bc20', + }, + }, + query: { + match_phrase: { + 'signal.rule.id': 'd62249f0-1632-11ec-b035-19607969bc20', + }, + }, + }, + ]; + beforeEach(() => { + wrapper = mount( + + + + ); + }); + + test(`provides filters from non Redux state when rendering in alerts table`, () => { + const props = wrapper.find('[data-test-subj="top-n"]').first().props() as Props; + + expect(props.filters).toEqual([ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'signal.rule.id', + params: { + query: 'd62249f0-1632-11ec-b035-19607969bc20', + }, + }, + query: { + match_phrase: { + 'signal.rule.id': 'd62249f0-1632-11ec-b035-19607969bc20', + }, + }, + }, + ]); + }); + }); + describe('rendering in a global NON-timeline context', () => { let wrapper: ReactWrapper; diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx index 2286a5303078..1556f2d0f3d1 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx @@ -41,11 +41,11 @@ const makeMapStateToProps = () => { // The mapped Redux state provided to this component includes the global // filters that appear at the top of most views in the app, and all the // filters in the active timeline: - const mapStateToProps = (state: State) => { + const mapStateToProps = (state: State, ownProps: { globalFilters?: Filter[] }) => { const activeTimeline: TimelineModel = getTimeline(state, TimelineId.active) ?? timelineDefaults; const activeTimelineFilters = activeTimeline.filters ?? EMPTY_FILTERS; const activeTimelineInput: inputsModel.InputsRange = getInputsTimeline(state); - + const { globalFilters } = ownProps; return { activeTimelineEventType: activeTimeline.eventType, activeTimelineFilters: @@ -59,7 +59,7 @@ const makeMapStateToProps = () => { dataProviders: activeTimeline.activeTab === TimelineTabs.query ? activeTimeline.dataProviders : [], globalQuery: getGlobalQuerySelector(state), - globalFilters: getGlobalFiltersQuerySelector(state), + globalFilters: globalFilters ?? getGlobalFiltersQuerySelector(state), kqlMode: activeTimeline.kqlMode, }; }; @@ -82,6 +82,7 @@ export interface OwnProps { toggleTopN: () => void; onFilterAdded?: () => void; value?: string[] | string | null; + globalFilters?: Filter[]; } type PropsFromRedux = ConnectedProps; type Props = OwnProps & PropsFromRedux; diff --git a/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx b/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx index 8279613e67db..149a0c62b8b6 100644 --- a/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx @@ -6,6 +6,7 @@ */ import React, { useCallback, useState, useMemo } from 'react'; +import { Filter } from '../../../../../../../src/plugins/data/public'; import type { BrowserFields, @@ -167,11 +168,13 @@ export const defaultCellActions: TGridCellAction[] = [ ({ browserFields, data, + globalFilters, timelineId, pageSize, }: { browserFields: BrowserFields; data: TimelineNonEcsData[][]; + globalFilters?: Filter[]; timelineId: string; pageSize: number; }) => @@ -205,6 +208,7 @@ export const defaultCellActions: TGridCellAction[] = [ enablePopOver data-test-subj="hover-actions-show-top-n" field={columnId} + globalFilters={globalFilters} onClick={onClick} onFilterAdded={onFilterAdded} ownFocus={false} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 8de3f0065f5f..70d7faa47b9e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -391,7 +391,6 @@ const RuleDetailsPageComponent: React.FC = ({ const alertsTableDefaultFilters = useMemo( () => [ ...buildAlertsRuleIdFilter(ruleId), - ...filters, ...(ruleRegistryEnabled ? [ // TODO: Once we are past experimental phase this code should be removed @@ -400,7 +399,7 @@ const RuleDetailsPageComponent: React.FC = ({ : [...buildShowBuildingBlockFilter(showBuildingBlockAlerts)]), ...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts), ], - [ruleId, filters, ruleRegistryEnabled, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts] + [ruleId, ruleRegistryEnabled, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts] ); const alertMergedFilters = useMemo( diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index b4776f328cf1..58fbd64faf8a 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -26,6 +26,7 @@ import { appendSearch } from '../../common/components/link_to/helpers'; import { EndpointIndexUIQueryParams } from '../pages/endpoint_hosts/types'; import { TrustedAppsListPageLocation } from '../pages/trusted_apps/state'; import { EventFiltersPageLocation } from '../pages/event_filters/types'; +import { PolicyDetailsArtifactsPageLocation } from '../pages/policy/types'; // Taken from: https://github.com/microsoft/TypeScript/issues/12936#issuecomment-559034150 type ExactKeys = Exclude extends never ? T1 : never; @@ -160,6 +161,25 @@ const normalizeTrustedAppsPageLocation = ( } }; +const normalizePolicyDetailsArtifactsListPageLocation = ( + location?: Partial +): Partial => { + if (location) { + return { + ...(!isDefaultOrMissing(location.page_index, MANAGEMENT_DEFAULT_PAGE) + ? { page_index: location.page_index } + : {}), + ...(!isDefaultOrMissing(location.page_size, MANAGEMENT_DEFAULT_PAGE_SIZE) + ? { page_size: location.page_size } + : {}), + ...(!isDefaultOrMissing(location.show, undefined) ? { show: location.show } : {}), + ...(!isDefaultOrMissing(location.filter, '') ? { filter: location.filter } : ''), + }; + } else { + return {}; + } +}; + const normalizeEventFiltersPageLocation = ( location?: Partial ): Partial => { @@ -257,6 +277,34 @@ export const getTrustedAppsListPath = (location?: Partial { + const showParamValue = extractFirstParamValue( + query, + 'show' + ) as PolicyDetailsArtifactsPageLocation['show']; + + return { + ...extractListPaginationParams(query), + show: showParamValue && 'list' === showParamValue ? showParamValue : undefined, + }; +}; + +export const getPolicyDetailsArtifactsListPath = ( + policyId: string, + location?: Partial +): string => { + const path = generatePath(MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, { + tabName: AdministrationSubTab.policies, + policyId, + }); + + return `${path}${appendSearch( + querystring.stringify(normalizePolicyDetailsArtifactsListPageLocation(location)) + )}`; +}; + export const extractEventFiltetrsPageLocation = ( query: querystring.ParsedUrlQuery ): EventFiltersPageLocation => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.test.tsx index 624a3c265c4c..1cb21c7da170 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.test.tsx @@ -92,7 +92,7 @@ describe('when rendering the endpoint list `AdminSearchBar`', () => { ])( 'should update the url and exclude the `admin_query` param when %s was entered', async (_, value) => { - await render(); + await render({ admin_query: "(language:kuery,query:'foo')" }); await submitQuery(value); expect(getQueryParamsFromStore().admin_query).toBe(undefined); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.tsx index 2f2a1666b6f5..18d22e0cd1b1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.tsx @@ -24,7 +24,7 @@ const AdminQueryBar = styled.div` export const AdminSearchBar = memo(() => { const history = useHistory(); - const queryParams = useEndpointSelector(selectors.uiQueryParams); + const { admin_query: _, ...queryParams } = useEndpointSelector(selectors.uiQueryParams); const searchBarIndexPatterns = useEndpointSelector(selectors.patterns); const searchBarQuery = useEndpointSelector(selectors.searchBarQuery); const clonedIndexPatterns = useMemo( diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/index.ts new file mode 100644 index 000000000000..ab84bb4f253e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PolicySettingsAction } from './policy_settings_action'; +import { PolicyTrustedAppsAction } from './policy_trusted_apps_action'; + +export type PolicyDetailsAction = PolicySettingsAction | PolicyTrustedAppsAction; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/policy_settings_action.ts similarity index 83% rename from x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action.ts rename to x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/policy_settings_action.ts index 6bd39e9c24f9..eec0ab1c6445 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/policy_settings_action.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { ILicense } from '../../../../../../../licensing/common/types'; -import { GetAgentStatusResponse } from '../../../../../../../fleet/common/types/rest_spec'; -import { PolicyData, UIPolicyConfig } from '../../../../../../common/endpoint/types'; -import { ServerApiError } from '../../../../../common/types'; -import { PolicyDetailsState } from '../../types'; +import { ILicense } from '../../../../../../../../licensing/common/types'; +import { GetAgentStatusResponse } from '../../../../../../../../fleet/common/types/rest_spec'; +import { PolicyData, UIPolicyConfig } from '../../../../../../../common/endpoint/types'; +import { ServerApiError } from '../../../../../../common/types'; +import { PolicyDetailsState } from '../../../types'; export interface ServerReturnedPolicyDetailsData { type: 'serverReturnedPolicyDetailsData'; @@ -69,7 +69,7 @@ export interface LicenseChanged { payload: ILicense; } -export type PolicyDetailsAction = +export type PolicySettingsAction = | ServerReturnedPolicyDetailsData | UserClickedPolicyDetailsSaveButton | ServerReturnedPolicyDetailsAgentSummaryData diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/policy_trusted_apps_action.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/policy_trusted_apps_action.ts new file mode 100644 index 000000000000..46e0f8293cc3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/policy_trusted_apps_action.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// TODO: defined trusted apps actions (code below only here to silence TS) +export type PolicyTrustedAppsAction = + | { + type: 'a'; + } + | { type: 'b' }; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.ts index bc83c7438eba..bc9e42ddf7f5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.ts @@ -11,8 +11,7 @@ import { AppAction } from '../../../../../common/store/actions'; import { Immutable } from '../../../../../../common/endpoint/types'; export { policyDetailsMiddlewareFactory } from './middleware'; -export { PolicyDetailsAction } from './action'; -export { policyDetailsReducer } from './reducer'; +export { policyDetailsReducer, initialPolicyDetailsState } from './reducer'; export interface EndpointPolicyDetailsStatePluginState { policyDetails: Immutable; @@ -21,3 +20,4 @@ export interface EndpointPolicyDetailsStatePluginState { export interface EndpointPolicyDetailsStatePluginReducer { policyDetails: ImmutableReducer; } +export { PolicyDetailsAction } from './action'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts deleted file mode 100644 index 3d90b8d640ac..000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { IHttpFetchError } from 'kibana/public'; -import { - DefaultPolicyNotificationMessage, - DefaultPolicyRuleNotificationMessage, -} from '../../../../../../common/endpoint/models/policy_config'; -import { PolicyDetailsState, UpdatePolicyResponse } from '../../types'; -import { - policyIdFromParams, - isOnPolicyDetailsPage, - policyDetails, - policyDetailsForUpdate, - needsToRefresh, -} from './selectors'; -import { - sendGetPackagePolicy, - sendGetFleetAgentStatusForPolicy, - sendPutPackagePolicy, -} from '../services/ingest'; -import { NewPolicyData, PolicyData } from '../../../../../../common/endpoint/types'; -import { ImmutableMiddlewareFactory } from '../../../../../common/store'; -import { getPolicyDataForUpdate } from '../../../../../../common/endpoint/service/policy/get_policy_data_for_update'; - -export const policyDetailsMiddlewareFactory: ImmutableMiddlewareFactory = ( - coreStart -) => { - const http = coreStart.http; - return ({ getState, dispatch }) => - (next) => - async (action) => { - next(action); - const state = getState(); - - if ( - action.type === 'userChangedUrl' && - needsToRefresh(state) && - isOnPolicyDetailsPage(state) - ) { - const id = policyIdFromParams(state); - let policyItem: PolicyData; - - try { - policyItem = (await sendGetPackagePolicy(http, id)).item; - // sets default user notification message if policy config message is empty - if (policyItem.inputs[0].config.policy.value.windows.popup.malware.message === '') { - policyItem.inputs[0].config.policy.value.windows.popup.malware.message = - DefaultPolicyNotificationMessage; - policyItem.inputs[0].config.policy.value.mac.popup.malware.message = - DefaultPolicyNotificationMessage; - policyItem.inputs[0].config.policy.value.linux.popup.malware.message = - DefaultPolicyNotificationMessage; - } - if (policyItem.inputs[0].config.policy.value.windows.popup.ransomware.message === '') { - policyItem.inputs[0].config.policy.value.windows.popup.ransomware.message = - DefaultPolicyNotificationMessage; - } - if ( - policyItem.inputs[0].config.policy.value.windows.popup.memory_protection.message === '' - ) { - policyItem.inputs[0].config.policy.value.windows.popup.memory_protection.message = - DefaultPolicyRuleNotificationMessage; - } - if ( - policyItem.inputs[0].config.policy.value.windows.popup.behavior_protection.message === - '' - ) { - policyItem.inputs[0].config.policy.value.windows.popup.behavior_protection.message = - DefaultPolicyRuleNotificationMessage; - } - if ( - policyItem.inputs[0].config.policy.value.mac.popup.behavior_protection.message === '' - ) { - policyItem.inputs[0].config.policy.value.mac.popup.behavior_protection.message = - DefaultPolicyRuleNotificationMessage; - } - if ( - policyItem.inputs[0].config.policy.value.linux.popup.behavior_protection.message === '' - ) { - policyItem.inputs[0].config.policy.value.linux.popup.behavior_protection.message = - DefaultPolicyRuleNotificationMessage; - } - } catch (error) { - dispatch({ - type: 'serverFailedToReturnPolicyDetailsData', - payload: error.body || error, - }); - return; - } - - dispatch({ - type: 'serverReturnedPolicyDetailsData', - payload: { - policyItem, - }, - }); - - // Agent summary is secondary data, so its ok for it to come after the details - // page is populated with the main content - if (policyItem.policy_id) { - const { results } = await sendGetFleetAgentStatusForPolicy(http, policyItem.policy_id); - dispatch({ - type: 'serverReturnedPolicyDetailsAgentSummaryData', - payload: { - agentStatusSummary: results, - }, - }); - } - } else if (action.type === 'userClickedPolicyDetailsSaveButton') { - const { id } = policyDetails(state) as PolicyData; - const updatedPolicyItem = policyDetailsForUpdate(state) as NewPolicyData; - - let apiResponse: UpdatePolicyResponse; - try { - apiResponse = await sendPutPackagePolicy(http, id, updatedPolicyItem).catch( - (error: IHttpFetchError) => { - if (!error.response || error.response.status !== 409) { - return Promise.reject(error); - } - // Handle 409 error (version conflict) here, by using the latest document - // for the package policy and adding the updated policy to it, ensuring that - // any recent updates to `manifest_artifacts` are retained. - return sendGetPackagePolicy(http, id).then((packagePolicy) => { - const latestUpdatedPolicyItem = packagePolicy.item; - latestUpdatedPolicyItem.inputs[0].config.policy = - updatedPolicyItem.inputs[0].config.policy; - - return sendPutPackagePolicy( - http, - id, - getPolicyDataForUpdate(latestUpdatedPolicyItem) - ); - }); - } - ); - } catch (error) { - dispatch({ - type: 'serverReturnedPolicyDetailsUpdateFailure', - payload: { - success: false, - error: error.body || error, - }, - }); - return; - } - - dispatch({ - type: 'serverReturnedUpdatedPolicyDetailsData', - payload: { - policyItem: apiResponse.item, - updateStatus: { - success: true, - }, - }, - }); - } - }; -}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/index.ts new file mode 100644 index 000000000000..6b7e4e7d541c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ImmutableMiddlewareFactory } from '../../../../../../common/store'; +import { PolicyDetailsState } from '../../../types'; +import { policyTrustedAppsMiddlewareRunner } from './policy_trusted_apps_middleware'; +import { policySettingsMiddlewareRunner } from './policy_settings_middleware'; + +export const policyDetailsMiddlewareFactory: ImmutableMiddlewareFactory = ( + coreStart +) => { + return (store) => (next) => async (action) => { + next(action); + + policySettingsMiddlewareRunner(coreStart, store, action); + policyTrustedAppsMiddlewareRunner(coreStart, store, action); + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/policy_settings_middleware.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/policy_settings_middleware.ts new file mode 100644 index 000000000000..73b244944e50 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/policy_settings_middleware.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IHttpFetchError } from 'kibana/public'; +import { + DefaultPolicyNotificationMessage, + DefaultPolicyRuleNotificationMessage, +} from '../../../../../../../common/endpoint/models/policy_config'; +import { MiddlewareRunner, UpdatePolicyResponse } from '../../../types'; +import { + policyIdFromParams, + isOnPolicyDetailsPage, + policyDetails, + policyDetailsForUpdate, + needsToRefresh, +} from '../selectors/policy_settings_selectors'; +import { + sendGetPackagePolicy, + sendGetFleetAgentStatusForPolicy, + sendPutPackagePolicy, +} from '../../services/ingest'; +import { NewPolicyData, PolicyData } from '../../../../../../../common/endpoint/types'; +import { getPolicyDataForUpdate } from '../../../../../../../common/endpoint/service/policy'; + +export const policySettingsMiddlewareRunner: MiddlewareRunner = async ( + coreStart, + { dispatch, getState }, + action +) => { + const http = coreStart.http; + const state = getState(); + + if (action.type === 'userChangedUrl' && needsToRefresh(state) && isOnPolicyDetailsPage(state)) { + const id = policyIdFromParams(state); + let policyItem: PolicyData; + + try { + policyItem = (await sendGetPackagePolicy(http, id)).item; + // sets default user notification message if policy config message is empty + if (policyItem.inputs[0].config.policy.value.windows.popup.malware.message === '') { + policyItem.inputs[0].config.policy.value.windows.popup.malware.message = + DefaultPolicyNotificationMessage; + policyItem.inputs[0].config.policy.value.mac.popup.malware.message = + DefaultPolicyNotificationMessage; + policyItem.inputs[0].config.policy.value.linux.popup.malware.message = + DefaultPolicyNotificationMessage; + } + if (policyItem.inputs[0].config.policy.value.windows.popup.ransomware.message === '') { + policyItem.inputs[0].config.policy.value.windows.popup.ransomware.message = + DefaultPolicyNotificationMessage; + } + if (policyItem.inputs[0].config.policy.value.windows.popup.memory_protection.message === '') { + policyItem.inputs[0].config.policy.value.windows.popup.memory_protection.message = + DefaultPolicyRuleNotificationMessage; + } + if ( + policyItem.inputs[0].config.policy.value.windows.popup.behavior_protection.message === '' + ) { + policyItem.inputs[0].config.policy.value.windows.popup.behavior_protection.message = + DefaultPolicyRuleNotificationMessage; + } + if (policyItem.inputs[0].config.policy.value.mac.popup.behavior_protection.message === '') { + policyItem.inputs[0].config.policy.value.mac.popup.behavior_protection.message = + DefaultPolicyRuleNotificationMessage; + } + if (policyItem.inputs[0].config.policy.value.linux.popup.behavior_protection.message === '') { + policyItem.inputs[0].config.policy.value.linux.popup.behavior_protection.message = + DefaultPolicyRuleNotificationMessage; + } + } catch (error) { + dispatch({ + type: 'serverFailedToReturnPolicyDetailsData', + payload: error.body || error, + }); + return; + } + + dispatch({ + type: 'serverReturnedPolicyDetailsData', + payload: { + policyItem, + }, + }); + + // Agent summary is secondary data, so its ok for it to come after the details + // page is populated with the main content + if (policyItem.policy_id) { + const { results } = await sendGetFleetAgentStatusForPolicy(http, policyItem.policy_id); + dispatch({ + type: 'serverReturnedPolicyDetailsAgentSummaryData', + payload: { + agentStatusSummary: results, + }, + }); + } + } else if (action.type === 'userClickedPolicyDetailsSaveButton') { + const { id } = policyDetails(state) as PolicyData; + const updatedPolicyItem = policyDetailsForUpdate(state) as NewPolicyData; + + let apiResponse: UpdatePolicyResponse; + try { + apiResponse = await sendPutPackagePolicy(http, id, updatedPolicyItem).catch( + (error: IHttpFetchError) => { + if (!error.response || error.response.status !== 409) { + return Promise.reject(error); + } + // Handle 409 error (version conflict) here, by using the latest document + // for the package policy and adding the updated policy to it, ensuring that + // any recent updates to `manifest_artifacts` are retained. + return sendGetPackagePolicy(http, id).then((packagePolicy) => { + const latestUpdatedPolicyItem = packagePolicy.item; + latestUpdatedPolicyItem.inputs[0].config.policy = + updatedPolicyItem.inputs[0].config.policy; + + return sendPutPackagePolicy(http, id, getPolicyDataForUpdate(latestUpdatedPolicyItem)); + }); + } + ); + } catch (error) { + dispatch({ + type: 'serverReturnedPolicyDetailsUpdateFailure', + payload: { + success: false, + error: error.body || error, + }, + }); + return; + } + + dispatch({ + type: 'serverReturnedUpdatedPolicyDetailsData', + payload: { + policyItem: apiResponse.item, + updateStatus: { + success: true, + }, + }, + }); + } +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/policy_trusted_apps_middleware.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/policy_trusted_apps_middleware.ts new file mode 100644 index 000000000000..171bbd881302 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/policy_trusted_apps_middleware.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MiddlewareRunner } from '../../../types'; + +export const policyTrustedAppsMiddlewareRunner: MiddlewareRunner = async ( + coreStart, + store, + action +) => { + // FIXME: implement middlware for trusted apps +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/index.ts new file mode 100644 index 000000000000..a577c1ca85ef --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/index.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ImmutableReducer } from '../../../../../../common/store'; +import { PolicyDetailsState } from '../../../types'; +import { AppAction } from '../../../../../../common/store/actions'; +import { policySettingsReducer } from './policy_settings_reducer'; +import { initialPolicyDetailsState } from './initial_policy_details_state'; +import { policyTrustedAppsReducer } from './trusted_apps_reducer'; + +export * from './initial_policy_details_state'; + +export const policyDetailsReducer: ImmutableReducer = ( + state = initialPolicyDetailsState(), + action +) => { + return [policySettingsReducer, policyTrustedAppsReducer].reduce( + (updatedState, runReducer) => runReducer(updatedState, action), + state + ); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/initial_policy_details_state.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/initial_policy_details_state.ts new file mode 100644 index 000000000000..723f8fe31bd2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/initial_policy_details_state.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Immutable } from '../../../../../../../common/endpoint/types'; +import { PolicyDetailsState } from '../../../types'; +import { + MANAGEMENT_DEFAULT_PAGE, + MANAGEMENT_DEFAULT_PAGE_SIZE, +} from '../../../../../common/constants'; +import { createUninitialisedResourceState } from '../../../../../state'; + +/** + * Return a fresh copy of initial state, since we mutate state in the reducer. + */ +export const initialPolicyDetailsState: () => Immutable = () => ({ + policyItem: undefined, + isLoading: false, + agentStatusSummary: { + error: 0, + events: 0, + offline: 0, + online: 0, + total: 0, + other: 0, + }, + artifacts: { + location: { + page_index: MANAGEMENT_DEFAULT_PAGE, + page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, + show: undefined, + filter: '', + }, + availableList: createUninitialisedResourceState(), + }, +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/policy_settings_reducer.ts similarity index 81% rename from x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer.ts rename to x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/policy_settings_reducer.ts index 512059e9c3aa..9997e547e814 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/policy_settings_reducer.ts @@ -5,16 +5,20 @@ * 2.0. */ -import { fullPolicy, isOnPolicyDetailsPage, license } from './selectors'; +// eslint-disable-next-line import/no-nodejs-modules +import { parse } from 'querystring'; +import { fullPolicy, isOnPolicyDetailsPage, license } from '../selectors/policy_settings_selectors'; import { Immutable, PolicyConfig, - UIPolicyConfig, PolicyData, -} from '../../../../../../common/endpoint/types'; -import { ImmutableReducer } from '../../../../../common/store'; -import { AppAction } from '../../../../../common/store/actions'; -import { PolicyDetailsState } from '../../types'; + UIPolicyConfig, +} from '../../../../../../../common/endpoint/types'; +import { ImmutableReducer } from '../../../../../../common/store'; +import { AppAction } from '../../../../../../common/store/actions'; +import { PolicyDetailsState } from '../../../types'; +import { extractPolicyDetailsArtifactsListPageLocation } from '../../../../../common/routing'; +import { initialPolicyDetailsState } from './initial_policy_details_state'; const updatePolicyConfigInPolicyData = ( policyData: Immutable, @@ -33,23 +37,7 @@ const updatePolicyConfigInPolicyData = ( })), }); -/** - * Return a fresh copy of initial state, since we mutate state in the reducer. - */ -export const initialPolicyDetailsState: () => Immutable = () => ({ - policyItem: undefined, - isLoading: false, - agentStatusSummary: { - error: 0, - events: 0, - offline: 0, - online: 0, - total: 0, - other: 0, - }, -}); - -export const policyDetailsReducer: ImmutableReducer = ( +export const policySettingsReducer: ImmutableReducer = ( state = initialPolicyDetailsState(), action ) => { @@ -106,6 +94,12 @@ export const policyDetailsReducer: ImmutableReducer = { ...state, location: action.payload, + artifacts: { + ...state.artifacts, + location: extractPolicyDetailsArtifactsListPageLocation( + parse(action.payload.search.slice(1)) + ), + }, }; const isCurrentlyOnDetailsPage = isOnPolicyDetailsPage(newState); const wasPreviouslyOnDetailsPage = isOnPolicyDetailsPage(state); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.ts new file mode 100644 index 000000000000..7f2f9e437ca0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ImmutableReducer } from '../../../../../../common/store'; +import { PolicyDetailsState } from '../../../types'; +import { AppAction } from '../../../../../../common/store/actions'; +import { initialPolicyDetailsState } from './initial_policy_details_state'; + +export const policyTrustedAppsReducer: ImmutableReducer = ( + state = initialPolicyDetailsState(), + action +) => { + // FIXME: implement trusted apps reducer + return state; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/index.ts new file mode 100644 index 000000000000..af5be5c39480 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './policy_settings_selectors'; +export * from './trusted_apps_selectors'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts similarity index 92% rename from x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts rename to x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts index 017111da8d88..23ab0fd73c9e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts @@ -7,23 +7,23 @@ import { createSelector } from 'reselect'; import { matchPath } from 'react-router-dom'; -import { ILicense } from '../../../../../../../licensing/common/types'; -import { unsetPolicyFeaturesAccordingToLicenseLevel } from '../../../../../../common/license/policy_config'; -import { PolicyDetailsState } from '../../types'; +import { ILicense } from '../../../../../../../../licensing/common/types'; +import { unsetPolicyFeaturesAccordingToLicenseLevel } from '../../../../../../../common/license/policy_config'; +import { PolicyDetailsArtifactsPageLocation, PolicyDetailsState } from '../../../types'; import { Immutable, NewPolicyData, PolicyConfig, PolicyData, UIPolicyConfig, -} from '../../../../../../common/endpoint/types'; -import { policyFactory as policyConfigFactory } from '../../../../../../common/endpoint/models/policy_config'; +} from '../../../../../../../common/endpoint/types'; +import { policyFactory as policyConfigFactory } from '../../../../../../../common/endpoint/models/policy_config'; import { MANAGEMENT_ROUTING_POLICY_DETAILS_FORM_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, -} from '../../../../common/constants'; -import { ManagementRoutePolicyDetailsParams } from '../../../../types'; -import { getPolicyDataForUpdate } from '../../../../../../common/endpoint/service/policy/get_policy_data_for_update'; +} from '../../../../../common/constants'; +import { ManagementRoutePolicyDetailsParams } from '../../../../../types'; +import { getPolicyDataForUpdate } from '../../../../../../../common/endpoint/service/policy/get_policy_data_for_update'; /** Returns the policy details */ export const policyDetails = (state: Immutable) => state.policyItem; @@ -80,6 +80,13 @@ export const needsToRefresh = (state: Immutable): boolean => return !state.policyItem && !state.apiError; }; +/** + * Returns current artifacts location + */ +export const getCurrentArtifactsLocation = ( + state: Immutable +): Immutable => state.artifacts.location; + /** Returns a boolean of whether the user is on the policy form page or not */ export const isOnPolicyFormPage = (state: Immutable) => { return ( diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.ts new file mode 100644 index 000000000000..f7a568b5ade0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const isOnTrustedAppsView = () => { + return true; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/types.ts b/x-pack/plugins/security_solution/public/management/pages/policy/types.ts index 6d767df73cd1..9000fb469afd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/types.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { CoreStart } from 'kibana/public'; import { ILicense } from '../../../../../licensing/common/types'; import { AppLocation, @@ -12,6 +13,7 @@ import { ProtectionFields, PolicyData, UIPolicyConfig, + MaybeImmutable, } from '../../../../common/endpoint/types'; import { ServerApiError } from '../../../common/types'; import { @@ -21,6 +23,19 @@ import { GetPackagesResponse, UpdatePackagePolicyResponse, } from '../../../../../fleet/common'; +import { AsyncResourceState } from '../../state'; +import { TrustedAppsListData } from '../trusted_apps/state'; +import { ImmutableMiddlewareAPI } from '../../../common/store'; +import { AppAction } from '../../../common/store/actions'; + +/** + * Function that runs Policy Details middleware + */ +export type MiddlewareRunner = ( + coreStart: CoreStart, + store: ImmutableMiddlewareAPI, + action: MaybeImmutable +) => Promise; /** * Policy list store state @@ -61,6 +76,8 @@ export interface PolicyDetailsState { isLoading: boolean; /** current location of the application */ location?: Immutable; + /** artifacts namespace inside policy details page */ + artifacts: PolicyArtifactsState; /** A summary of stats for the agents associated with a given Fleet Agent Policy */ agentStatusSummary?: Omit; /** Status of an update to the policy */ @@ -72,12 +89,29 @@ export interface PolicyDetailsState { license?: ILicense; } +/** + * Policy artifacts store state + */ +export interface PolicyArtifactsState { + /** artifacts location params */ + location: PolicyDetailsArtifactsPageLocation; + /** A list of artifacts can be linked to the policy */ + availableList: AsyncResourceState; +} + export enum OS { windows = 'windows', mac = 'mac', linux = 'linux', } +export interface PolicyDetailsArtifactsPageLocation { + page_index: number; + page_size: number; + show?: 'list'; + filter: string; +} + /** * Returns the keys of an object whose values meet a criteria. * Ex) interface largeNestedObject = { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx index 51edf1a200c5..45aad6c3d143 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx @@ -13,7 +13,7 @@ import { EuiSpacer, EuiSwitch, EuiText } from '@elastic/eui'; import { OperatingSystem } from '../../../../../../../common/endpoint/types'; import { isAntivirusRegistrationEnabled } from '../../../store/policy_details/selectors'; import { usePolicyDetailsSelector } from '../../policy_hooks'; -import { ConfigForm } from '../../components/config_form'; +import { ConfigForm } from '../config_form'; const TRANSLATIONS: Readonly<{ [K in 'title' | 'description' | 'label']: string }> = { title: i18n.translate( diff --git a/x-pack/plugins/security_solution/public/management/store/reducer.ts b/x-pack/plugins/security_solution/public/management/store/reducer.ts index 25c7c87c6f5c..662d2b4322bc 100644 --- a/x-pack/plugins/security_solution/public/management/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/store/reducer.ts @@ -9,7 +9,7 @@ import { combineReducers } from 'redux'; import { policyDetailsReducer, initialPolicyDetailsState, -} from '../pages/policy/store/policy_details/reducer'; +} from '../pages/policy/store/policy_details'; import { MANAGEMENT_STORE_ENDPOINTS_NAMESPACE, MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts b/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts index 1c03e52c67ae..771e3e059c33 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts @@ -11,6 +11,8 @@ export const TELEMETRY_CHANNEL_LISTS = 'security-lists'; export const TELEMETRY_CHANNEL_ENDPOINT_META = 'endpoint-metadata'; +export const LIST_TRUSTED_APPLICATION = 'trusted_application'; + export const LIST_ENDPOINT_EXCEPTION = 'endpoint_exception'; export const LIST_ENDPOINT_EVENT_FILTER = 'endpoint_event_filter'; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filters.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filters.test.ts new file mode 100644 index 000000000000..4844a10d99f9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filters.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { copyAllowlistedFields } from './filters'; + +describe('Security Telemetry filters', () => { + describe('allowlistEventFields', () => { + const allowlist = { + a: true, + b: true, + c: { + d: true, + }, + }; + + it('filters top level', () => { + const event = { + a: 'a', + a1: 'a1', + b: 'b', + b1: 'b1', + }; + expect(copyAllowlistedFields(allowlist, event)).toStrictEqual({ + a: 'a', + b: 'b', + }); + }); + + it('filters nested', () => { + const event = { + a: { + a1: 'a1', + }, + a1: 'a1', + b: { + b1: 'b1', + }, + b1: 'b1', + c: { + d: 'd', + e: 'e', + f: 'f', + }, + }; + expect(copyAllowlistedFields(allowlist, event)).toStrictEqual({ + a: { + a1: 'a1', + }, + b: { + b1: 'b1', + }, + c: { + d: 'd', + }, + }); + }); + + it('filters arrays of objects', () => { + const event = { + a: [ + { + a1: 'a1', + }, + ], + b: { + b1: 'b1', + }, + c: [ + { + d: 'd1', + e: 'e1', + f: 'f1', + }, + { + d: 'd2', + e: 'e2', + f: 'f2', + }, + { + d: 'd3', + e: 'e3', + f: 'f3', + }, + ], + }; + expect(copyAllowlistedFields(allowlist, event)).toStrictEqual({ + a: [ + { + a1: 'a1', + }, + ], + b: { + b1: 'b1', + }, + c: [ + { + d: 'd1', + }, + { + d: 'd2', + }, + { + d: 'd3', + }, + ], + }); + }); + + it("doesn't create empty objects", () => { + const event = { + a: 'a', + b: 'b', + c: { + e: 'e', + }, + }; + expect(copyAllowlistedFields(allowlist, event)).toStrictEqual({ + a: 'a', + b: 'b', + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts index 18c3baccf9aa..61172fac511f 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { TelemetryEvent } from './types'; + export interface AllowlistFields { [key: string]: boolean | AllowlistFields; } @@ -124,3 +126,48 @@ export const allowlistEventFields: AllowlistFields = { }, ...allowlistBaseEventFields, }; + +export const exceptionListEventFields: AllowlistFields = { + created_at: true, + description: true, + effectScope: true, + entries: true, + id: true, + name: true, + os: true, + os_types: true, +}; + +/** + * Filters out information not required for downstream analysis + * + * @param allowlist + * @param event + * @returns + */ +export function copyAllowlistedFields( + allowlist: AllowlistFields, + event: TelemetryEvent +): TelemetryEvent { + return Object.entries(allowlist).reduce((newEvent, [allowKey, allowValue]) => { + const eventValue = event[allowKey]; + if (eventValue !== null && eventValue !== undefined) { + if (allowValue === true) { + return { ...newEvent, [allowKey]: eventValue }; + } else if (typeof allowValue === 'object' && Array.isArray(eventValue)) { + const subValues = eventValue.filter((v) => typeof v === 'object'); + return { + ...newEvent, + [allowKey]: subValues.map((v) => copyAllowlistedFields(allowValue, v as TelemetryEvent)), + }; + } else if (typeof allowValue === 'object' && typeof eventValue === 'object') { + const values = copyAllowlistedFields(allowValue, eventValue as TelemetryEvent); + return { + ...newEvent, + ...(Object.keys(values).length > 0 ? { [allowKey]: values } : {}), + }; + } + } + return newEvent; + }, {}); +} diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts index a4d11b71f2a8..647219e8c558 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts @@ -7,17 +7,19 @@ import moment from 'moment'; import { createMockPackagePolicy } from './mocks'; -import { TrustedApp } from '../../../common/endpoint/types'; -import { LIST_ENDPOINT_EXCEPTION, LIST_ENDPOINT_EVENT_FILTER } from './constants'; +import { + LIST_ENDPOINT_EXCEPTION, + LIST_ENDPOINT_EVENT_FILTER, + LIST_TRUSTED_APPLICATION, +} from './constants'; import { getPreviousDiagTaskTimestamp, getPreviousEpMetaTaskTimestamp, batchTelemetryRecords, isPackagePolicyList, - templateTrustedApps, - templateEndpointExceptions, + templateExceptionList, } from './helpers'; -import { EndpointExceptionListItem } from './types'; +import { ExceptionListItem } from './types'; describe('test diagnostic telemetry scheduled task timing helper', () => { test('test -5 mins is returned when there is no previous task run', async () => { @@ -133,8 +135,8 @@ describe('test package policy type guard', () => { describe('list telemetry schema', () => { test('trusted apps document is correctly formed', () => { - const data = [{ id: 'test_1' }] as TrustedApp[]; - const templatedItems = templateTrustedApps(data); + const data = [{ id: 'test_1' }] as ExceptionListItem[]; + const templatedItems = templateExceptionList(data, LIST_TRUSTED_APPLICATION); expect(templatedItems[0]?.trusted_application.length).toEqual(1); expect(templatedItems[0]?.endpoint_exception.length).toEqual(0); @@ -142,8 +144,8 @@ describe('list telemetry schema', () => { }); test('trusted apps document is correctly formed with multiple entries', () => { - const data = [{ id: 'test_2' }, { id: 'test_2' }] as TrustedApp[]; - const templatedItems = templateTrustedApps(data); + const data = [{ id: 'test_2' }, { id: 'test_2' }] as ExceptionListItem[]; + const templatedItems = templateExceptionList(data, LIST_TRUSTED_APPLICATION); expect(templatedItems[0]?.trusted_application.length).toEqual(1); expect(templatedItems[1]?.trusted_application.length).toEqual(1); @@ -152,8 +154,8 @@ describe('list telemetry schema', () => { }); test('endpoint exception document is correctly formed', () => { - const data = [{ id: 'test_3' }] as EndpointExceptionListItem[]; - const templatedItems = templateEndpointExceptions(data, LIST_ENDPOINT_EXCEPTION); + const data = [{ id: 'test_3' }] as ExceptionListItem[]; + const templatedItems = templateExceptionList(data, LIST_ENDPOINT_EXCEPTION); expect(templatedItems[0]?.trusted_application.length).toEqual(0); expect(templatedItems[0]?.endpoint_exception.length).toEqual(1); @@ -161,12 +163,8 @@ describe('list telemetry schema', () => { }); test('endpoint exception document is correctly formed with multiple entries', () => { - const data = [ - { id: 'test_4' }, - { id: 'test_4' }, - { id: 'test_4' }, - ] as EndpointExceptionListItem[]; - const templatedItems = templateEndpointExceptions(data, LIST_ENDPOINT_EXCEPTION); + const data = [{ id: 'test_4' }, { id: 'test_4' }, { id: 'test_4' }] as ExceptionListItem[]; + const templatedItems = templateExceptionList(data, LIST_ENDPOINT_EXCEPTION); expect(templatedItems[0]?.trusted_application.length).toEqual(0); expect(templatedItems[0]?.endpoint_exception.length).toEqual(1); @@ -176,8 +174,8 @@ describe('list telemetry schema', () => { }); test('endpoint event filters document is correctly formed', () => { - const data = [{ id: 'test_5' }] as EndpointExceptionListItem[]; - const templatedItems = templateEndpointExceptions(data, LIST_ENDPOINT_EVENT_FILTER); + const data = [{ id: 'test_5' }] as ExceptionListItem[]; + const templatedItems = templateExceptionList(data, LIST_ENDPOINT_EVENT_FILTER); expect(templatedItems[0]?.trusted_application.length).toEqual(0); expect(templatedItems[0]?.endpoint_exception.length).toEqual(0); @@ -185,8 +183,8 @@ describe('list telemetry schema', () => { }); test('endpoint event filters document is correctly formed with multiple entries', () => { - const data = [{ id: 'test_6' }, { id: 'test_6' }] as EndpointExceptionListItem[]; - const templatedItems = templateEndpointExceptions(data, LIST_ENDPOINT_EVENT_FILTER); + const data = [{ id: 'test_6' }, { id: 'test_6' }] as ExceptionListItem[]; + const templatedItems = templateExceptionList(data, LIST_ENDPOINT_EVENT_FILTER); expect(templatedItems[0]?.trusted_application.length).toEqual(0); expect(templatedItems[0]?.endpoint_exception.length).toEqual(0); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts index bb2cc4f42ca9..a9eaef3ce6ed 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts @@ -7,10 +7,15 @@ import moment from 'moment'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { TrustedApp } from '../../../common/endpoint/types'; import { PackagePolicy } from '../../../../fleet/common/types/models/package_policy'; -import { EndpointExceptionListItem, ListTemplate } from './types'; -import { LIST_ENDPOINT_EXCEPTION, LIST_ENDPOINT_EVENT_FILTER } from './constants'; +import { copyAllowlistedFields, exceptionListEventFields } from './filters'; +import { ExceptionListItem, ListTemplate, TelemetryEvent } from './types'; +import { + LIST_ENDPOINT_EXCEPTION, + LIST_ENDPOINT_EVENT_FILTER, + LIST_TRUSTED_APPLICATION, +} from './constants'; +import { TrustedApp } from '../../../common/endpoint/types'; /** * Determines the when the last run was in order to execute to. @@ -89,43 +94,41 @@ export function isPackagePolicyList( } /** - * Maps Exception list item to parsable object + * Maps trusted application to shared telemetry object * * @param exceptionListItem * @returns collection of endpoint exceptions */ -export const exceptionListItemToEndpointEntry = (exceptionListItem: ExceptionListItemSchema) => { +export const trustedApplicationToTelemetryEntry = (trustedApplication: TrustedApp) => { + return { + id: trustedApplication.id, + version: trustedApplication.version || '', + name: trustedApplication.name, + description: trustedApplication.description, + created_at: trustedApplication.created_at, + updated_at: trustedApplication.updated_at, + entries: trustedApplication.entries, + os: trustedApplication.os, + } as ExceptionListItem; +}; + +/** + * Maps endpoint lists to shared telemetry object + * + * @param exceptionListItem + * @returns collection of endpoint exceptions + */ +export const exceptionListItemToTelemetryEntry = (exceptionListItem: ExceptionListItemSchema) => { return { id: exceptionListItem.id, version: exceptionListItem._version || '', name: exceptionListItem.name, description: exceptionListItem.description, created_at: exceptionListItem.created_at, - created_by: exceptionListItem.created_by, updated_at: exceptionListItem.updated_at, - updated_by: exceptionListItem.updated_by, entries: exceptionListItem.entries, os_types: exceptionListItem.os_types, - } as EndpointExceptionListItem; -}; - -/** - * Constructs the lists telemetry schema from a collection of Trusted Apps - * - * @param listData - * @returns lists telemetry schema - */ -export const templateTrustedApps = (listData: TrustedApp[]) => { - return listData.map((item) => { - const template: ListTemplate = { - trusted_application: [], - endpoint_exception: [], - endpoint_event_filter: [], - }; - - template.trusted_application.push(item); - return template; - }); + } as ExceptionListItem; }; /** @@ -135,10 +138,7 @@ export const templateTrustedApps = (listData: TrustedApp[]) => { * @param listType * @returns lists telemetry schema */ -export const templateEndpointExceptions = ( - listData: EndpointExceptionListItem[], - listType: string -) => { +export const templateExceptionList = (listData: ExceptionListItem[], listType: string) => { return listData.map((item) => { const template: ListTemplate = { trusted_application: [], @@ -146,13 +146,24 @@ export const templateEndpointExceptions = ( endpoint_event_filter: [], }; + // cast exception list type to a TelemetryEvent for allowlist filtering + const filteredListItem = copyAllowlistedFields( + exceptionListEventFields, + item as unknown as TelemetryEvent + ); + + if (listType === LIST_TRUSTED_APPLICATION) { + template.trusted_application.push(filteredListItem); + return template; + } + if (listType === LIST_ENDPOINT_EXCEPTION) { - template.endpoint_exception.push(item); + template.endpoint_exception.push(filteredListItem); return template; } if (listType === LIST_ENDPOINT_EVENT_FILTER) { - template.endpoint_event_filter.push(item); + template.endpoint_event_filter.push(filteredListItem); return template; } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts b/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts index 06dffdddc49c..8b715b8e8d58 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts @@ -17,7 +17,7 @@ import { AgentService, AgentPolicyServiceInterface } from '../../../../fleet/ser import { ExceptionListClient } from '../../../../lists/server'; import { EndpointAppContextService } from '../../endpoint/endpoint_app_context_services'; import { TELEMETRY_MAX_BUFFER_SIZE } from './constants'; -import { exceptionListItemToEndpointEntry } from './helpers'; +import { exceptionListItemToTelemetryEntry, trustedApplicationToTelemetryEntry } from './helpers'; import { TelemetryEvent, ESLicense, ESClusterInfo, GetEndpointListResponse } from './types'; export class TelemetryReceiver { @@ -202,7 +202,16 @@ export class TelemetryReceiver { throw Error('exception list client is unavailable: cannot retrieve trusted applications'); } - return getTrustedAppsList(this.exceptionListClient, { page: 1, per_page: 10_000 }); + const results = await getTrustedAppsList(this.exceptionListClient, { + page: 1, + per_page: 10_000, + }); + return { + data: results?.data.map(trustedApplicationToTelemetryEntry), + total: results?.total ?? 0, + page: results?.page ?? 1, + per_page: results?.per_page ?? this.max_records, + }; } public async fetchEndpointList(listId: string): Promise { @@ -224,7 +233,7 @@ export class TelemetryReceiver { }); return { - data: results?.data.map(exceptionListItemToEndpointEntry) ?? [], + data: results?.data.map(exceptionListItemToTelemetryEntry) ?? [], total: results?.total ?? 0, page: results?.page ?? 1, per_page: results?.per_page ?? this.max_records, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts index d04d0ab49afe..21e6b2cf6d9c 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts @@ -6,7 +6,7 @@ */ /* eslint-disable dot-notation */ -import { TelemetryEventsSender, copyAllowlistedFields } from './sender'; +import { TelemetryEventsSender } from './sender'; import { loggingSystemMock } from 'src/core/server/mocks'; import { usageCountersServiceMock } from 'src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock'; import { URL } from 'url'; @@ -220,123 +220,6 @@ describe('TelemetryEventsSender', () => { }); }); -describe('allowlistEventFields', () => { - const allowlist = { - a: true, - b: true, - c: { - d: true, - }, - }; - - it('filters top level', () => { - const event = { - a: 'a', - a1: 'a1', - b: 'b', - b1: 'b1', - }; - expect(copyAllowlistedFields(allowlist, event)).toStrictEqual({ - a: 'a', - b: 'b', - }); - }); - - it('filters nested', () => { - const event = { - a: { - a1: 'a1', - }, - a1: 'a1', - b: { - b1: 'b1', - }, - b1: 'b1', - c: { - d: 'd', - e: 'e', - f: 'f', - }, - }; - expect(copyAllowlistedFields(allowlist, event)).toStrictEqual({ - a: { - a1: 'a1', - }, - b: { - b1: 'b1', - }, - c: { - d: 'd', - }, - }); - }); - - it('filters arrays of objects', () => { - const event = { - a: [ - { - a1: 'a1', - }, - ], - b: { - b1: 'b1', - }, - c: [ - { - d: 'd1', - e: 'e1', - f: 'f1', - }, - { - d: 'd2', - e: 'e2', - f: 'f2', - }, - { - d: 'd3', - e: 'e3', - f: 'f3', - }, - ], - }; - expect(copyAllowlistedFields(allowlist, event)).toStrictEqual({ - a: [ - { - a1: 'a1', - }, - ], - b: { - b1: 'b1', - }, - c: [ - { - d: 'd1', - }, - { - d: 'd2', - }, - { - d: 'd3', - }, - ], - }); - }); - - it("doesn't create empty objects", () => { - const event = { - a: 'a', - b: 'b', - c: { - e: 'e', - }, - }; - expect(copyAllowlistedFields(allowlist, event)).toStrictEqual({ - a: 'a', - b: 'b', - }); - }); -}); - describe('getV3UrlFromV2', () => { let logger: ReturnType; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts index 2e615a268117..0037aaa28fee 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts @@ -17,7 +17,7 @@ import { TaskManagerStartContract, } from '../../../../task_manager/server'; import { TelemetryReceiver } from './receiver'; -import { AllowlistFields, allowlistEventFields } from './filters'; +import { allowlistEventFields, copyAllowlistedFields } from './filters'; import { DiagnosticTask, EndpointTask, ExceptionListsTask } from './tasks'; import { createUsageCounterLabel } from './helpers'; import { TelemetryEvent } from './types'; @@ -194,8 +194,8 @@ export class TelemetryEventsSender { /** * This function sends events to the elastic telemetry channel. Caution is required - * because it does no allowlist filtering. The caller is responsible for making sure - * that there is no sensitive material or PII in the records that are sent upstream. + * because it does no allowlist filtering at send time. The function call site is + * responsible for ensuring sure no sensitive material is in telemetry events. * * @param channel the elastic telemetry channel * @param toSend telemetry events @@ -294,30 +294,3 @@ export class TelemetryEventsSender { } } } - -export function copyAllowlistedFields( - allowlist: AllowlistFields, - event: TelemetryEvent -): TelemetryEvent { - return Object.entries(allowlist).reduce((newEvent, [allowKey, allowValue]) => { - const eventValue = event[allowKey]; - if (eventValue !== null && eventValue !== undefined) { - if (allowValue === true) { - return { ...newEvent, [allowKey]: eventValue }; - } else if (typeof allowValue === 'object' && Array.isArray(eventValue)) { - const subValues = eventValue.filter((v) => typeof v === 'object'); - return { - ...newEvent, - [allowKey]: subValues.map((v) => copyAllowlistedFields(allowValue, v as TelemetryEvent)), - }; - } else if (typeof allowValue === 'object' && typeof eventValue === 'object') { - const values = copyAllowlistedFields(allowValue, eventValue as TelemetryEvent); - return { - ...newEvent, - ...(Object.keys(values).length > 0 ? { [allowKey]: values } : {}), - }; - } - } - return newEvent; - }, {}); -} diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.ts index b54858e1f5f4..fe2039419b02 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.ts @@ -19,9 +19,10 @@ import { import { LIST_ENDPOINT_EXCEPTION, LIST_ENDPOINT_EVENT_FILTER, + LIST_TRUSTED_APPLICATION, TELEMETRY_CHANNEL_LISTS, } from '../constants'; -import { batchTelemetryRecords, templateEndpointExceptions, templateTrustedApps } from '../helpers'; +import { batchTelemetryRecords, templateExceptionList } from '../helpers'; import { TelemetryEventsSender } from '../sender'; import { TelemetryReceiver } from '../receiver'; @@ -110,7 +111,7 @@ export class TelemetryExceptionListsTask { // Lists Telemetry: Trusted Applications const trustedApps = await this.receiver.fetchTrustedApplications(); - const trustedAppsJson = templateTrustedApps(trustedApps.data); + const trustedAppsJson = templateExceptionList(trustedApps.data, LIST_TRUSTED_APPLICATION); this.logger.debug(`Trusted Apps: ${trustedAppsJson}`); batchTelemetryRecords(trustedAppsJson, MAX_TELEMETRY_BATCH).forEach((batch) => @@ -120,7 +121,7 @@ export class TelemetryExceptionListsTask { // Lists Telemetry: Endpoint Exceptions const epExceptions = await this.receiver.fetchEndpointList(ENDPOINT_LIST_ID); - const epExceptionsJson = templateEndpointExceptions(epExceptions.data, LIST_ENDPOINT_EXCEPTION); + const epExceptionsJson = templateExceptionList(epExceptions.data, LIST_ENDPOINT_EXCEPTION); this.logger.debug(`EP Exceptions: ${epExceptionsJson}`); batchTelemetryRecords(epExceptionsJson, MAX_TELEMETRY_BATCH).forEach((batch) => @@ -130,7 +131,7 @@ export class TelemetryExceptionListsTask { // Lists Telemetry: Endpoint Event Filters const epFilters = await this.receiver.fetchEndpointList(ENDPOINT_EVENT_FILTERS_LIST_ID); - const epFiltersJson = templateEndpointExceptions(epFilters.data, LIST_ENDPOINT_EVENT_FILTER); + const epFiltersJson = templateExceptionList(epFilters.data, LIST_ENDPOINT_EVENT_FILTER); this.logger.debug(`EP Event Filters: ${epFiltersJson}`); batchTelemetryRecords(epFiltersJson, MAX_TELEMETRY_BATCH).forEach((batch) => diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts index b78017314a98..abcad26ed000 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts @@ -6,7 +6,6 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; -import { TrustedApp } from '../../../common/endpoint/types'; type BaseSearchTypes = string | number | boolean | object; export type SearchTypes = BaseSearchTypes | BaseSearchTypes[] | undefined; @@ -211,26 +210,25 @@ export interface GetEndpointListResponse { per_page: number; page: number; total: number; - data: EndpointExceptionListItem[]; + data: ExceptionListItem[]; } // Telemetry List types -export interface EndpointExceptionListItem { +export interface ExceptionListItem { id: string; version: string; name: string; description: string; created_at: string; - created_by: string; updated_at: string; - updated_by: string; entries: object; + os: string; os_types: object; } export interface ListTemplate { - trusted_application: TrustedApp[]; - endpoint_exception: EndpointExceptionListItem[]; - endpoint_event_filter: EndpointExceptionListItem[]; + trusted_application: TelemetryEvent[]; + endpoint_exception: TelemetryEvent[]; + endpoint_event_filter: TelemetryEvent[]; } diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/pinned_events/field_migrator.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/pinned_events/field_migrator.ts new file mode 100644 index 000000000000..5939676c2a92 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/pinned_events/field_migrator.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TIMELINE_ID_REF_NAME } from '../../constants'; +import { timelineSavedObjectType } from '../../saved_object_mappings'; +import { FieldMigrator } from '../../utils/migrator'; + +/** + * A migrator to handle moving specific fields that reference the timeline saved object to the references field within a note saved + * object. + */ +export const pinnedEventFieldsMigrator = new FieldMigrator([ + { path: 'timelineId', type: timelineSavedObjectType, name: TIMELINE_ID_REF_NAME }, +]); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/pinned_events/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/pinned_events/index.ts index b3d262b13cbf..260531e1106b 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/pinned_events/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/pinned_events/index.ts @@ -19,13 +19,13 @@ import { PinnedEventSavedObjectRuntimeType, SavedPinnedEvent, PinnedEvent as PinnedEventResponse, + PinnedEventWithoutExternalRefs, } from '../../../../../common/types/timeline/pinned_event'; -import { PageInfoNote, SortNote } from '../../../../../common/types/timeline/note'; import { FrameworkRequest } from '../../../framework'; -import { pickSavedTimeline } from '../../saved_object/timelines'; -import { convertSavedObjectToSavedTimeline } from '../timelines'; +import { createTimeline } from '../../saved_object/timelines'; import { pinnedEventSavedObjectType } from '../../saved_object_mappings/pinned_events'; +import { pinnedEventFieldsMigrator } from './field_migrator'; import { timelineSavedObjectType } from '../../saved_object_mappings'; export interface PinnedEvent { @@ -46,13 +46,6 @@ export interface PinnedEvent { timelineId: string ) => Promise; - getAllPinnedEvents: ( - request: FrameworkRequest, - pageInfo: PageInfoNote | null, - search: string | null, - sort: SortNote | null - ) => Promise; - persistPinnedEventOnTimeline: ( request: FrameworkRequest, pinnedEventId: string | null, // pinned event saved object id @@ -117,26 +110,7 @@ export const getAllPinnedEventsByTimelineId = async ( ): Promise => { const options: SavedObjectsFindOptions = { type: pinnedEventSavedObjectType, - search: timelineId, - searchFields: ['timelineId'], - }; - return getAllSavedPinnedEvents(request, options); -}; - -export const getAllPinnedEvents = async ( - request: FrameworkRequest, - pageInfo: PageInfoNote | null, - search: string | null, - sort: SortNote | null -): Promise => { - const options: SavedObjectsFindOptions = { - type: pinnedEventSavedObjectType, - perPage: pageInfo != null ? pageInfo.pageSize : undefined, - page: pageInfo != null ? pageInfo.pageIndex : undefined, - search: search != null ? search : undefined, - searchFields: ['timelineId', 'eventId'], - sortField: sort != null ? sort.sortField : undefined, - sortOrder: sort != null ? sort.sortOrder : undefined, + hasReference: { type: timelineSavedObjectType, id: timelineId }, }; return getAllSavedPinnedEvents(request, options); }; @@ -147,51 +121,35 @@ export const persistPinnedEventOnTimeline = async ( eventId: string, timelineId: string | null ): Promise => { - const savedObjectsClient = request.context.core.savedObjects.client; - try { - if (pinnedEventId == null) { - const timelineVersionSavedObject = - timelineId == null - ? await (async () => { - const timelineResult = convertSavedObjectToSavedTimeline( - await savedObjectsClient.create( - timelineSavedObjectType, - pickSavedTimeline(null, {}, request.user || null) - ) - ); - timelineId = timelineResult.savedObjectId; // eslint-disable-line no-param-reassign - return timelineResult.version; - })() - : null; - - if (timelineId != null) { - const allPinnedEventId = await getAllPinnedEventsByTimelineId(request, timelineId); - const isPinnedAlreadyExisting = allPinnedEventId.filter( - (pinnedEvent) => pinnedEvent.eventId === eventId - ); - - if (isPinnedAlreadyExisting.length === 0) { - const savedPinnedEvent: SavedPinnedEvent = { - eventId, - timelineId, - }; - // create Pinned Event on Timeline - return convertSavedObjectToSavedPinnedEvent( - await savedObjectsClient.create( - pinnedEventSavedObjectType, - pickSavedPinnedEvent(pinnedEventId, savedPinnedEvent, request.user || null) - ), - timelineVersionSavedObject != null ? timelineVersionSavedObject : undefined - ); - } - return isPinnedAlreadyExisting[0]; - } - throw new Error('You can NOT pinned event without a timelineID'); + if (pinnedEventId != null) { + // Delete Pinned Event on Timeline + await deletePinnedEventOnTimeline(request, [pinnedEventId]); + return null; } - // Delete Pinned Event on Timeline - await deletePinnedEventOnTimeline(request, [pinnedEventId]); - return null; + + const { timelineId: validatedTimelineId, timelineVersion } = await getValidTimelineIdAndVersion( + request, + timelineId + ); + + const pinnedEvents = await getPinnedEventsInTimelineWithEventId( + request, + validatedTimelineId, + eventId + ); + + // we already had this event pinned so let's just return the one we already had + if (pinnedEvents.length > 0) { + return pinnedEvents[0]; + } + + return await createPinnedEvent({ + request, + eventId, + timelineId: validatedTimelineId, + timelineVersion, + }); } catch (err) { if (getOr(null, 'output.statusCode', err) === 404) { /* @@ -215,11 +173,91 @@ export const persistPinnedEventOnTimeline = async ( } }; +const getValidTimelineIdAndVersion = async ( + request: FrameworkRequest, + timelineId: string | null +): Promise<{ timelineId: string; timelineVersion?: string }> => { + if (timelineId != null) { + return { + timelineId, + }; + } + + const savedObjectsClient = request.context.core.savedObjects.client; + + // create timeline because it didn't exist + const { timeline: timelineResult } = await createTimeline({ + timelineId: null, + timeline: {}, + savedObjectsClient, + userInfo: request.user, + }); + + return { + timelineId: timelineResult.savedObjectId, + timelineVersion: timelineResult.version, + }; +}; + +const getPinnedEventsInTimelineWithEventId = async ( + request: FrameworkRequest, + timelineId: string, + eventId: string +): Promise => { + const allPinnedEventId = await getAllPinnedEventsByTimelineId(request, timelineId); + const pinnedEvents = allPinnedEventId.filter((pinnedEvent) => pinnedEvent.eventId === eventId); + + return pinnedEvents; +}; + +const createPinnedEvent = async ({ + request, + eventId, + timelineId, + timelineVersion, +}: { + request: FrameworkRequest; + eventId: string; + timelineId: string; + timelineVersion?: string; +}) => { + const savedObjectsClient = request.context.core.savedObjects.client; + + const savedPinnedEvent: SavedPinnedEvent = { + eventId, + timelineId, + }; + + const pinnedEventWithCreator = pickSavedPinnedEvent(null, savedPinnedEvent, request.user); + + const { transformedFields: migratedAttributes, references } = + pinnedEventFieldsMigrator.extractFieldsToReferences({ + data: pinnedEventWithCreator, + }); + + const createdPinnedEvent = await savedObjectsClient.create( + pinnedEventSavedObjectType, + migratedAttributes, + { references } + ); + + const repopulatedSavedObject = + pinnedEventFieldsMigrator.populateFieldsFromReferences(createdPinnedEvent); + + // create Pinned Event on Timeline + return convertSavedObjectToSavedPinnedEvent(repopulatedSavedObject, timelineVersion); +}; + const getSavedPinnedEvent = async (request: FrameworkRequest, pinnedEventId: string) => { const savedObjectsClient = request.context.core.savedObjects.client; - const savedObject = await savedObjectsClient.get(pinnedEventSavedObjectType, pinnedEventId); + const savedObject = await savedObjectsClient.get( + pinnedEventSavedObjectType, + pinnedEventId + ); + + const populatedPinnedEvent = pinnedEventFieldsMigrator.populateFieldsFromReferences(savedObject); - return convertSavedObjectToSavedPinnedEvent(savedObject); + return convertSavedObjectToSavedPinnedEvent(populatedPinnedEvent); }; const getAllSavedPinnedEvents = async ( @@ -227,11 +265,14 @@ const getAllSavedPinnedEvents = async ( options: SavedObjectsFindOptions ) => { const savedObjectsClient = request.context.core.savedObjects.client; - const savedObjects = await savedObjectsClient.find(options); + const savedObjects = await savedObjectsClient.find(options); - return savedObjects.saved_objects.map((savedObject) => - convertSavedObjectToSavedPinnedEvent(savedObject) - ); + return savedObjects.saved_objects.map((savedObject) => { + const populatedPinnedEvent = + pinnedEventFieldsMigrator.populateFieldsFromReferences(savedObject); + + return convertSavedObjectToSavedPinnedEvent(populatedPinnedEvent); + }); }; export const savePinnedEvents = ( @@ -284,11 +325,10 @@ export const pickSavedPinnedEvent = ( if (pinnedEventId == null) { savedPinnedEvent.created = dateNow; savedPinnedEvent.createdBy = userInfo?.username ?? UNAUTHENTICATED_USER; - savedPinnedEvent.updated = dateNow; - savedPinnedEvent.updatedBy = userInfo?.username ?? UNAUTHENTICATED_USER; - } else if (pinnedEventId != null) { - savedPinnedEvent.updated = dateNow; - savedPinnedEvent.updatedBy = userInfo?.username ?? UNAUTHENTICATED_USER; } + + savedPinnedEvent.updated = dateNow; + savedPinnedEvent.updatedBy = userInfo?.username ?? UNAUTHENTICATED_USER; + return savedPinnedEvent; }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/notes.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/notes.test.ts deleted file mode 100644 index b9649896c25a..000000000000 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/notes.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { TIMELINE_ID_REF_NAME } from '../../constants'; -import { migrateNoteTimelineIdToReferences, TimelineId } from './notes'; - -describe('notes migrations', () => { - describe('7.16.0 timelineId', () => { - it('removes the timelineId from the migrated document', () => { - const migratedDoc = migrateNoteTimelineIdToReferences({ - id: '1', - type: 'awesome', - attributes: { timelineId: '123' }, - }); - - expect(migratedDoc.attributes).toEqual({}); - expect(migratedDoc.references).toEqual([ - // importing the timeline saved object type from the timeline saved object causes a circular import and causes the jest tests to fail - { id: '123', name: TIMELINE_ID_REF_NAME, type: 'siem-ui-timeline' }, - ]); - }); - - it('preserves additional fields when migrating timeline id', () => { - const migratedDoc = migrateNoteTimelineIdToReferences({ - id: '1', - type: 'awesome', - attributes: { awesome: 'yes', timelineId: '123' } as unknown as TimelineId, - }); - - expect(migratedDoc.attributes).toEqual({ awesome: 'yes' }); - expect(migratedDoc.references).toEqual([ - { id: '123', name: TIMELINE_ID_REF_NAME, type: 'siem-ui-timeline' }, - ]); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/notes.ts index a8d753e916af..76773b7fcd51 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/notes.ts @@ -5,39 +5,9 @@ * 2.0. */ -import { - SavedObjectMigrationMap, - SavedObjectSanitizedDoc, - SavedObjectUnsanitizedDoc, -} from 'kibana/server'; -import { timelineSavedObjectType } from '..'; -import { TIMELINE_ID_REF_NAME } from '../../constants'; -import { createMigratedDoc, createReference } from './utils'; - -export interface TimelineId { - timelineId?: string | null; -} - -export const migrateNoteTimelineIdToReferences = ( - doc: SavedObjectUnsanitizedDoc -): SavedObjectSanitizedDoc => { - const { timelineId, ...restAttributes } = doc.attributes; - - const { references: docReferences = [] } = doc; - const timelineIdReferences = createReference( - timelineId, - TIMELINE_ID_REF_NAME, - timelineSavedObjectType - ); - - return createMigratedDoc({ - doc, - attributes: restAttributes, - docReferences, - migratedReferences: timelineIdReferences, - }); -}; +import { SavedObjectMigrationMap } from 'kibana/server'; +import { migrateTimelineIdToReferences } from './utils'; export const notesMigrations: SavedObjectMigrationMap = { - '7.16.0': migrateNoteTimelineIdToReferences, + '7.16.0': migrateTimelineIdToReferences, }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/pinned_events.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/pinned_events.ts new file mode 100644 index 000000000000..4d21190d9381 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/pinned_events.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectMigrationMap } from 'kibana/server'; +import { migrateTimelineIdToReferences } from './utils'; + +export const pinnedEventsMigrations: SavedObjectMigrationMap = { + '7.16.0': migrateTimelineIdToReferences, +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/types.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/types.ts new file mode 100644 index 000000000000..7c62310a99aa --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/types.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface TimelineId { + timelineId?: string | null; +} diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.test.ts index 02e3fca996d5..329f09e85f3a 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.test.ts @@ -5,9 +5,39 @@ * 2.0. */ -import { createMigratedDoc, createReference } from './utils'; +import { timelineSavedObjectType } from '../timelines'; +import { TIMELINE_ID_REF_NAME } from '../../constants'; +import { TimelineId } from './types'; +import { createMigratedDoc, createReference, migrateTimelineIdToReferences } from './utils'; describe('migration utils', () => { + describe('migrateTimelineIdToReferences', () => { + it('removes the timelineId from the migrated document', () => { + const migratedDoc = migrateTimelineIdToReferences({ + id: '1', + type: 'awesome', + attributes: { timelineId: '123' }, + }); + + expect(migratedDoc.attributes).toEqual({}); + expect(migratedDoc.references).toEqual([ + { id: '123', name: TIMELINE_ID_REF_NAME, type: timelineSavedObjectType }, + ]); + }); + + it('preserves additional fields when migrating timeline id', () => { + const migratedDoc = migrateTimelineIdToReferences({ + id: '1', + type: 'awesome', + attributes: { awesome: 'yes', timelineId: '123' } as unknown as TimelineId, + }); + + expect(migratedDoc.attributes).toEqual({ awesome: 'yes' }); + expect(migratedDoc.references).toEqual([ + { id: '123', name: TIMELINE_ID_REF_NAME, type: timelineSavedObjectType }, + ]); + }); + }); describe('createReference', () => { it('returns an array with a reference when the id is defined', () => { expect(createReference('awesome', 'name', 'type')).toEqual([ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.ts index ff9b56e6ae2c..7bd7bc148c26 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.ts @@ -10,6 +10,9 @@ import { SavedObjectSanitizedDoc, SavedObjectUnsanitizedDoc, } from 'kibana/server'; +import { timelineSavedObjectType } from '../timelines'; +import { TIMELINE_ID_REF_NAME } from '../../constants'; +import { TimelineId } from './types'; export function createReference( id: string | null | undefined, @@ -19,6 +22,26 @@ export function createReference( return id != null ? [{ id, name, type }] : []; } +export const migrateTimelineIdToReferences = ( + doc: SavedObjectUnsanitizedDoc +): SavedObjectSanitizedDoc => { + const { timelineId, ...restAttributes } = doc.attributes; + + const { references: docReferences = [] } = doc; + const timelineIdReferences = createReference( + timelineId, + TIMELINE_ID_REF_NAME, + timelineSavedObjectType + ); + + return createMigratedDoc({ + doc, + attributes: restAttributes, + docReferences, + migratedReferences: timelineIdReferences, + }); +}; + export const createMigratedDoc = ({ doc, attributes, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts index 387f78e5059f..eda2478e7809 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts @@ -6,7 +6,7 @@ */ import { SavedObjectsType } from '../../../../../../../src/core/server'; -import { notesMigrations } from './migrations'; +import { notesMigrations } from './migrations/notes'; export const noteSavedObjectType = 'siem-ui-timeline-note'; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/pinned_events.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/pinned_events.ts index fbbffe35a58c..2f8e72ad763f 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/pinned_events.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/pinned_events.ts @@ -6,14 +6,12 @@ */ import { SavedObjectsType } from '../../../../../../../src/core/server'; +import { pinnedEventsMigrations } from './migrations/pinned_events'; export const pinnedEventSavedObjectType = 'siem-ui-timeline-pinned-event'; export const pinnedEventSavedObjectMappings: SavedObjectsType['mappings'] = { properties: { - timelineId: { - type: 'keyword', - }, eventId: { type: 'keyword', }, @@ -37,4 +35,5 @@ export const pinnedEventType: SavedObjectsType = { hidden: false, namespaceType: 'single', mappings: pinnedEventSavedObjectMappings, + migrations: pinnedEventsMigrations, }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/timelines.ts index 8300f72a162e..e1e3a454087f 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/timelines.ts @@ -6,7 +6,7 @@ */ import { SavedObjectsType } from '../../../../../../../src/core/server'; -import { timelinesMigrations } from './migrations'; +import { timelinesMigrations } from './migrations/timelines'; export const timelineSavedObjectType = 'siem-ui-timeline'; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/__mocks__/index.ts index 8616a2ef1485..41cf691e00f9 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/__mocks__/index.ts @@ -81,7 +81,7 @@ export const formattedSearchStrategyResponse = { issuers: { terms: { field: 'tls.server.issuer' } }, subjects: { terms: { field: 'tls.server.subject' } }, not_after: { terms: { field: 'tls.server.not_after' } }, - ja3: { terms: { field: 'tls.server.ja3s' } }, + ja3: { terms: { field: 'tls.client.ja3s' } }, }, }, }, @@ -136,7 +136,7 @@ export const expectedDsl = { issuers: { terms: { field: 'tls.server.issuer' } }, subjects: { terms: { field: 'tls.server.subject' } }, not_after: { terms: { field: 'tls.server.not_after' } }, - ja3: { terms: { field: 'tls.server.ja3s' } }, + ja3: { terms: { field: 'tls.client.ja3s' } }, }, }, }, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/query.tls_network.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/query.tls_network.dsl.ts index be60b33ae2d2..7f3f649ed965 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/query.tls_network.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/query.tls_network.dsl.ts @@ -47,7 +47,7 @@ const getAggs = (querySize: number, sort: SortField) => ({ }, ja3: { terms: { - field: 'tls.server.ja3s', + field: 'tls.client.ja3s', }, }, }, diff --git a/x-pack/plugins/timelines/common/types/timeline/columns/index.tsx b/x-pack/plugins/timelines/common/types/timeline/columns/index.tsx index cc20c856f0e1..644b38d55133 100644 --- a/x-pack/plugins/timelines/common/types/timeline/columns/index.tsx +++ b/x-pack/plugins/timelines/common/types/timeline/columns/index.tsx @@ -8,7 +8,7 @@ import { ReactNode } from 'react'; import { EuiDataGridColumn, EuiDataGridColumnCellActionProps } from '@elastic/eui'; -import { IFieldSubType } from '../../../../../../../src/plugins/data/common'; +import { Filter, IFieldSubType } from '../../../../../../../src/plugins/data/common'; import { BrowserFields } from '../../../search_strategy/index_fields'; import { TimelineNonEcsData } from '../../../search_strategy/timeline'; @@ -45,14 +45,16 @@ export type ColumnId = string; export type TGridCellAction = ({ browserFields, data, - timelineId, + globalFilters, pageSize, + timelineId, }: { browserFields: BrowserFields; /** each row of data is represented as one TimelineNonEcsData[] */ data: TimelineNonEcsData[][]; - timelineId: string; + globalFilters?: Filter[]; pageSize: number; + timelineId: string; }) => (props: EuiDataGridColumnCellActionProps) => ReactNode; /** The specification of a column header */ diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx index 86f42d2f68f7..d67cc746f352 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx @@ -74,6 +74,7 @@ import type { EuiTheme } from '../../../../../../../src/plugins/kibana_react/com import { ViewSelection } from '../event_rendered_view/selector'; import { EventRenderedView } from '../event_rendered_view'; import { useDataGridHeightHack } from './height_hack'; +import { Filter } from '../../../../../../../src/plugins/data/public'; const StatefulAlertStatusBulkActions = lazy( () => import('../toolbar/bulk_actions/alert_status_bulk_actions') @@ -86,6 +87,7 @@ interface OwnProps { bulkActions?: BulkActionsProp; data: TimelineItem[]; defaultCellActions?: TGridCellAction[]; + filters?: Filter[]; filterQuery: string; filterStatus?: AlertStatus; id: string; @@ -300,15 +302,18 @@ export const BodyComponent = React.memo( data, defaultCellActions, filterQuery, + filters, filterStatus, + hasAlertsCrud, + hasAlertsCrudPermissions, id, indexNames, isEventViewer = false, + isLoading, isSelectAllChecked, itemsPerPageOptions, leadingControlColumns = EMPTY_CONTROL_COLUMNS, loadingEventIds, - isLoading, loadPage, onRuleChange, pageSize, @@ -322,11 +327,9 @@ export const BodyComponent = React.memo( tableView = 'gridView', tabType, totalItems, + totalSelectAllAlerts, trailingControlColumns = EMPTY_CONTROL_COLUMNS, unit = defaultUnit, - hasAlertsCrud, - hasAlertsCrudPermissions, - totalSelectAllAlerts, }) => { const dispatch = useDispatch(); const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []); @@ -641,10 +644,11 @@ export const BodyComponent = React.memo( columnHeaders.map((header) => { const buildAction = (tGridCellAction: TGridCellAction) => tGridCellAction({ - data: data.map((row) => row.data), browserFields, - timelineId: id, + data: data.map((row) => row.data), + globalFilters: filters, pageSize, + timelineId: id, }); return { @@ -653,7 +657,7 @@ export const BodyComponent = React.memo( header.tGridCellActions?.map(buildAction) ?? defaultCellActions?.map(buildAction), }; }), - [browserFields, columnHeaders, data, defaultCellActions, id, pageSize] + [browserFields, columnHeaders, data, defaultCellActions, id, pageSize, filters] ); const renderTGridCellValue = useMemo(() => { diff --git a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx index fb37bab7668b..b649dc36abe0 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx @@ -347,30 +347,31 @@ const TGridIntegratedComponent: React.FC = ({ > diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9c69e4fa612f..38c957292ec7 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4369,14 +4369,11 @@ "savedObjects.saveModalOrigin.originAfterSavingSwitchLabel": "保存後に{originVerb}から{origin}", "savedObjects.saveModalOrigin.returnToOriginLabel": "戻る", "savedObjects.saveModalOrigin.saveAndReturnLabel": "保存して戻る", - "savedObjectsManagement.breadcrumb.edit": "{savedObjectType}を編集", "savedObjectsManagement.breadcrumb.index": "保存されたオブジェクト", "savedObjectsManagement.deleteConfirm.modalDeleteButtonLabel": "削除", "savedObjectsManagement.deleteConfirm.modalDescription": "このアクションはオブジェクトをKibanaから永久に削除します。", "savedObjectsManagement.deleteConfirm.modalTitle": "「{title}」を削除しますか?", "savedObjectsManagement.deleteSavedObjectsConfirmModalDescription": "この操作は次の保存されたオブジェクトを削除します:", - "savedObjectsManagement.field.offLabel": "オフ", - "savedObjectsManagement.field.onLabel": "オン", "savedObjectsManagement.importSummary.createdCountHeader": "{createdCount}件の新規項目", "savedObjectsManagement.importSummary.createdOutcomeLabel": "作成済み", "savedObjectsManagement.importSummary.errorCountHeader": "{errorCount}件のエラー", @@ -4478,21 +4475,10 @@ "savedObjectsManagement.objectsTable.unableFindSavedObjectNotificationMessage": "保存されたオブジェクトが見つかりません", "savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage": "保存されたオブジェクトが見つかりません", "savedObjectsManagement.objectView.unableFindSavedObjectNotificationMessage": "保存されたオブジェクトが見つかりません", - "savedObjectsManagement.view.cancelButtonAriaLabel": "キャンセル", - "savedObjectsManagement.view.cancelButtonLabel": "キャンセル", - "savedObjectsManagement.view.deleteItemButtonLabel": "{title}を削除", - "savedObjectsManagement.view.editItemTitle": "{title}の編集", "savedObjectsManagement.view.fieldDoesNotExistErrorMessage": "このオブジェクトに関連付けられたフィールドは、現在このインデックスパターンに存在しません。", - "savedObjectsManagement.view.howToFixErrorDescription": "このエラーの原因がわかる場合は修正してください。わからない場合は上の削除ボタンをクリックしてください。", - "savedObjectsManagement.view.howToModifyObjectDescription": "オブジェクトの編集は上級ユーザー向けです。オブジェクトのプロパティが検証されておらず、無効なオブジェクトはエラー、データ損失、またはそれ以上の問題の原因となります。コードを熟知した人に指示されていない限り、この設定は変更しない方が無難です。", - "savedObjectsManagement.view.howToModifyObjectTitle": "十分ご注意ください!", "savedObjectsManagement.view.indexPatternDoesNotExistErrorMessage": "このオブジェクトに関連付けられたインデックスパターンは現在存在しません。", - "savedObjectsManagement.view.saveButtonAriaLabel": "{ title }オブジェクトを保存", - "savedObjectsManagement.view.saveButtonLabel": "{ title }オブジェクトを保存", "savedObjectsManagement.view.savedObjectProblemErrorMessage": "この保存されたオブジェクトに問題があります", "savedObjectsManagement.view.savedSearchDoesNotExistErrorMessage": "このオブジェクトに関連付けられた保存された検索は現在存在しません。", - "savedObjectsManagement.view.viewItemButtonLabel": "{title}を表示", - "savedObjectsManagement.view.viewItemTitle": "{title}を表示", "security.checkup.dismissButtonText": "閉じる", "security.checkup.dontShowAgain": "今後表示しない", "security.checkup.insecureClusterMessage": "1 ビットを失わないでください。Elastic では無料でデータを保護できます。", @@ -5241,9 +5227,7 @@ "visTypeTimeseries.indexPatternSelect.label": "インデックスパターン", "visTypeTimeseries.indexPatternSelect.queryAllIndexesText": "すべてのインデックスにクエリを実行するには * を使用します", "visTypeTimeseries.indexPatternSelect.switchModePopover.areaLabel": "インデックスパターン選択モードを構成", - "visTypeTimeseries.indexPatternSelect.switchModePopover.text": "インデックスパターンは、データ探索で対象とする1つ以上のElasticsearchインデックスを定義します。ElasticsearchインデックスまたはKibanaインデックスパターン(推奨)を使用できます。", "visTypeTimeseries.indexPatternSelect.switchModePopover.title": "インデックスパターン選択モード", - "visTypeTimeseries.indexPatternSelect.switchModePopover.useKibanaIndices": "Kibanaインデックスパターンのみを使用", "visTypeTimeseries.kbnVisTypes.metricsDescription": "時系列データの高度な分析を実行します。", "visTypeTimeseries.kbnVisTypes.metricsTitle": "TSVB", "visTypeTimeseries.lastValueModeIndicator.lastBucketDate": "バケット:{lastBucketDate}", @@ -5570,7 +5554,6 @@ "visTypeTimeseries.visEditorVisualization.changesWillBeAutomaticallyAppliedMessage": "変更が自動的に適用されます。", "visTypeTimeseries.visEditorVisualization.indexPatternMode.dismissNoticeButtonText": "閉じる", "visTypeTimeseries.visEditorVisualization.indexPatternMode.link": "確認してください。", - "visTypeTimeseries.visEditorVisualization.indexPatternMode.notificationMessage": "お知らせElasticsearchインデックスまたはKibanaインデックスパターンからデータを可視化できるようになりました。{indexPatternModeLink}。", "visTypeTimeseries.visEditorVisualization.indexPatternMode.notificationTitle": "TSVBはインデックスパターンをサポートします", "visTypeTimeseries.visPicker.gaugeLabel": "ゲージ", "visTypeTimeseries.visPicker.metricLabel": "メトリック", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 1c91700a74e8..9f046ffc53cc 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4409,14 +4409,11 @@ "savedObjects.saveModalOrigin.originAfterSavingSwitchLabel": "保存后{originVerb}至{origin}", "savedObjects.saveModalOrigin.returnToOriginLabel": "返回", "savedObjects.saveModalOrigin.saveAndReturnLabel": "保存并返回", - "savedObjectsManagement.breadcrumb.edit": "编辑 {savedObjectType}", "savedObjectsManagement.breadcrumb.index": "已保存对象", "savedObjectsManagement.deleteConfirm.modalDeleteButtonLabel": "删除", "savedObjectsManagement.deleteConfirm.modalDescription": "此操作会将对象从 Kibana 永久移除。", "savedObjectsManagement.deleteConfirm.modalTitle": "删除“{title}”?", "savedObjectsManagement.deleteSavedObjectsConfirmModalDescription": "此操作将删除以下已保存对象:", - "savedObjectsManagement.field.offLabel": "关闭", - "savedObjectsManagement.field.onLabel": "开启", "savedObjectsManagement.importSummary.createdCountHeader": "{createdCount} 个新", "savedObjectsManagement.importSummary.createdOutcomeLabel": "已创建", "savedObjectsManagement.importSummary.errorCountHeader": "{errorCount} 个错误", @@ -4523,21 +4520,10 @@ "savedObjectsManagement.objectsTable.unableFindSavedObjectNotificationMessage": "找不到已保存对象", "savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage": "找不到已保存对象", "savedObjectsManagement.objectView.unableFindSavedObjectNotificationMessage": "找不到已保存对象", - "savedObjectsManagement.view.cancelButtonAriaLabel": "取消", - "savedObjectsManagement.view.cancelButtonLabel": "取消", - "savedObjectsManagement.view.deleteItemButtonLabel": "删除“{title}”", - "savedObjectsManagement.view.editItemTitle": "编辑“{title}”", "savedObjectsManagement.view.fieldDoesNotExistErrorMessage": "与此对象关联的字段在该索引模式中已不存在。", - "savedObjectsManagement.view.howToFixErrorDescription": "如果您清楚此错误的含义,请修复该错误 — 否则单击上面的删除按钮。", - "savedObjectsManagement.view.howToModifyObjectDescription": "修改对象仅适用于高级用户。对象属性未得到验证,无效的对象可能会导致错误、数据丢失或更坏的情况发生。除非熟悉该代码的人让您来这里,否则您可能不应到访此处。", - "savedObjectsManagement.view.howToModifyObjectTitle": "谨慎操作!", "savedObjectsManagement.view.indexPatternDoesNotExistErrorMessage": "与此对象关联的索引模式已不存在。", - "savedObjectsManagement.view.saveButtonAriaLabel": "保存 { title } 对象", - "savedObjectsManagement.view.saveButtonLabel": "保存 { title } 对象", "savedObjectsManagement.view.savedObjectProblemErrorMessage": "此已保存对象有问题", "savedObjectsManagement.view.savedSearchDoesNotExistErrorMessage": "与此对象关联的已保存搜索已不存在。", - "savedObjectsManagement.view.viewItemButtonLabel": "查看“{title}”", - "savedObjectsManagement.view.viewItemTitle": "查看“{title}”", "security.checkup.dismissButtonText": "关闭", "security.checkup.dontShowAgain": "不再显示", "security.checkup.insecureClusterMessage": "不要丢失一位。使用 Elastic,免费保护您的数据。", @@ -5286,9 +5272,7 @@ "visTypeTimeseries.indexPatternSelect.label": "索引模式", "visTypeTimeseries.indexPatternSelect.queryAllIndexesText": "要查询所有索引,请使用 *", "visTypeTimeseries.indexPatternSelect.switchModePopover.areaLabel": "配置索引模式选择模式", - "visTypeTimeseries.indexPatternSelect.switchModePopover.text": "索引模式可识别一个或多个您希望浏览的 Elasticsearch 索引。可用使用 Elasticsearch 索引或 Kibana 索引模式(推荐)。", "visTypeTimeseries.indexPatternSelect.switchModePopover.title": "索引模式选择模式", - "visTypeTimeseries.indexPatternSelect.switchModePopover.useKibanaIndices": "仅使用 Kibana 索引模式", "visTypeTimeseries.kbnVisTypes.metricsDescription": "对时间序列数据执行高级分析。", "visTypeTimeseries.kbnVisTypes.metricsTitle": "TSVB", "visTypeTimeseries.lastValueModeIndicator.lastBucketDate": "存储桶:{lastBucketDate}", @@ -5616,7 +5600,6 @@ "visTypeTimeseries.visEditorVisualization.changesWillBeAutomaticallyAppliedMessage": "将自动应用更改。", "visTypeTimeseries.visEditorVisualization.indexPatternMode.dismissNoticeButtonText": "关闭", "visTypeTimeseries.visEditorVisualization.indexPatternMode.link": "请查看。", - "visTypeTimeseries.visEditorVisualization.indexPatternMode.notificationMessage": "好消息!现在可以可视化 Elasticsearch 索引或 Kibana 索引模式的数据。{indexPatternModeLink}。", "visTypeTimeseries.visEditorVisualization.indexPatternMode.notificationTitle": "TSVB 现在支持索引模式", "visTypeTimeseries.visPicker.gaugeLabel": "仪表盘", "visTypeTimeseries.visPicker.metricLabel": "指标", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index d198c82366fb..c7c41ac4e817 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -39,6 +39,10 @@ jest.mock('react-router-dom', () => ({ }), })); +jest.mock('../../../lib/action_connector_api', () => ({ + loadAllActions: jest.fn().mockResolvedValue([]), +})); + jest.mock('../../../lib/capabilities', () => ({ hasAllPrivilege: jest.fn(() => true), hasSaveAlertsCapability: jest.fn(() => true), @@ -60,24 +64,22 @@ const authorizedConsumers = { }; const recoveryActionGroup: ActionGroup<'recovered'> = { id: 'recovered', name: 'Recovered' }; -describe('alert_details', () => { - // mock Api handlers +const alertType: AlertType = { + id: '.noop', + name: 'No Op', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup, + actionVariables: { context: [], state: [], params: [] }, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, + enabledInLicense: true, +}; +describe('alert_details', () => { it('renders the alert name as a title', () => { const alert = mockAlert(); - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - enabledInLicense: true, - }; - expect( shallow( @@ -87,19 +89,6 @@ describe('alert_details', () => { it('renders the alert type badge', () => { const alert = mockAlert(); - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - enabledInLicense: true, - }; - expect( shallow( @@ -118,19 +107,6 @@ describe('alert_details', () => { }, }, }); - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - enabledInLicense: true, - }; - expect( shallow( @@ -155,19 +131,6 @@ describe('alert_details', () => { ], }); - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - enabledInLicense: true, - }; - const actionTypes: ActionType[] = [ { id: '.server-log', @@ -212,18 +175,6 @@ describe('alert_details', () => { }, ], }); - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: ALERTS_FEATURE_ID, - minimumLicenseRequired: 'basic', - authorizedConsumers, - enabledInLicense: true, - }; const actionTypes: ActionType[] = [ { id: '.server-log', @@ -273,20 +224,6 @@ describe('alert_details', () => { describe('links', () => { it('links to the app that created the alert', () => { const alert = mockAlert(); - - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - expect( shallow( @@ -296,19 +233,6 @@ describe('alert_details', () => { it('links to the Edit flyout', () => { const alert = mockAlert(); - - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; const pageHeaderProps = shallow( ) @@ -316,22 +240,22 @@ describe('alert_details', () => { .props() as EuiPageHeaderProps; const rightSideItems = pageHeaderProps.rightSideItems; expect(!!rightSideItems && rightSideItems[2]!).toMatchInlineSnapshot(` - - - - - - `); + + + + + + `); }); }); }); @@ -341,20 +265,6 @@ describe('disable button', () => { const alert = mockAlert({ enabled: true, }); - - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - const enableButton = shallow( ) @@ -368,55 +278,43 @@ describe('disable button', () => { }); }); - it('should render a enable button when alert is disabled', () => { + it('should render a enable button and empty state when alert is disabled', async () => { const alert = mockAlert({ enabled: false, }); - - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - - const enableButton = shallow( + const wrapper = mountWithIntl( - ) - .find(EuiSwitch) - .find('[name="enable"]') - .first(); + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const enableButton = wrapper.find(EuiSwitch).find('[name="enable"]').first(); + const disabledEmptyPrompt = wrapper.find('[data-test-subj="disabledEmptyPrompt"]'); + const disabledEmptyPromptAction = wrapper.find('[data-test-subj="disabledEmptyPromptAction"]'); expect(enableButton.props()).toMatchObject({ checked: false, disabled: false, }); + expect(disabledEmptyPrompt.exists()).toBeTruthy(); + expect(disabledEmptyPromptAction.exists()).toBeTruthy(); + + disabledEmptyPromptAction.first().simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(mockAlertApis.enableAlert).toHaveBeenCalledTimes(1); }); - it('should enable the alert when alert is disabled and button is clicked', () => { + it('should disable the alert when alert is enabled and button is clicked', () => { const alert = mockAlert({ enabled: true, }); - - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - const disableAlert = jest.fn(); const enableButton = shallow( { expect(disableAlert).toHaveBeenCalledTimes(1); }); - it('should disable the alert when alert is enabled and button is clicked', () => { + it('should enable the alert when alert is disabled and button is clicked', () => { const alert = mockAlert({ enabled: false, }); - - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - const enableAlert = jest.fn(); const enableButton = shallow( { }, }); - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - const disableAlert = jest.fn(); const enableAlert = jest.fn(); const wrapper = mountWithIntl( @@ -565,19 +436,6 @@ describe('disable button', () => { }, }); - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - const disableAlert = jest.fn(async () => { await new Promise((resolve) => setTimeout(resolve, 6000)); }); @@ -630,27 +488,12 @@ describe('mute button', () => { enabled: true, muteAll: false, }); - - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - const enableButton = shallow( ) .find(EuiSwitch) .find('[name="mute"]') .first(); - expect(enableButton.props()).toMatchObject({ checked: false, disabled: false, @@ -662,27 +505,12 @@ describe('mute button', () => { enabled: true, muteAll: true, }); - - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - const enableButton = shallow( ) .find(EuiSwitch) .find('[name="mute"]') .first(); - expect(enableButton.props()).toMatchObject({ checked: true, disabled: false, @@ -694,20 +522,6 @@ describe('mute button', () => { enabled: true, muteAll: false, }); - - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - const muteAlert = jest.fn(); const enableButton = shallow( { .find(EuiSwitch) .find('[name="mute"]') .first(); - enableButton.simulate('click'); const handler = enableButton.prop('onChange'); expect(typeof handler).toEqual('function'); @@ -735,20 +548,6 @@ describe('mute button', () => { enabled: true, muteAll: true, }); - - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - const unmuteAlert = jest.fn(); const enableButton = shallow( { .find(EuiSwitch) .find('[name="mute"]') .first(); - enableButton.simulate('click'); const handler = enableButton.prop('onChange'); expect(typeof handler).toEqual('function'); @@ -776,27 +574,12 @@ describe('mute button', () => { enabled: false, muteAll: false, }); - - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - const enableButton = shallow( ) .find(EuiSwitch) .find('[name="mute"]') .first(); - expect(enableButton.props()).toMatchObject({ checked: false, disabled: true, @@ -843,20 +626,6 @@ describe('edit button', () => { }, ], }); - - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: 'alerting', - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - const pageHeaderProps = shallow( { .props() as EuiPageHeaderProps; const rightSideItems = pageHeaderProps.rightSideItems; expect(!!rightSideItems && rightSideItems[2]!).toMatchInlineSnapshot(` - - - - - - `); + + + + + + `); }); it('should not render an edit button when alert editable but actions arent', () => { const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); - hasExecuteActionsCapability.mockReturnValue(false); + hasExecuteActionsCapability.mockReturnValueOnce(false); const alert = mockAlert({ enabled: true, muteAll: false, @@ -902,20 +671,6 @@ describe('edit button', () => { }, ], }); - - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: 'alerting', - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - expect( shallow( { it('should render an edit button when alert editable but actions arent when there are no actions on the alert', async () => { const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); - hasExecuteActionsCapability.mockReturnValue(false); + hasExecuteActionsCapability.mockReturnValueOnce(false); const alert = mockAlert({ enabled: true, muteAll: false, actions: [], }); - - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - producer: 'alerting', - authorizedConsumers, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }; - const pageHeaderProps = shallow( { .props() as EuiPageHeaderProps; const rightSideItems = pageHeaderProps.rightSideItems; expect(!!rightSideItems && rightSideItems[2]!).toMatchInlineSnapshot(` - - - - - - `); + + + + + + `); }); }); -describe('refresh button', () => { - it('should call requestRefresh when clicked', () => { - const alert = mockAlert(); - const alertType: AlertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup, - actionVariables: { context: [], state: [], params: [] }, - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - producer: ALERTS_FEATURE_ID, - authorizedConsumers, +describe('broken connector indicator', () => { + const actionTypes: ActionType[] = [ + { + id: '.server-log', + name: 'Server log', + enabled: true, + enabledInConfig: true, enabledInLicense: true, - }; + minimumLicenseRequired: 'basic', + }, + ]; + ruleTypeRegistry.has.mockReturnValue(true); + const alertTypeR: AlertTypeModel = { + id: 'my-alert-type', + iconClass: 'test', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + alertParamsExpression: jest.fn(), + requiresAppContext: false, + }; + ruleTypeRegistry.get.mockReturnValue(alertTypeR); + useKibanaMock().services.ruleTypeRegistry = ruleTypeRegistry; + const { loadAllActions } = jest.requireMock('../../../lib/action_connector_api'); + loadAllActions.mockResolvedValue([ + { + secrets: {}, + isMissingSecrets: false, + id: 'connector-id-1', + actionTypeId: '.server-log', + name: 'Test connector', + config: {}, + isPreconfigured: false, + }, + { + secrets: {}, + isMissingSecrets: false, + id: 'connector-id-2', + actionTypeId: '.server-log', + name: 'Test connector 2', + config: {}, + isPreconfigured: false, + }, + ]); + it('should not render broken connector indicator or warning if all rule actions connectors exist', async () => { + const alert = mockAlert({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: 'connector-id-1', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-2', + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + const wrapper = mountWithIntl( + + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const brokenConnectorIndicator = wrapper + .find('[data-test-subj="actionWithBrokenConnector"]') + .first(); + const brokenConnectorWarningBanner = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') + .first(); + expect(brokenConnectorIndicator.exists()).toBeFalsy(); + expect(brokenConnectorWarningBanner.exists()).toBeFalsy(); + }); + + it('should render broken connector indicator and warning if any rule actions connector does not exist', async () => { + const alert = mockAlert({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: 'connector-id-1', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-2', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-doesnt-exist', + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + const wrapper = mountWithIntl( + + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const brokenConnectorIndicator = wrapper + .find('[data-test-subj="actionWithBrokenConnector"]') + .first(); + const brokenConnectorWarningBanner = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') + .first(); + const brokenConnectorWarningBannerAction = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBannerEdit"]') + .first(); + expect(brokenConnectorIndicator.exists()).toBeTruthy(); + expect(brokenConnectorWarningBanner.exists()).toBeTruthy(); + expect(brokenConnectorWarningBannerAction.exists()).toBeTruthy(); + }); + + it('should render broken connector indicator and warning with no edit button if any rule actions connector does not exist and user has no edit access', async () => { + const alert = mockAlert({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: 'connector-id-1', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-2', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-doesnt-exist', + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); + hasExecuteActionsCapability.mockReturnValue(false); + const wrapper = mountWithIntl( + + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const brokenConnectorIndicator = wrapper + .find('[data-test-subj="actionWithBrokenConnector"]') + .first(); + const brokenConnectorWarningBanner = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') + .first(); + const brokenConnectorWarningBannerAction = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBannerEdit"]') + .first(); + expect(brokenConnectorIndicator.exists()).toBeTruthy(); + expect(brokenConnectorWarningBanner.exists()).toBeTruthy(); + expect(brokenConnectorWarningBannerAction.exists()).toBeFalsy(); + }); +}); + +describe('refresh button', () => { + it('should call requestRefresh when clicked', async () => { + const alert = mockAlert(); const requestRefresh = jest.fn(); const wrapper = mountWithIntl( { /> ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); const refreshButton = wrapper.find('[data-test-subj="refreshAlertsButton"]').first(); expect(refreshButton.exists()).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index 2558993a13fe..2b13bdf613d9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -22,13 +22,16 @@ import { EuiButtonEmpty, EuiButton, EuiLoadingSpinner, + EuiIconTip, + EuiEmptyPrompt, + EuiPageTemplate, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { AlertExecutionStatusErrorReasons } from '../../../../../../alerting/common'; import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities'; import { getAlertingSectionBreadcrumb, getAlertDetailsBreadcrumb } from '../../../lib/breadcrumb'; import { getCurrentDocTitle } from '../../../lib/doc_title'; -import { Alert, AlertType, ActionType } from '../../../../types'; +import { Alert, AlertType, ActionType, ActionConnector } from '../../../../types'; import { ComponentOpts as BulkOperationsComponentOpts, withBulkAlertOperations, @@ -40,6 +43,7 @@ import { routeToRuleDetails } from '../../../constants'; import { alertsErrorReasonTranslationsMapping } from '../../alerts_list/translations'; import { useKibana } from '../../../../common/lib/kibana'; import { alertReducer } from '../../alert_form/alert_reducer'; +import { loadAllActions as loadConnectors } from '../../../lib/action_connector_api'; export type AlertDetailsProps = { alert: Alert; @@ -72,6 +76,9 @@ export const AlertDetails: React.FunctionComponent = ({ dispatch({ command: { type: 'setAlert' }, payload: { key: 'alert', value } }); }; + const [hasActionsWithBrokenConnector, setHasActionsWithBrokenConnector] = + useState(false); + // Set breadcrumb and page title useEffect(() => { setBreadcrumbs([ @@ -82,6 +89,28 @@ export const AlertDetails: React.FunctionComponent = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Determine if any attached action has an issue with its connector + useEffect(() => { + (async () => { + let loadedConnectors: ActionConnector[] = []; + try { + loadedConnectors = await loadConnectors({ http }); + } catch (err) { + loadedConnectors = []; + } + + if (loadedConnectors.length > 0) { + const hasActionWithBrokenConnector = alert.actions.some( + (action) => !loadedConnectors.find((connector) => connector.id === action.id) + ); + if (setHasActionsWithBrokenConnector) { + setHasActionsWithBrokenConnector(hasActionWithBrokenConnector); + } + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const canExecuteActions = hasExecuteActionsCapability(capabilities); const canSaveAlert = hasAllPrivilege(alert, alertType) && @@ -197,13 +226,27 @@ export const AlertDetails: React.FunctionComponent = ({ {uniqueActions && uniqueActions.length ? ( <> -

- {' '} + {hasActionsWithBrokenConnector && ( + -

+ )}
+ {uniqueActions.map((action, index) => ( @@ -358,6 +401,42 @@ export const AlertDetails: React.FunctionComponent = ({ ) : null} + {hasActionsWithBrokenConnector && ( + + + + + {hasEditButton && ( + + + setEditFlyoutVisibility(true)} + > + + + + + )} + + + + )} {alert.enabled ? ( @@ -370,23 +449,46 @@ export const AlertDetails: React.FunctionComponent = ({ ) : ( <> - + + + } - )} - color="warning" - iconType="help" - > -

- -

-
+ body={ + <> +

+ +

+ + } + actions={[ + { + setIsEnabledUpdating(true); + setIsEnabled(true); + await enableAlert(alert); + requestRefresh(); + setIsEnabledUpdating(false); + }} + > + Enable + , + ]} + /> + )}
diff --git a/x-pack/test/api_integration/apis/security_solution/timeline_migrations.ts b/x-pack/test/api_integration/apis/security_solution/timeline_migrations.ts index 9863ebb7ba64..1bfefe04239e 100644 --- a/x-pack/test/api_integration/apis/security_solution/timeline_migrations.ts +++ b/x-pack/test/api_integration/apis/security_solution/timeline_migrations.ts @@ -6,18 +6,28 @@ */ import expect from '@kbn/expect'; -import { SavedTimeline } from '../../../../plugins/security_solution/common/types/timeline'; -import { SavedNote } from '../../../../plugins/security_solution/common/types/timeline/note'; +import { + noteSavedObjectType, + pinnedEventSavedObjectType, + timelineSavedObjectType, +} from '../../../../plugins/security_solution/server/lib/timeline/saved_object_mappings'; +import { TimelineWithoutExternalRefs } from '../../../../plugins/security_solution/common/types/timeline'; +import { NoteWithoutExternalRefs } from '../../../../plugins/security_solution/common/types/timeline/note'; import { FtrProviderContext } from '../../ftr_provider_context'; import { getSavedObjectFromES } from './utils'; +import { PinnedEventWithoutExternalRefs } from '../../../../plugins/security_solution/common/types/timeline/pinned_event'; interface TimelineWithoutSavedQueryId { - 'siem-ui-timeline': Omit; + [timelineSavedObjectType]: TimelineWithoutExternalRefs; } interface NoteWithoutTimelineId { - 'siem-ui-timeline-note': Omit; + [noteSavedObjectType]: NoteWithoutExternalRefs; +} + +interface PinnedEventWithoutTimelineId { + [pinnedEventSavedObjectType]: PinnedEventWithoutExternalRefs; } export default function ({ getService }: FtrProviderContext) { @@ -28,23 +38,22 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); describe('7.16.0', () => { - describe('notes timelineId', () => { - before(async () => { - await esArchiver.load( - 'x-pack/test/functional/es_archives/security_solution/timelines/7.15.0' - ); - }); - - after(async () => { - await esArchiver.unload( - 'x-pack/test/functional/es_archives/security_solution/timelines/7.15.0' - ); - }); + before(async () => { + await esArchiver.load( + 'x-pack/test/functional/es_archives/security_solution/timelines/7.15.0' + ); + }); + after(async () => { + await esArchiver.unload( + 'x-pack/test/functional/es_archives/security_solution/timelines/7.15.0' + ); + }); + describe('notes timelineId', () => { it('removes the timelineId in the saved object', async () => { const timelines = await getSavedObjectFromES( es, - 'siem-ui-timeline-note', + noteSavedObjectType, { ids: { values: [ @@ -55,13 +64,13 @@ export default function ({ getService }: FtrProviderContext) { } ); - expect( - timelines.body.hits.hits[0]._source?.['siem-ui-timeline-note'] - ).to.not.have.property('timelineId'); + expect(timelines.body.hits.hits[0]._source?.[noteSavedObjectType]).to.not.have.property( + 'timelineId' + ); - expect( - timelines.body.hits.hits[1]._source?.['siem-ui-timeline-note'] - ).to.not.have.property('timelineId'); + expect(timelines.body.hits.hits[1]._source?.[noteSavedObjectType]).to.not.have.property( + 'timelineId' + ); }); it('preserves the eventId in the saved object after migration', async () => { @@ -87,30 +96,18 @@ export default function ({ getService }: FtrProviderContext) { }); describe('savedQueryId', () => { - before(async () => { - await esArchiver.load( - 'x-pack/test/functional/es_archives/security_solution/timelines/7.15.0' - ); - }); - - after(async () => { - await esArchiver.unload( - 'x-pack/test/functional/es_archives/security_solution/timelines/7.15.0' - ); - }); - it('removes the savedQueryId', async () => { const timelines = await getSavedObjectFromES( es, - 'siem-ui-timeline', + timelineSavedObjectType, { ids: { values: ['siem-ui-timeline:8dc70950-1012-11ec-9ad3-2d7c6600c0f7'] }, } ); - expect(timelines.body.hits.hits[0]._source?.['siem-ui-timeline']).to.not.have.property( - 'savedQueryId' - ); + expect( + timelines.body.hits.hits[0]._source?.[timelineSavedObjectType] + ).to.not.have.property('savedQueryId'); }); it('preserves the title in the saved object after migration', async () => { @@ -129,6 +126,57 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.data.getOneTimeline.savedQueryId).to.be("It's me"); }); }); + + describe('pinned events timelineId', () => { + it('removes the timelineId in the saved object', async () => { + const timelines = await getSavedObjectFromES( + es, + pinnedEventSavedObjectType, + { + ids: { + values: [ + 'siem-ui-timeline-pinned-event:7a9a5540-126e-11ec-83d2-db1096c73738', + 'siem-ui-timeline-pinned-event:98d919b0-126e-11ec-83d2-db1096c73738', + ], + }, + } + ); + + expect( + timelines.body.hits.hits[0]._source?.[pinnedEventSavedObjectType] + ).to.not.have.property('timelineId'); + + expect( + timelines.body.hits.hits[1]._source?.[pinnedEventSavedObjectType] + ).to.not.have.property('timelineId'); + }); + + it('preserves the eventId in the saved object after migration', async () => { + const resp = await supertest + .get('/api/timeline') + .query({ id: '6484cc90-126e-11ec-83d2-db1096c73738' }); + + expect(resp.body.data.getOneTimeline.pinnedEventsSaveObject[0].eventId).to.be( + 'DNo00XsBEVtyvU-8LGNe' + ); + expect(resp.body.data.getOneTimeline.pinnedEventsSaveObject[1].eventId).to.be( + 'Edo00XsBEVtyvU-8LGNe' + ); + }); + + it('returns the timelineId in the response', async () => { + const resp = await supertest + .get('/api/timeline') + .query({ id: '6484cc90-126e-11ec-83d2-db1096c73738' }); + + expect(resp.body.data.getOneTimeline.pinnedEventsSaveObject[0].timelineId).to.be( + '6484cc90-126e-11ec-83d2-db1096c73738' + ); + expect(resp.body.data.getOneTimeline.pinnedEventsSaveObject[1].timelineId).to.be( + '6484cc90-126e-11ec-83d2-db1096c73738' + ); + }); + }); }); }); } diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/basic/index.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/basic/index.ts index 90fbb1063743..3fb7d7a29af3 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/basic/index.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/basic/index.ts @@ -12,7 +12,7 @@ import { createSpacesAndUsers, deleteSpacesAndUsers } from '../../../common/lib/ export default ({ loadTestFile, getService }: FtrProviderContext): void => { describe('cases security and spaces enabled: basic', function () { // Fastest ciGroup for the moment. - this.tags('ciGroup5'); + this.tags('ciGroup13'); before(async () => { await createSpacesAndUsers(getService); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts index 964e9135aba7..68f0ba43d889 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts @@ -85,7 +85,7 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - it('should create a user action when creating a case', async () => { + it('should create a user action when deleting a case', async () => { const postedCase = await createCase(supertest, getPostCaseRequest()); await deleteCases({ supertest, caseIDs: [postedCase.id] }); const userActions = await getCaseUserActions({ supertest, caseID: postedCase.id }); @@ -106,6 +106,8 @@ export default ({ getService }: FtrProviderContext): void => { action_by: defaultUser, old_value: null, new_value: null, + new_val_connector_id: null, + old_val_connector_id: null, case_id: `${postedCase.id}`, comment_id: null, sub_case_id: '', diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/import_export.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/import_export.ts index f149f4b5d13a..dd1c2e810f15 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/import_export.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/import_export.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { join } from 'path'; import { SavedObject } from 'kibana/server'; +import supertest from 'supertest'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; import { deleteAllCaseItems, @@ -29,15 +30,16 @@ import { CaseUserActionAttributes, CASE_COMMENT_SAVED_OBJECT, CasePostRequest, + CaseUserActionResponse, } from '../../../../../../plugins/cases/common'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); + const supertestService = getService('supertest'); const es = getService('es'); describe('import and export cases', () => { - const actionsRemover = new ActionsRemover(supertest); + const actionsRemover = new ActionsRemover(supertestService); afterEach(async () => { await deleteAllCaseItems(es); @@ -46,14 +48,14 @@ export default ({ getService }: FtrProviderContext): void => { it('exports a case with its associated user actions and comments', async () => { const caseRequest = getPostCaseRequest(); - const postedCase = await createCase(supertest, caseRequest); + const postedCase = await createCase(supertestService, caseRequest); await createComment({ - supertest, + supertest: supertestService, caseId: postedCase.id, params: postCommentUserReq, }); - const { text } = await supertest + const { text } = await supertestService .post(`/api/saved_objects/_export`) .send({ type: ['cases'], @@ -72,7 +74,7 @@ export default ({ getService }: FtrProviderContext): void => { }); it('imports a case with a comment and user actions', async () => { - await supertest + await supertestService .post('/api/saved_objects/_import') .query({ overwrite: true }) .attach( @@ -85,12 +87,12 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(200); - const findResponse = await findCases({ supertest, query: {} }); + const findResponse = await findCases({ supertest: supertestService, query: {} }); expect(findResponse.total).to.eql(1); expect(findResponse.cases[0].title).to.eql('A case to export'); expect(findResponse.cases[0].description).to.eql('a description'); - const { body: commentsResponse }: { body: CommentsResponse } = await supertest + const { body: commentsResponse }: { body: CommentsResponse } = await supertestService .get(`${CASES_URL}/${findResponse.cases[0].id}/comments/_find`) .send() .expect(200); @@ -99,7 +101,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(comment.comment).to.eql('A comment for my case'); const userActions = await getCaseUserActions({ - supertest, + supertest: supertestService, caseID: findResponse.cases[0].id, }); @@ -118,7 +120,7 @@ export default ({ getService }: FtrProviderContext): void => { }); it('imports a case with a connector', async () => { - await supertest + await supertestService .post('/api/saved_objects/_import') .query({ overwrite: true }) .attach( @@ -133,35 +135,56 @@ export default ({ getService }: FtrProviderContext): void => { actionsRemover.add('default', '1cd34740-06ad-11ec-babc-0b08808e8e01', 'action', 'actions'); - const findResponse = await findCases({ supertest, query: {} }); - expect(findResponse.total).to.eql(1); - expect(findResponse.cases[0].title).to.eql('A case with a connector'); - expect(findResponse.cases[0].description).to.eql('super description'); + await expectImportToHaveOneCase(supertestService); const userActions = await getCaseUserActions({ - supertest, - caseID: findResponse.cases[0].id, + supertest: supertestService, + caseID: '2e85c3f0-06ad-11ec-babc-0b08808e8e01', }); - expect(userActions).to.have.length(3); - expect(userActions[0].action).to.eql('create'); - expect(includesAllCreateCaseActionFields(userActions[0].action_field)).to.eql(true); - expect(userActions[1].action).to.eql('push-to-service'); - expect(userActions[1].action_field).to.eql(['pushed']); - expect(userActions[1].old_value).to.eql(null); + expectImportToHaveCreateCaseUserAction(userActions[0]); + expectImportToHavePushUserAction(userActions[1]); + expectImportToHaveUpdateConnector(userActions[2]); + }); + }); +}; - const parsedPushNewValue = JSON.parse(userActions[1].new_value!); - expect(parsedPushNewValue.connector_name).to.eql('A jira connector'); - expect(parsedPushNewValue.connector_id).to.eql('1cd34740-06ad-11ec-babc-0b08808e8e01'); +const expectImportToHaveOneCase = async (supertestService: supertest.SuperTest) => { + const findResponse = await findCases({ supertest: supertestService, query: {} }); + expect(findResponse.total).to.eql(1); + expect(findResponse.cases[0].title).to.eql('A case with a connector'); + expect(findResponse.cases[0].description).to.eql('super description'); +}; - expect(userActions[2].action).to.eql('update'); - expect(userActions[2].action_field).to.eql(['connector']); +const expectImportToHaveCreateCaseUserAction = (userAction: CaseUserActionResponse) => { + expect(userAction.action).to.eql('create'); + expect(includesAllCreateCaseActionFields(userAction.action_field)).to.eql(true); +}; - const parsedUpdateNewValue = JSON.parse(userActions[2].new_value!); - expect(parsedUpdateNewValue.id).to.eql('none'); - }); - }); +const expectImportToHavePushUserAction = (userAction: CaseUserActionResponse) => { + expect(userAction.action).to.eql('push-to-service'); + expect(userAction.action_field).to.eql(['pushed']); + expect(userAction.old_value).to.eql(null); + + const parsedPushNewValue = JSON.parse(userAction.new_value!); + expect(parsedPushNewValue.connector_name).to.eql('A jira connector'); + expect(parsedPushNewValue).to.not.have.property('connector_id'); + expect(userAction.new_val_connector_id).to.eql('1cd34740-06ad-11ec-babc-0b08808e8e01'); +}; + +const expectImportToHaveUpdateConnector = (userAction: CaseUserActionResponse) => { + expect(userAction.action).to.eql('update'); + expect(userAction.action_field).to.eql(['connector']); + + const parsedUpdateNewValue = JSON.parse(userAction.new_value!); + expect(parsedUpdateNewValue).to.not.have.property('id'); + // the new val connector id is null because it is the none connector + expect(userAction.new_val_connector_id).to.eql(null); + + const parsedUpdateOldValue = JSON.parse(userAction.old_value!); + expect(parsedUpdateOldValue).to.not.have.property('id'); + expect(userAction.old_val_connector_id).to.eql('1cd34740-06ad-11ec-babc-0b08808e8e01'); }; const ndjsonToObject = (input: string) => { diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts index 63b2f2e9b90e..d7c506a6b69d 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts @@ -126,6 +126,8 @@ export default ({ getService }: FtrProviderContext): void => { action: 'update', action_by: defaultUser, new_value: CaseStatuses.closed, + new_val_connector_id: null, + old_val_connector_id: null, old_value: CaseStatuses.open, case_id: `${postedCase.id}`, comment_id: null, @@ -165,6 +167,8 @@ export default ({ getService }: FtrProviderContext): void => { action_by: defaultUser, new_value: CaseStatuses['in-progress'], old_value: CaseStatuses.open, + old_val_connector_id: null, + new_val_connector_id: null, case_id: `${postedCase.id}`, comment_id: null, sub_case_id: '', diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts index 96709ee7c309..13408c5d309d 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts @@ -114,6 +114,8 @@ export default ({ getService }: FtrProviderContext): void => { const { new_value, ...rest } = creationUserAction as CaseUserActionResponse; const parsedNewValue = JSON.parse(new_value!); + const { id: connectorId, ...restCaseConnector } = postedCase.connector; + expect(rest).to.eql({ action_field: [ 'description', @@ -127,6 +129,9 @@ export default ({ getService }: FtrProviderContext): void => { action: 'create', action_by: defaultUser, old_value: null, + old_val_connector_id: null, + // the connector id will be null here because it the connector is none + new_val_connector_id: null, case_id: `${postedCase.id}`, comment_id: null, sub_case_id: '', @@ -138,7 +143,7 @@ export default ({ getService }: FtrProviderContext): void => { description: postedCase.description, title: postedCase.title, tags: postedCase.tags, - connector: postedCase.connector, + connector: restCaseConnector, settings: postedCase.settings, owner: postedCase.owner, }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/post_comment.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/post_comment.ts index f4c31c052cdd..942293437b03 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/post_comment.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/post_comment.ts @@ -148,7 +148,9 @@ export default ({ getService }: FtrProviderContext): void => { action: 'create', action_by: defaultUser, new_value: `{"comment":"${postCommentUserReq.comment}","type":"${postCommentUserReq.type}","owner":"securitySolutionFixture"}`, + new_val_connector_id: null, old_value: null, + old_val_connector_id: null, case_id: `${postedCase.id}`, comment_id: `${patchedCase.comments![0].id}`, sub_case_id: '', diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts index 35ebb1a4bf7b..4cae10510d28 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts @@ -48,6 +48,15 @@ export default ({ getService }: FtrProviderContext): void => { }); it(`on new case, user action: 'create' should be called with actionFields: ['description', 'status', 'tags', 'title', 'connector', 'settings, owner]`, async () => { + const { id: connectorId, ...restConnector } = userActionPostResp.connector; + + const userActionNewValueNoId = { + ...userActionPostResp, + connector: { + ...restConnector, + }, + }; + const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') @@ -73,7 +82,10 @@ export default ({ getService }: FtrProviderContext): void => { ]); expect(body[0].action).to.eql('create'); expect(body[0].old_value).to.eql(null); - expect(JSON.parse(body[0].new_value)).to.eql(userActionPostResp); + expect(body[0].old_val_connector_id).to.eql(null); + // this will be null because it is for the none connector + expect(body[0].new_val_connector_id).to.eql(null); + expect(JSON.parse(body[0].new_value)).to.eql(userActionNewValueNoId); }); it(`on close case, user action: 'update' should be called with actionFields: ['status']`, async () => { @@ -147,18 +159,19 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.length).to.eql(2); expect(body[1].action_field).to.eql(['connector']); expect(body[1].action).to.eql('update'); + // this is null because it is the none connector + expect(body[1].old_val_connector_id).to.eql(null); expect(JSON.parse(body[1].old_value)).to.eql({ - id: 'none', name: 'none', type: '.none', fields: null, }); expect(JSON.parse(body[1].new_value)).to.eql({ - id: '123', name: 'Connector', type: '.jira', fields: { issueType: 'Task', priority: 'High', parent: null }, }); + expect(body[1].new_val_connector_id).to.eql('123'); }); it(`on update tags, user action: 'add' and 'delete' should be called with actionFields: ['tags']`, async () => { diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts index b4c2dca47bf5..f9e66880c523 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts @@ -12,6 +12,10 @@ import { SECURITY_SOLUTION_OWNER, } from '../../../../../../plugins/cases/common/constants'; import { getCaseUserActions } from '../../../../common/lib/utils'; +import { + CaseUserActionResponse, + CaseUserActionsResponse, +} from '../../../../../../plugins/cases/common'; // eslint-disable-next-line import/no-default-export export default function createGetTests({ getService }: FtrProviderContext) { @@ -41,14 +45,18 @@ export default function createGetTests({ getService }: FtrProviderContext) { expect(connectorUserAction.action_field.length).eql(1); expect(connectorUserAction.action_field[0]).eql('connector'); + expect(connectorUserAction.old_val_connector_id).to.eql( + 'c1900ac0-017f-11eb-93f8-d161651bf509' + ); expect(oldValue).to.eql({ - id: 'c1900ac0-017f-11eb-93f8-d161651bf509', name: 'none', type: '.none', fields: null, }); + expect(connectorUserAction.new_val_connector_id).to.eql( + 'b1900ac0-017f-11eb-93f8-d161651bf509' + ); expect(newValue).to.eql({ - id: 'b1900ac0-017f-11eb-93f8-d161651bf509', name: 'none', type: '.none', fields: null, @@ -77,5 +85,142 @@ export default function createGetTests({ getService }: FtrProviderContext) { } }); }); + + describe('7.13 connector id extraction', () => { + let userActions: CaseUserActionsResponse; + + before(async () => { + await esArchiver.load( + 'x-pack/test/functional/es_archives/cases/migrations/7.13_user_actions' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/functional/es_archives/cases/migrations/7.13_user_actions' + ); + }); + + describe('none connector case', () => { + it('removes the connector id from the case create user action and sets the ids to null', async () => { + userActions = await getCaseUserActions({ + supertest, + caseID: 'aa8ac630-005e-11ec-91f1-6daf2ab59fb5', + }); + + const userAction = getUserActionById( + userActions, + 'ab43b5f0-005e-11ec-91f1-6daf2ab59fb5' + )!; + + const newValDecoded = JSON.parse(userAction.new_value!); + expect(newValDecoded.description).to.be('a description'); + expect(newValDecoded.title).to.be('a case'); + expect(newValDecoded.connector).not.have.property('id'); + // the connector id should be none so it should be removed + expect(userAction.new_val_connector_id).to.be(null); + expect(userAction.old_val_connector_id).to.be(null); + }); + + it('sets the connector ids to null for a create user action with null new and old values', async () => { + const userAction = getUserActionById( + userActions, + 'b3094de0-005e-11ec-91f1-6daf2ab59fb5' + )!; + + expect(userAction.new_val_connector_id).to.be(null); + expect(userAction.old_val_connector_id).to.be(null); + }); + }); + + describe('case with many user actions', () => { + before(async () => { + userActions = await getCaseUserActions({ + supertest, + caseID: 'e6fa9370-005e-11ec-91f1-6daf2ab59fb5', + }); + }); + + it('removes the connector id field for a created case user action', async () => { + const userAction = getUserActionById( + userActions, + 'e7882d70-005e-11ec-91f1-6daf2ab59fb5' + )!; + + const newValDecoded = JSON.parse(userAction.new_value!); + expect(newValDecoded.description).to.be('a description'); + expect(newValDecoded.title).to.be('a case'); + + expect(newValDecoded.connector).to.not.have.property('id'); + expect(userAction.new_val_connector_id).to.be('d92243b0-005e-11ec-91f1-6daf2ab59fb5'); + expect(userAction.old_val_connector_id).to.be(null); + }); + + it('removes the connector id from the external service new value', async () => { + const userAction = getUserActionById( + userActions, + 'e9471b80-005e-11ec-91f1-6daf2ab59fb5' + )!; + + const newValDecoded = JSON.parse(userAction.new_value!); + expect(newValDecoded.connector_name).to.be('a jira connector'); + expect(newValDecoded).to.not.have.property('connector_id'); + expect(userAction.new_val_connector_id).to.be('d92243b0-005e-11ec-91f1-6daf2ab59fb5'); + expect(userAction.old_val_connector_id).to.be(null); + }); + + it('sets the connector ids to null for a comment user action', async () => { + const userAction = getUserActionById( + userActions, + 'efe9de50-005e-11ec-91f1-6daf2ab59fb5' + )!; + + const newValDecoded = JSON.parse(userAction.new_value!); + expect(newValDecoded.comment).to.be('a comment'); + expect(userAction.new_val_connector_id).to.be(null); + expect(userAction.old_val_connector_id).to.be(null); + }); + + it('removes the connector id for an update connector action', async () => { + const userAction = getUserActionById( + userActions, + '16cd9e30-005f-11ec-91f1-6daf2ab59fb5' + )!; + + const newValDecoded = JSON.parse(userAction.new_value!); + const oldValDecoded = JSON.parse(userAction.old_value!); + + expect(newValDecoded.name).to.be('a different jira connector'); + expect(oldValDecoded.name).to.be('a jira connector'); + + expect(newValDecoded).to.not.have.property('id'); + expect(oldValDecoded).to.not.have.property('id'); + expect(userAction.new_val_connector_id).to.be('0a572860-005f-11ec-91f1-6daf2ab59fb5'); + expect(userAction.old_val_connector_id).to.be('d92243b0-005e-11ec-91f1-6daf2ab59fb5'); + }); + + it('removes the connector id from the external service new value for second push', async () => { + const userAction = getUserActionById( + userActions, + '1ea33bb0-005f-11ec-91f1-6daf2ab59fb5' + )!; + + const newValDecoded = JSON.parse(userAction.new_value!); + + expect(newValDecoded.connector_name).to.be('a different jira connector'); + + expect(newValDecoded).to.not.have.property('connector_id'); + expect(userAction.new_val_connector_id).to.be('0a572860-005f-11ec-91f1-6daf2ab59fb5'); + expect(userAction.old_val_connector_id).to.be(null); + }); + }); + }); }); } + +function getUserActionById( + userActions: CaseUserActionsResponse, + id: string +): CaseUserActionResponse | undefined { + return userActions.find((userAction) => userAction.action_id === id); +} diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts index 94fe494fc7cc..0ea66d35b63b 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts @@ -275,6 +275,8 @@ export default ({ getService }: FtrProviderContext): void => { action: 'push-to-service', action_by: defaultUser, old_value: null, + old_val_connector_id: null, + new_val_connector_id: connector.id, case_id: `${postedCase.id}`, comment_id: null, sub_case_id: '', @@ -284,7 +286,6 @@ export default ({ getService }: FtrProviderContext): void => { expect(parsedNewValue).to.eql({ pushed_at: pushedCase.external_service!.pushed_at, pushed_by: defaultUser, - connector_id: connector.id, connector_name: connector.name, external_id: '123', external_title: 'INC01', diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts index 79af6bb279a3..255a2a4ce28b 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts @@ -108,8 +108,10 @@ export default ({ getService }: FtrProviderContext): void => { expect(body[1].action_field).to.eql(['pushed']); expect(body[1].action).to.eql('push-to-service'); expect(body[1].old_value).to.eql(null); + expect(body[1].old_val_connector_id).to.eql(null); + expect(body[1].new_val_connector_id).to.eql(configure.connector.id); const newValue = JSON.parse(body[1].new_value); - expect(newValue.connector_id).to.eql(configure.connector.id); + expect(newValue).to.not.have.property('connector_id'); expect(newValue.pushed_by).to.eql(defaultUser); }); }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/index.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/index.ts index 26bc6a072450..cd4b062c065a 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/index.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/index.ts @@ -12,7 +12,7 @@ import { createSpacesAndUsers, deleteSpacesAndUsers } from '../../../common/lib/ export default ({ loadTestFile, getService }: FtrProviderContext): void => { describe('cases security and spaces enabled: trial', function () { // Fastest ciGroup for the moment. - this.tags('ciGroup5'); + this.tags('ciGroup13'); before(async () => { await createSpacesAndUsers(getService); diff --git a/x-pack/test/functional/apps/apm/index.ts b/x-pack/test/functional/apps/apm/index.ts index e4db5a66aa55..20c2bc264a74 100644 --- a/x-pack/test/functional/apps/apm/index.ts +++ b/x-pack/test/functional/apps/apm/index.ts @@ -9,7 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('APM specs', function () { - this.tags('ciGroup6'); + this.tags('ciGroup10'); loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./correlations')); }); diff --git a/x-pack/test/functional/apps/lens/formula.ts b/x-pack/test/functional/apps/lens/formula.ts index f645b2c64629..a8d074ad0631 100644 --- a/x-pack/test/functional/apps/lens/formula.ts +++ b/x-pack/test/functional/apps/lens/formula.ts @@ -236,6 +236,38 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); }); + it('should keep the formula if the user does not fully transition to a static value', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + await PageObjects.lens.createLayer('threshold'); + + await PageObjects.lens.configureDimension( + { + dimension: 'lnsXY_yThresholdLeftPanel > lns-dimensionTrigger', + operation: 'formula', + formula: `count()`, + keepOpen: true, + }, + 1 + ); + + await PageObjects.lens.switchToStaticValue(); + await PageObjects.lens.closeDimensionEditor(); + await PageObjects.common.sleep(1000); + + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_yThresholdLeftPanel', 0)).to.eql( + 'count()' + ); + }); + it('should allow numeric only formulas', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 09bbda595d55..50dbe05df166 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -25,10 +25,14 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await esArchiver.unload('x-pack/test/functional/es_archives/lens/basic'); }); + describe('', function () { + this.tags(['ciGroup3', 'skipFirefox']); + loadTestFile(require.resolve('./smokescreen')); + }); + describe('', function () { this.tags(['ciGroup4', 'skipFirefox']); - loadTestFile(require.resolve('./smokescreen')); loadTestFile(require.resolve('./add_to_dashboard')); loadTestFile(require.resolve('./table')); loadTestFile(require.resolve('./runtime_fields')); @@ -43,6 +47,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./lens_tagging')); loadTestFile(require.resolve('./formula')); loadTestFile(require.resolve('./heatmap')); + loadTestFile(require.resolve('./thresholds')); loadTestFile(require.resolve('./inspector')); // has to be last one in the suite because it overrides saved objects diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 6d9360ac32b4..ff5bae8aa7e6 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -180,6 +180,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); }); + it('should not show static value tab for data layers', async () => { + await PageObjects.lens.openDimensionEditor('lnsXY_yDimensionPanel > lns-dimensionTrigger'); + // Quick functions and Formula tabs should be visible + expect(await testSubjects.exists('lens-dimensionTabs-quickFunctions')).to.eql(true); + expect(await testSubjects.exists('lens-dimensionTabs-formula')).to.eql(true); + // Static value tab should not be visible + expect(await testSubjects.exists('lens-dimensionTabs-static_value')).to.eql(false); + + await PageObjects.lens.closeDimensionEditor(); + }); + it('should be able to add very long labels and still be able to remove a dimension', async () => { await PageObjects.lens.openDimensionEditor('lnsXY_yDimensionPanel > lns-dimensionTrigger'); const longLabel = diff --git a/x-pack/test/functional/apps/lens/thresholds.ts b/x-pack/test/functional/apps/lens/thresholds.ts new file mode 100644 index 000000000000..bf6535acc7c8 --- /dev/null +++ b/x-pack/test/functional/apps/lens/thresholds.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); + const find = getService('find'); + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + + describe('lens thresholds tests', () => { + it('should show a disabled threshold layer button if no data dimension is defined', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + + await testSubjects.click('lnsLayerAddButton'); + await retry.waitFor('wait for layer popup to appear', async () => + testSubjects.exists(`lnsLayerAddButton-threshold`) + ); + expect( + await (await testSubjects.find(`lnsLayerAddButton-threshold`)).getAttribute('disabled') + ).to.be('true'); + }); + + it('should add a threshold layer with a static value in it', async () => { + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + await PageObjects.lens.createLayer('threshold'); + + expect((await find.allByCssSelector(`[data-test-subj^="lns-layerPanel-"]`)).length).to.eql(2); + expect( + await ( + await testSubjects.find('lnsXY_yThresholdLeftPanel > lns-dimensionTrigger') + ).getVisibleText() + ).to.eql('Static value: 4992.44'); + }); + + it('should create a dynamic threshold when dragging a field to a threshold dimension group', async () => { + await PageObjects.lens.dragFieldToDimensionTrigger( + 'bytes', + 'lnsXY_yThresholdLeftPanel > lns-empty-dimension' + ); + + expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yThresholdLeftPanel')).to.eql([ + 'Static value: 4992.44', + 'Median of bytes', + ]); + }); + }); +} diff --git a/x-pack/test/functional/apps/maps/layer_errors.js b/x-pack/test/functional/apps/maps/layer_errors.js index 64973461c107..23cbc8c51835 100644 --- a/x-pack/test/functional/apps/maps/layer_errors.js +++ b/x-pack/test/functional/apps/maps/layer_errors.js @@ -104,24 +104,6 @@ export default function ({ getPageObjects }) { }); }); - describe('KibanaRegionmapSource with missing region map configuration', () => { - const MISSING_REGION_NAME = 'nameThatDoesNotExitForKibanaRegionmapSource'; - const LAYER_NAME = 'Custom_vector_shapes'; - - it('should diplay error message in layer panel', async () => { - const errorMsg = await PageObjects.maps.getLayerErrorText(LAYER_NAME); - expect(errorMsg).to.equal( - `Unable to find map.regionmap configuration for ${MISSING_REGION_NAME}` - ); - }); - - it('should allow deletion of layer', async () => { - await PageObjects.maps.removeLayer(LAYER_NAME); - const exists = await PageObjects.maps.doesLayerExist(LAYER_NAME); - expect(exists).to.be(false); - }); - }); - describe('KibanaTilemapSource with missing map.tilemap.url configuration', () => { const LAYER_NAME = 'Custom_TMS'; diff --git a/x-pack/test/functional/apps/rollup_job/tsvb.js b/x-pack/test/functional/apps/rollup_job/tsvb.js index 3b216bfb34b9..4ef918aab09e 100644 --- a/x-pack/test/functional/apps/rollup_job/tsvb.js +++ b/x-pack/test/functional/apps/rollup_job/tsvb.js @@ -43,6 +43,7 @@ export default function ({ getService, getPageObjects }) { await kibanaServer.uiSettings.replace({ defaultIndex: 'rollup', }); + await kibanaServer.uiSettings.update({ 'metrics:allowStringIndices': true }); }); it('create rollup tsvb', async () => { @@ -110,6 +111,7 @@ export default function ({ getService, getPageObjects }) { await kibanaServer.importExport.unload( 'x-pack/test/functional/fixtures/kbn_archiver/rollup/rollup.json' ); + await kibanaServer.uiSettings.update({ 'metrics:allowStringIndices': false }); await security.testUser.restoreDefaults(); }); }); diff --git a/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts b/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts index 3d4c30c1bfdd..58f08a1dfb9f 100644 --- a/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts +++ b/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts @@ -14,6 +14,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'settings', 'security', 'error', 'savedObjects']); const kibanaServer = getService('kibanaServer'); let version: string = ''; + const find = getService('find'); describe('feature controls saved objects management', () => { before(async () => { @@ -108,12 +109,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(actual).to.be(true); }); }); - - describe('edit visualization', () => { + // From https://github.com/elastic/kibana/issues/59588 edit view became read-only json view + // test description changed from "edit" to "inspect" + // Skipping the test to allow code owners to delete or modify the test. + describe('inspect visualization', () => { before(async () => { await PageObjects.common.navigateToUrl( 'management', - 'kibana/objects/savedVisualizations/75c3e060-1e7c-11e9-8488-65449e65d0ed', + 'kibana/objects/visualization/75c3e060-1e7c-11e9-8488-65449e65d0ed', { shouldLoginIfPrompted: false, shouldUseHashForSubUrl: false, @@ -125,11 +128,13 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await testSubjects.existOrFail('savedObjectEditDelete'); }); - it('shows save button', async () => { + // no longer a feature + it.skip('shows save button', async () => { await testSubjects.existOrFail('savedObjectEditSave'); }); - it('has inputs without readonly attributes', async () => { + // no longer a feature + it.skip('has inputs without readonly attributes', async () => { const form = await testSubjects.find('savedObjectEditForm'); const inputs = await form.findAllByCssSelector('input'); expect(inputs.length).to.be.greaterThan(0); @@ -223,17 +228,30 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('edit visualization', () => { + // From https://github.com/elastic/kibana/issues/59588 edit view became read-only json view + // test description changed from "edit" to "inspect" + // Skipping the test to allow code owners to delete or modify the test. + describe('inspect visualization', () => { before(async () => { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaSavedObjects(); + const objects = await PageObjects.savedObjects.getRowTitles(); + expect(objects.includes('A Pie')).to.be(true); + await PageObjects.savedObjects.clickInspectByTitle('A Pie'); await PageObjects.common.navigateToUrl( 'management', - 'kibana/objects/savedVisualizations/75c3e060-1e7c-11e9-8488-65449e65d0ed', + 'kibana/objects/visualization/75c3e060-1e7c-11e9-8488-65449e65d0ed', { shouldLoginIfPrompted: false, shouldUseHashForSubUrl: false, } ); - await testSubjects.existOrFail('savedObjectsEdit'); + }); + + it('allows viewing the object', async () => { + const inspectContainer = await find.byClassName('kibanaCodeEditor'); + const visibleContainerText = await inspectContainer.getVisibleText(); + expect(visibleContainerText.includes('A Pie')); }); it('does not show delete button', async () => { @@ -244,7 +262,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await testSubjects.missingOrFail('savedObjectEditSave'); }); - it('has inputs with only readonly attributes', async () => { + // No longer a feature + it.skip('has inputs with only readonly attributes', async () => { const form = await testSubjects.find('savedObjectEditForm'); const inputs = await form.findAllByCssSelector('input'); expect(inputs.length).to.be.greaterThan(0); @@ -309,11 +328,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('edit visualization', () => { + describe('inspect visualization', () => { it('redirects to management home', async () => { await PageObjects.common.navigateToUrl( 'management', - 'kibana/objects/savedVisualizations/75c3e060-1e7c-11e9-8488-65449e65d0ed', + 'kibana/objects/visualization/75c3e060-1e7c-11e9-8488-65449e65d0ed', { shouldLoginIfPrompted: false, ensureCurrentUrl: false, diff --git a/x-pack/test/functional/apps/saved_objects_management/spaces_integration.ts b/x-pack/test/functional/apps/saved_objects_management/spaces_integration.ts index 28d04c1f9c54..e4da0b341dce 100644 --- a/x-pack/test/functional/apps/saved_objects_management/spaces_integration.ts +++ b/x-pack/test/functional/apps/saved_objects_management/spaces_integration.ts @@ -14,7 +14,6 @@ const getSpacePrefix = (spaceId: string) => { export default function ({ getPageObjects, getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects([ 'common', 'security', @@ -22,9 +21,15 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'spaceSelector', 'settings', ]); + const find = getService('find'); const spaceId = 'space_1'; + const textIncludesAll = (text: string, items: string[]) => { + const bools = items.map((item) => !!text.includes(item)); + return bools.every((currBool) => currBool === true); + }; + describe('spaces integration', () => { before(async () => { await esArchiver.load( @@ -54,9 +59,19 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.waitUntilUrlIncludes(getSpacePrefix(spaceId)); - expect(await testSubjects.getAttribute(`savedObjects-editField-title`, 'value')).to.eql( - 'A Pie' - ); + const inspectContainer = await find.byClassName('kibanaCodeEditor'); + const visibleContainerText = await inspectContainer.getVisibleText(); + expect( + textIncludesAll(visibleContainerText, [ + 'A Pie', + 'title', + 'id', + 'type', + 'attributes', + 'references', + ]) + ).to.be(true); + expect(visibleContainerText.includes('A Pie')); }); }); } diff --git a/x-pack/test/functional/apps/uptime/index.ts b/x-pack/test/functional/apps/uptime/index.ts index 501fec500266..2db29bfca405 100644 --- a/x-pack/test/functional/apps/uptime/index.ts +++ b/x-pack/test/functional/apps/uptime/index.ts @@ -42,7 +42,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { const uptime = getService('uptime'); describe('Uptime app', function () { - this.tags('ciGroup6'); + this.tags('ciGroup10'); beforeEach('delete settings', async () => { await deleteUptimeSettingsObject(server); diff --git a/x-pack/test/functional/apps/uptime/synthetics_integration.ts b/x-pack/test/functional/apps/uptime/synthetics_integration.ts index 1fe13227d254..e403c4d25097 100644 --- a/x-pack/test/functional/apps/uptime/synthetics_integration.ts +++ b/x-pack/test/functional/apps/uptime/synthetics_integration.ts @@ -130,7 +130,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); describe('When on the Synthetics Integration Policy Create Page', function () { - this.tags(['ciGroup6']); + this.tags(['ciGroup10']); const basicConfig = { name: monitorName, apmServiceName: 'Sample APM Service', diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 95a962388cdd..daa3ef3872cf 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -32,7 +32,6 @@ export default async function ({ readConfigFile }) { resolve(__dirname, './apps/discover'), resolve(__dirname, './apps/security'), resolve(__dirname, './apps/spaces'), - resolve(__dirname, './apps/lens'), resolve(__dirname, './apps/logstash'), resolve(__dirname, './apps/grok_debugger'), resolve(__dirname, './apps/infra'), @@ -58,6 +57,7 @@ export default async function ({ readConfigFile }) { resolve(__dirname, './apps/reporting_management'), resolve(__dirname, './apps/management'), resolve(__dirname, './apps/reporting'), + resolve(__dirname, './apps/lens'), // smokescreen tests cause flakiness in other tests // This license_management file must be last because it is destructive. resolve(__dirname, './apps/license_management'), diff --git a/x-pack/test/functional/es_archives/cases/migrations/7.13_user_actions/data.json.gz b/x-pack/test/functional/es_archives/cases/migrations/7.13_user_actions/data.json.gz new file mode 100644 index 000000000000..5f73dfd89d16 Binary files /dev/null and b/x-pack/test/functional/es_archives/cases/migrations/7.13_user_actions/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/cases/migrations/7.13_user_actions/mappings.json b/x-pack/test/functional/es_archives/cases/migrations/7.13_user_actions/mappings.json new file mode 100644 index 000000000000..c6b71a261385 --- /dev/null +++ b/x-pack/test/functional/es_archives/cases/migrations/7.13_user_actions/mappings.json @@ -0,0 +1,2954 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": { + }, + ".kibana_7.13.4": { + } + }, + "index": ".kibana_1", + "mappings": { + "_meta": { + "migrationMappingPropertyHashes": { + "action": "6e96ac5e648f57523879661ea72525b7", + "action_task_params": "a9d49f184ee89641044be0ca2950fa3a", + "alert": "d75d3b0e95fe394753d73d8f7952cd7d", + "api_key_pending_invalidation": "16f515278a295f6245149ad7c5ddedb7", + "apm-indices": "9bb9b2bf1fa636ed8619cbab5ce6a1dd", + "apm-telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "app_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "application_usage_daily": "43b8830d5d0df85a6823d290885fc9fd", + "application_usage_totals": "3d1b76c39bfb2cc8296b024d73854724", + "application_usage_transactional": "3d1b76c39bfb2cc8296b024d73854724", + "canvas-element": "7390014e1091044523666d97247392fc", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "canvas-workpad-template": "ae2673f678281e2c055d764b153e9715", + "cases": "7c28a18fbac7c2a4e79449e9802ef476", + "cases-comments": "112cefc2b6737e613a8ef033234755e6", + "cases-configure": "387c5f3a3bda7e0ae0dd4e106f914a69", + "cases-connector-mappings": "6bc7e49411d38be4969dc6aa8bd43776", + "cases-user-actions": "32277330ec6b721abe3b846cfd939a71", + "config": "c63748b75f39d0c54de12d12c1ccbc20", + "core-usage-stats": "3d1b76c39bfb2cc8296b024d73854724", + "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", + "dashboard": "40554caf09725935e2c02e02563a2d07", + "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", + "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "epm-packages": "0cbbb16506734d341a96aaed65ec6413", + "epm-packages-assets": "44621b2f6052ef966da47b7c3a00f33b", + "exception-list": "baf108c9934dda844921f692a513adae", + "exception-list-agnostic": "baf108c9934dda844921f692a513adae", + "file-upload-usage-collection-telemetry": "a34fbb8e3263d105044869264860c697", + "fleet-agent-actions": "9511b565b1cc6441a42033db3d5de8e9", + "fleet-agents": "59fd74f819f028f8555776db198d2562", + "fleet-enrollment-api-keys": "a69ef7ae661dab31561d6c6f052ef2a7", + "fleet-preconfiguration-deletion-record": "4c36f199189a367e43541f236141204c", + "graph-workspace": "27a94b2edcb0610c6aea54a7c56d7752", + "index-pattern": "45915a1ad866812242df474eb0479052", + "infrastructure-ui-source": "3d1b76c39bfb2cc8296b024d73854724", + "ingest-agent-policies": "cb4dbcc5a695e53f40a359303cb6286f", + "ingest-outputs": "1acb789ca37cbee70259ca79e124d9ad", + "ingest-package-policies": "c91ca97b1ff700f0fc64dc6b13d65a85", + "ingest_manager_settings": "f159646d76ab261bfbf8ef504d9631e4", + "inventory-view": "3d1b76c39bfb2cc8296b024d73854724", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "legacy-url-alias": "3d1b76c39bfb2cc8296b024d73854724", + "lens": "52346cfec69ff7b47d5f0c12361a2797", + "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327", + "map": "9134b47593116d7953f6adba096fc463", + "maps-telemetry": "5ef305b18111b77789afefbd36b66171", + "metrics-explorer-view": "3d1b76c39bfb2cc8296b024d73854724", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "ml-job": "3bb64c31915acf93fc724af137a0891b", + "ml-module": "46ef4f0d6682636f0fff9799d6a2d7ac", + "monitoring-telemetry": "2669d5ec15e82391cf58df4294ee9c68", + "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", + "originId": "2f4316de49999235636386fe51dc06c1", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "db2c00e39b36f40930a3b9fc71c823e1", + "search-session": "4e238afeeaa2550adef326e140454265", + "search-telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "security-rule": "8ae39a88fc70af3375b7050e8d8d5cc7", + "security-solution-signals-migration": "72761fd374ca11122ac8025a92b84fca", + "siem-detection-engine-rule-actions": "6569b288c169539db10cb262bf79de18", + "siem-detection-engine-rule-status": "ae783f41c6937db6b7a2ef5c93a9e9b0", + "siem-ui-timeline": "3e97beae13cdfc6d62bc1846119f7276", + "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084", + "siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29", + "space": "c5ca8acafa0beaa4d08d014a97b6bc6b", + "spaces-usage-stats": "3d1b76c39bfb2cc8296b024d73854724", + "tag": "83d55da58f6530f7055415717ec06474", + "telemetry": "36a616f7026dfa617d6655df850fe16d", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "type": "2f4316de49999235636386fe51dc06c1", + "ui-counter": "0d409297dc5ebe1e3a1da691c6ee32e3", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "upgrade-assistant-reindex-operation": "215107c281839ea9b3ad5f6419819763", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "uptime-dynamic-settings": "3d1b76c39bfb2cc8296b024d73854724", + "url": "c7f66a0df8b1b52f17c28c4adb111105", + "usage-counters": "8cc260bdceffec4ffc3ad165c97dc1b4", + "visualization": "f819cf6636b75c9e76ba733a0c6ef355", + "workplace_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724" + } + }, + "dynamic": "strict", + "properties": { + "action": { + "properties": { + "actionTypeId": { + "type": "keyword" + }, + "config": { + "enabled": false, + "type": "object" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "secrets": { + "type": "binary" + } + } + }, + "action_task_params": { + "properties": { + "actionId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alert": { + "properties": { + "actions": { + "properties": { + "actionRef": { + "type": "keyword" + }, + "actionTypeId": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + }, + "type": "nested" + }, + "alertTypeId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "apiKeyOwner": { + "type": "keyword" + }, + "consumer": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + }, + "createdBy": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + }, + "executionStatus": { + "properties": { + "error": { + "properties": { + "message": { + "type": "keyword" + }, + "reason": { + "type": "keyword" + } + } + }, + "lastExecutionDate": { + "type": "date" + }, + "status": { + "type": "keyword" + } + } + }, + "meta": { + "properties": { + "versionApiKeyLastmodified": { + "type": "keyword" + } + } + }, + "muteAll": { + "type": "boolean" + }, + "mutedInstanceIds": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "notifyWhen": { + "type": "keyword" + }, + "params": { + "ignore_above": 4096, + "type": "flattened" + }, + "schedule": { + "properties": { + "interval": { + "type": "keyword" + } + } + }, + "scheduledTaskId": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "throttle": { + "type": "keyword" + }, + "updatedAt": { + "type": "date" + }, + "updatedBy": { + "type": "keyword" + } + } + }, + "api_key_pending_invalidation": { + "properties": { + "apiKeyId": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + } + } + }, + "apm-indices": { + "properties": { + "apm_oss": { + "properties": { + "errorIndices": { + "type": "keyword" + }, + "metricsIndices": { + "type": "keyword" + }, + "onboardingIndices": { + "type": "keyword" + }, + "sourcemapIndices": { + "type": "keyword" + }, + "spanIndices": { + "type": "keyword" + }, + "transactionIndices": { + "type": "keyword" + } + } + } + } + }, + "apm-telemetry": { + "dynamic": "false", + "type": "object" + }, + "app_search_telemetry": { + "dynamic": "false", + "type": "object" + }, + "application_usage_daily": { + "dynamic": "false", + "properties": { + "timestamp": { + "type": "date" + } + } + }, + "application_usage_totals": { + "dynamic": "false", + "type": "object" + }, + "application_usage_transactional": { + "dynamic": "false", + "type": "object" + }, + "canvas-element": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "content": { + "type": "text" + }, + "help": { + "type": "text" + }, + "image": { + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "canvas-workpad-template": { + "dynamic": "false", + "properties": { + "help": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "tags": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "template_key": { + "type": "keyword" + } + } + }, + "cases": { + "properties": { + "closed_at": { + "type": "date" + }, + "closed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "connector": { + "properties": { + "fields": { + "properties": { + "key": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "id": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "description": { + "type": "text" + }, + "external_service": { + "properties": { + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "external_id": { + "type": "keyword" + }, + "external_title": { + "type": "text" + }, + "external_url": { + "type": "text" + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "settings": { + "properties": { + "syncAlerts": { + "type": "boolean" + } + } + }, + "status": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-comments": { + "properties": { + "alertId": { + "type": "keyword" + }, + "associationType": { + "type": "keyword" + }, + "comment": { + "type": "text" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "index": { + "type": "keyword" + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-configure": { + "properties": { + "closure_type": { + "type": "keyword" + }, + "connector": { + "properties": { + "fields": { + "properties": { + "key": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "id": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-connector-mappings": { + "properties": { + "mappings": { + "properties": { + "action_type": { + "type": "keyword" + }, + "source": { + "type": "keyword" + }, + "target": { + "type": "keyword" + } + } + } + } + }, + "cases-user-actions": { + "properties": { + "action": { + "type": "keyword" + }, + "action_at": { + "type": "date" + }, + "action_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "action_field": { + "type": "keyword" + }, + "new_value": { + "type": "text" + }, + "old_value": { + "type": "text" + } + } + }, + "config": { + "dynamic": "false", + "properties": { + "buildNum": { + "type": "keyword" + } + } + }, + "core-usage-stats": { + "dynamic": "false", + "type": "object" + }, + "coreMigrationVersion": { + "type": "keyword" + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "doc_values": false, + "index": false, + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "index": false, + "type": "text" + } + } + }, + "optionsJSON": { + "index": false, + "type": "text" + }, + "panelsJSON": { + "index": false, + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "pause": { + "doc_values": false, + "index": false, + "type": "boolean" + }, + "section": { + "doc_values": false, + "index": false, + "type": "integer" + }, + "value": { + "doc_values": false, + "index": false, + "type": "integer" + } + } + }, + "timeFrom": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "timeRestore": { + "doc_values": false, + "index": false, + "type": "boolean" + }, + "timeTo": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "endpoint:user-artifact": { + "properties": { + "body": { + "type": "binary" + }, + "compressionAlgorithm": { + "index": false, + "type": "keyword" + }, + "created": { + "index": false, + "type": "date" + }, + "decodedSha256": { + "index": false, + "type": "keyword" + }, + "decodedSize": { + "index": false, + "type": "long" + }, + "encodedSha256": { + "type": "keyword" + }, + "encodedSize": { + "index": false, + "type": "long" + }, + "encryptionAlgorithm": { + "index": false, + "type": "keyword" + }, + "identifier": { + "type": "keyword" + } + } + }, + "endpoint:user-artifact-manifest": { + "properties": { + "artifacts": { + "properties": { + "artifactId": { + "index": false, + "type": "keyword" + }, + "policyId": { + "index": false, + "type": "keyword" + } + }, + "type": "nested" + }, + "created": { + "index": false, + "type": "date" + }, + "schemaVersion": { + "type": "keyword" + }, + "semanticVersion": { + "index": false, + "type": "keyword" + } + } + }, + "enterprise_search_telemetry": { + "dynamic": "false", + "type": "object" + }, + "epm-packages": { + "properties": { + "es_index_patterns": { + "enabled": false, + "type": "object" + }, + "install_source": { + "type": "keyword" + }, + "install_started_at": { + "type": "date" + }, + "install_status": { + "type": "keyword" + }, + "install_version": { + "type": "keyword" + }, + "installed_es": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "installed_kibana": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "internal": { + "type": "boolean" + }, + "name": { + "type": "keyword" + }, + "package_assets": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "removable": { + "type": "boolean" + }, + "version": { + "type": "keyword" + } + } + }, + "epm-packages-assets": { + "properties": { + "asset_path": { + "type": "keyword" + }, + "data_base64": { + "type": "binary" + }, + "data_utf8": { + "index": false, + "type": "text" + }, + "install_source": { + "type": "keyword" + }, + "media_type": { + "type": "keyword" + }, + "package_name": { + "type": "keyword" + }, + "package_version": { + "type": "keyword" + } + } + }, + "exception-list": { + "properties": { + "_tags": { + "type": "keyword" + }, + "comments": { + "properties": { + "comment": { + "type": "keyword" + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "updated_at": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "entries": { + "properties": { + "entries": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "field": { + "type": "keyword" + }, + "list": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "immutable": { + "type": "boolean" + }, + "item_id": { + "type": "keyword" + }, + "list_id": { + "type": "keyword" + }, + "list_type": { + "type": "keyword" + }, + "meta": { + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + }, + "os_types": { + "type": "keyword" + }, + "tags": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + }, + "tie_breaker_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "exception-list-agnostic": { + "properties": { + "_tags": { + "type": "keyword" + }, + "comments": { + "properties": { + "comment": { + "type": "keyword" + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "updated_at": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "entries": { + "properties": { + "entries": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "field": { + "type": "keyword" + }, + "list": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "immutable": { + "type": "boolean" + }, + "item_id": { + "type": "keyword" + }, + "list_id": { + "type": "keyword" + }, + "list_type": { + "type": "keyword" + }, + "meta": { + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + }, + "os_types": { + "type": "keyword" + }, + "tags": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + }, + "tie_breaker_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "file-upload-usage-collection-telemetry": { + "properties": { + "file_upload": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "fleet-agent-actions": { + "properties": { + "ack_data": { + "type": "text" + }, + "agent_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "data": { + "type": "binary" + }, + "policy_id": { + "type": "keyword" + }, + "policy_revision": { + "type": "integer" + }, + "sent_at": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "fleet-agents": { + "properties": { + "access_api_key_id": { + "type": "keyword" + }, + "active": { + "type": "boolean" + }, + "current_error_events": { + "index": false, + "type": "text" + }, + "default_api_key": { + "type": "binary" + }, + "default_api_key_id": { + "type": "keyword" + }, + "enrolled_at": { + "type": "date" + }, + "last_checkin": { + "type": "date" + }, + "last_checkin_status": { + "type": "keyword" + }, + "last_updated": { + "type": "date" + }, + "local_metadata": { + "type": "flattened" + }, + "packages": { + "type": "keyword" + }, + "policy_id": { + "type": "keyword" + }, + "policy_revision": { + "type": "integer" + }, + "type": { + "type": "keyword" + }, + "unenrolled_at": { + "type": "date" + }, + "unenrollment_started_at": { + "type": "date" + }, + "updated_at": { + "type": "date" + }, + "upgrade_started_at": { + "type": "date" + }, + "upgraded_at": { + "type": "date" + }, + "user_provided_metadata": { + "type": "flattened" + }, + "version": { + "type": "keyword" + } + } + }, + "fleet-enrollment-api-keys": { + "properties": { + "active": { + "type": "boolean" + }, + "api_key": { + "type": "binary" + }, + "api_key_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "expire_at": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "policy_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + }, + "fleet-preconfiguration-deletion-record": { + "properties": { + "id": { + "type": "keyword" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "legacyIndexPatternRef": { + "index": false, + "type": "text" + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "dynamic": "false", + "properties": { + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "infrastructure-ui-source": { + "dynamic": "false", + "type": "object" + }, + "ingest-agent-policies": { + "properties": { + "description": { + "type": "text" + }, + "is_default": { + "type": "boolean" + }, + "is_default_fleet_server": { + "type": "boolean" + }, + "is_managed": { + "type": "boolean" + }, + "is_preconfigured": { + "type": "keyword" + }, + "monitoring_enabled": { + "index": false, + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "package_policies": { + "type": "keyword" + }, + "revision": { + "type": "integer" + }, + "status": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "ingest-outputs": { + "properties": { + "ca_sha256": { + "index": false, + "type": "keyword" + }, + "config": { + "type": "flattened" + }, + "config_yaml": { + "type": "text" + }, + "hosts": { + "type": "keyword" + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "ingest-package-policies": { + "properties": { + "created_at": { + "type": "date" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "enabled": { + "type": "boolean" + }, + "inputs": { + "enabled": false, + "properties": { + "compiled_input": { + "type": "flattened" + }, + "config": { + "type": "flattened" + }, + "enabled": { + "type": "boolean" + }, + "streams": { + "properties": { + "compiled_stream": { + "type": "flattened" + }, + "config": { + "type": "flattened" + }, + "data_stream": { + "properties": { + "dataset": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "vars": { + "type": "flattened" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "vars": { + "type": "flattened" + } + }, + "type": "nested" + }, + "name": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "output_id": { + "type": "keyword" + }, + "package": { + "properties": { + "name": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "policy_id": { + "type": "keyword" + }, + "revision": { + "type": "integer" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "ingest_manager_settings": { + "properties": { + "fleet_server_hosts": { + "type": "keyword" + }, + "has_seen_add_data_notice": { + "index": false, + "type": "boolean" + }, + "has_seen_fleet_migration_notice": { + "index": false, + "type": "boolean" + } + } + }, + "inventory-view": { + "dynamic": "false", + "type": "object" + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "legacy-url-alias": { + "dynamic": "false", + "type": "object" + }, + "lens": { + "properties": { + "description": { + "type": "text" + }, + "expression": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "state": { + "type": "flattened" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "lens-ui-telemetry": { + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "map": { + "properties": { + "bounds": { + "dynamic": "false", + "type": "object" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "maps-telemetry": { + "enabled": false, + "type": "object" + }, + "metrics-explorer-view": { + "dynamic": "false", + "type": "object" + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "action": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "alert": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "cases": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "cases-comments": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "cases-configure": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "cases-user-actions": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "config": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "dashboard": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "exception-list-agnostic": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "index-pattern": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "ingest-agent-policies": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "ingest-outputs": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "ingest-package-policies": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "ingest_manager_settings": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "search": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "search-session": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "search-telemetry": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "siem-detection-engine-rule-actions": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "space": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "visualization": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "ml-job": { + "properties": { + "datafeed_id": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "job_id": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "ml-module": { + "dynamic": "false", + "properties": { + "datafeeds": { + "type": "object" + }, + "defaultIndexPattern": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "description": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "id": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "jobs": { + "type": "object" + }, + "logo": { + "type": "object" + }, + "query": { + "type": "object" + }, + "title": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "type": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "monitoring-telemetry": { + "properties": { + "reportedClusterUuids": { + "type": "keyword" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "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" + } + } + }, + "search": { + "properties": { + "columns": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "description": { + "type": "text" + }, + "grid": { + "enabled": false, + "type": "object" + }, + "hideChart": { + "doc_values": false, + "index": false, + "type": "boolean" + }, + "hits": { + "doc_values": false, + "index": false, + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "index": false, + "type": "text" + } + } + }, + "sort": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "search-session": { + "properties": { + "appId": { + "type": "keyword" + }, + "completed": { + "type": "date" + }, + "created": { + "type": "date" + }, + "expires": { + "type": "date" + }, + "idMapping": { + "enabled": false, + "type": "object" + }, + "initialState": { + "enabled": false, + "type": "object" + }, + "name": { + "type": "keyword" + }, + "persisted": { + "type": "boolean" + }, + "realmName": { + "type": "keyword" + }, + "realmType": { + "type": "keyword" + }, + "restoreState": { + "enabled": false, + "type": "object" + }, + "sessionId": { + "type": "keyword" + }, + "status": { + "type": "keyword" + }, + "touched": { + "type": "date" + }, + "urlGeneratorId": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "search-telemetry": { + "dynamic": "false", + "type": "object" + }, + "security-rule": { + "dynamic": "false", + "properties": { + "name": { + "type": "keyword" + }, + "rule_id": { + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "security-solution-signals-migration": { + "properties": { + "created": { + "index": false, + "type": "date" + }, + "createdBy": { + "index": false, + "type": "text" + }, + "destinationIndex": { + "index": false, + "type": "keyword" + }, + "error": { + "index": false, + "type": "text" + }, + "sourceIndex": { + "type": "keyword" + }, + "status": { + "index": false, + "type": "keyword" + }, + "taskId": { + "index": false, + "type": "keyword" + }, + "updated": { + "index": false, + "type": "date" + }, + "updatedBy": { + "index": false, + "type": "text" + }, + "version": { + "type": "long" + } + } + }, + "siem-detection-engine-rule-actions": { + "properties": { + "actions": { + "properties": { + "action_type_id": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alertThrottle": { + "type": "keyword" + }, + "ruleAlertId": { + "type": "keyword" + }, + "ruleThrottle": { + "type": "keyword" + } + } + }, + "siem-detection-engine-rule-status": { + "properties": { + "alertId": { + "type": "keyword" + }, + "bulkCreateTimeDurations": { + "type": "float" + }, + "gap": { + "type": "text" + }, + "lastFailureAt": { + "type": "date" + }, + "lastFailureMessage": { + "type": "text" + }, + "lastLookBackDate": { + "type": "date" + }, + "lastSuccessAt": { + "type": "date" + }, + "lastSuccessMessage": { + "type": "text" + }, + "searchAfterTimeDurations": { + "type": "float" + }, + "status": { + "type": "keyword" + }, + "statusDate": { + "type": "date" + } + } + }, + "siem-ui-timeline": { + "properties": { + "columns": { + "properties": { + "aggregatable": { + "type": "boolean" + }, + "category": { + "type": "keyword" + }, + "columnHeaderType": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "example": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "indexes": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "placeholder": { + "type": "text" + }, + "searchable": { + "type": "boolean" + }, + "type": { + "type": "keyword" + } + } + }, + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "dataProviders": { + "properties": { + "and": { + "properties": { + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "type": { + "type": "text" + } + } + }, + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "type": { + "type": "text" + } + } + }, + "dateRange": { + "properties": { + "end": { + "type": "date" + }, + "start": { + "type": "date" + } + } + }, + "description": { + "type": "text" + }, + "eqlOptions": { + "properties": { + "eventCategoryField": { + "type": "text" + }, + "query": { + "type": "text" + }, + "size": { + "type": "text" + }, + "tiebreakerField": { + "type": "text" + }, + "timestampField": { + "type": "text" + } + } + }, + "eventType": { + "type": "keyword" + }, + "excludedRowRendererIds": { + "type": "text" + }, + "favorite": { + "properties": { + "favoriteDate": { + "type": "date" + }, + "fullName": { + "type": "text" + }, + "keySearch": { + "type": "text" + }, + "userName": { + "type": "text" + } + } + }, + "filters": { + "properties": { + "exists": { + "type": "text" + }, + "match_all": { + "type": "text" + }, + "meta": { + "properties": { + "alias": { + "type": "text" + }, + "controlledBy": { + "type": "text" + }, + "disabled": { + "type": "boolean" + }, + "field": { + "type": "text" + }, + "formattedValue": { + "type": "text" + }, + "index": { + "type": "keyword" + }, + "key": { + "type": "keyword" + }, + "negate": { + "type": "boolean" + }, + "params": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "text" + } + } + }, + "missing": { + "type": "text" + }, + "query": { + "type": "text" + }, + "range": { + "type": "text" + }, + "script": { + "type": "text" + } + } + }, + "indexNames": { + "type": "text" + }, + "kqlMode": { + "type": "keyword" + }, + "kqlQuery": { + "properties": { + "filterQuery": { + "properties": { + "kuery": { + "properties": { + "expression": { + "type": "text" + }, + "kind": { + "type": "keyword" + } + } + }, + "serializedQuery": { + "type": "text" + } + } + } + } + }, + "savedQueryId": { + "type": "keyword" + }, + "sort": { + "dynamic": "false", + "properties": { + "columnId": { + "type": "keyword" + }, + "columnType": { + "type": "keyword" + }, + "sortDirection": { + "type": "keyword" + } + } + }, + "status": { + "type": "keyword" + }, + "templateTimelineId": { + "type": "text" + }, + "templateTimelineVersion": { + "type": "integer" + }, + "timelineType": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-note": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-pinned-event": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "imageUrl": { + "index": false, + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "spaces-usage-stats": { + "dynamic": "false", + "type": "object" + }, + "tag": { + "properties": { + "color": { + "type": "text" + }, + "description": { + "type": "text" + }, + "name": { + "type": "text" + } + } + }, + "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" + } + } + }, + "type": { + "type": "keyword" + }, + "ui-counter": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "ui-metric": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "date" + }, + "upgrade-assistant-reindex-operation": { + "properties": { + "errorMessage": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "indexName": { + "type": "keyword" + }, + "lastCompletedStep": { + "type": "long" + }, + "locked": { + "type": "date" + }, + "newIndexName": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "reindexOptions": { + "properties": { + "openAndClose": { + "type": "boolean" + }, + "queueSettings": { + "properties": { + "queuedAt": { + "type": "long" + }, + "startedAt": { + "type": "long" + } + } + } + } + }, + "reindexTaskId": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "reindexTaskPercComplete": { + "type": "float" + }, + "runningReindexCount": { + "type": "integer" + }, + "status": { + "type": "integer" + } + } + }, + "upgrade-assistant-telemetry": { + "properties": { + "features": { + "properties": { + "deprecation_logging": { + "properties": { + "enabled": { + "null_value": true, + "type": "boolean" + } + } + } + } + }, + "ui_open": { + "properties": { + "cluster": { + "null_value": 0, + "type": "long" + }, + "indices": { + "null_value": 0, + "type": "long" + }, + "overview": { + "null_value": 0, + "type": "long" + } + } + }, + "ui_reindex": { + "properties": { + "close": { + "null_value": 0, + "type": "long" + }, + "open": { + "null_value": 0, + "type": "long" + }, + "start": { + "null_value": 0, + "type": "long" + }, + "stop": { + "null_value": 0, + "type": "long" + } + } + } + } + }, + "uptime-dynamic-settings": { + "dynamic": "false", + "type": "object" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "usage-counters": { + "dynamic": "false", + "properties": { + "domainId": { + "type": "keyword" + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "index": false, + "type": "text" + } + } + }, + "savedSearchRefName": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "index": false, + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "index": false, + "type": "text" + } + } + }, + "workplace_search_telemetry": { + "dynamic": "false", + "type": "object" + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "1", + "number_of_shards": "1", + "priority": "10", + "refresh_interval": "1s" + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/functional/es_archives/security_solution/timelines/7.15.0/data.json.gz b/x-pack/test/functional/es_archives/security_solution/timelines/7.15.0/data.json.gz index e942ef732b22..91e3e459f826 100644 Binary files a/x-pack/test/functional/es_archives/security_solution/timelines/7.15.0/data.json.gz and b/x-pack/test/functional/es_archives/security_solution/timelines/7.15.0/data.json.gz differ diff --git a/x-pack/test/functional/fixtures/kbn_archiver/maps.json b/x-pack/test/functional/fixtures/kbn_archiver/maps.json index b17b4a64f348..78e49997d5c9 100644 --- a/x-pack/test/functional/fixtures/kbn_archiver/maps.json +++ b/x-pack/test/functional/fixtures/kbn_archiver/maps.json @@ -684,7 +684,7 @@ { "attributes": { "description": "", - "layerListJSON": "[{\"sourceDescriptor\":{\"type\":\"KIBANA_TILEMAP\"},\"id\":\"ap0ys\",\"label\":\"Custom_TMS\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":1,\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"TILE\",\"properties\":{},\"previousStyle\":null},\"type\":\"TILE\"},{\"sourceDescriptor\":{\"type\":\"REGIONMAP_FILE\",\"name\":\"nameThatDoesNotExitForKibanaRegionmapSource\"},\"temporary\":false,\"id\":\"0sabv\",\"label\":\"Custom_vector_shapes\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#3cb44b\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"previousStyle\":null},\"type\":\"VECTOR\"},{\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"id\":\"idThatDoesNotExitForEMSTile\"},\"temporary\":false,\"id\":\"plw9l\",\"label\":\"EMS_tiles\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":1,\"visible\":true,\"style\":{\"type\":\"TILE\",\"properties\":{},\"previousStyle\":null},\"type\":\"VECTOR_TILE\"},{\"sourceDescriptor\":{\"type\":\"EMS_FILE\",\"id\":\"idThatDoesNotExitForEMSFileSource\"},\"temporary\":false,\"id\":\"2gro0\",\"label\":\"EMS_vector_shapes\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#e6194b\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"previousStyle\":null},\"type\":\"VECTOR\"},{\"sourceDescriptor\":{\"type\":\"ES_GEO_GRID\",\"id\":\"f67fe707-95dd-46d6-89b8-82617b251b61\",\"geoField\":\"geo.coordinates\",\"requestType\":\"grid\",\"resolution\":\"COARSE\",\"indexPatternRefName\":\"layer_4_source_index_pattern\",\"applyGlobalQuery\":true},\"temporary\":false,\"id\":\"pl5qd\",\"label\":\"\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"Count\",\"name\":\"doc_count\",\"origin\":\"source\"},\"color\":\"Blues\",\"fieldMetaOptions\":{\"isEnabled\":false,\"sigma\":3}}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"Count\",\"name\":\"doc_count\",\"origin\":\"source\"},\"minSize\":4,\"maxSize\":32,\"fieldMetaOptions\":{\"isEnabled\":false,\"sigma\":3}}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"previousStyle\":null},\"type\":\"VECTOR\"},{\"sourceDescriptor\":{\"id\":\"a07072bb-3a92-4320-bd37-250ef6d04db7\",\"type\":\"ES_SEARCH\",\"geoField\":\"geo.coordinates\",\"limit\":2048,\"filterByMapBounds\":true,\"tooltipProperties\":[],\"indexPatternRefName\":\"layer_5_source_index_pattern\",\"applyGlobalQuery\":true,\"scalingType\":\"LIMIT\"},\"temporary\":false,\"id\":\"9bw8h\",\"label\":\"\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#e6194b\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"previousStyle\":null},\"type\":\"VECTOR\"},{\"id\":\"n1t6f\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"sourceDescriptor\":{\"id\":\"62eca1fc-fe42-11e8-8eb2-f2801f1b9fd1\",\"type\":\"ES_SEARCH\",\"geoField\":\"geometry\",\"limit\":2048,\"filterByMapBounds\":false,\"showTooltip\":true,\"tooltipProperties\":[],\"indexPatternRefName\":\"layer_6_source_index_pattern\",\"applyGlobalQuery\":true,\"scalingType\":\"LIMIT\"},\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"max(prop1) group by meta_for_geo_shapes*.shape_name\",\"name\":\"__kbnjoin__max_of_prop1__855ccb86-fe42-11e8-8eb2-f2801f1b9fd1\",\"origin\":\"join\"},\"color\":\"Blues\",\"fieldMetaOptions\":{\"isEnabled\":false,\"sigma\":3}}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"temporary\":true,\"previousStyle\":null},\"type\":\"VECTOR\",\"joins\":[{\"leftField\":\"name\",\"right\":{\"id\":\"855ccb86-fe42-11e8-8eb2-f2801f1b9fd1\",\"indexPatternTitle\":\"meta_for_geo_shapes*\",\"term\":\"shape_name\",\"metrics\":[{\"type\":\"max\",\"field\":\"prop1\"}],\"indexPatternRefName\":\"layer_6_join_0_index_pattern\",\"applyGlobalQuery\":true,\"type\":\"ES_TERM_SOURCE\"}}]}]", + "layerListJSON": "[{\"sourceDescriptor\":{\"type\":\"KIBANA_TILEMAP\"},\"id\":\"ap0ys\",\"label\":\"Custom_TMS\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":1,\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"TILE\",\"properties\":{},\"previousStyle\":null},\"type\":\"TILE\"},{\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"id\":\"idThatDoesNotExitForEMSTile\"},\"temporary\":false,\"id\":\"plw9l\",\"label\":\"EMS_tiles\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":1,\"visible\":true,\"style\":{\"type\":\"TILE\",\"properties\":{},\"previousStyle\":null},\"type\":\"VECTOR_TILE\"},{\"sourceDescriptor\":{\"type\":\"EMS_FILE\",\"id\":\"idThatDoesNotExitForEMSFileSource\"},\"temporary\":false,\"id\":\"2gro0\",\"label\":\"EMS_vector_shapes\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#e6194b\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"previousStyle\":null},\"type\":\"VECTOR\"},{\"sourceDescriptor\":{\"type\":\"ES_GEO_GRID\",\"id\":\"f67fe707-95dd-46d6-89b8-82617b251b61\",\"geoField\":\"geo.coordinates\",\"requestType\":\"grid\",\"resolution\":\"COARSE\",\"indexPatternRefName\":\"layer_4_source_index_pattern\",\"applyGlobalQuery\":true},\"temporary\":false,\"id\":\"pl5qd\",\"label\":\"\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"Count\",\"name\":\"doc_count\",\"origin\":\"source\"},\"color\":\"Blues\",\"fieldMetaOptions\":{\"isEnabled\":false,\"sigma\":3}}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"Count\",\"name\":\"doc_count\",\"origin\":\"source\"},\"minSize\":4,\"maxSize\":32,\"fieldMetaOptions\":{\"isEnabled\":false,\"sigma\":3}}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"previousStyle\":null},\"type\":\"VECTOR\"},{\"sourceDescriptor\":{\"id\":\"a07072bb-3a92-4320-bd37-250ef6d04db7\",\"type\":\"ES_SEARCH\",\"geoField\":\"geo.coordinates\",\"limit\":2048,\"filterByMapBounds\":true,\"tooltipProperties\":[],\"indexPatternRefName\":\"layer_5_source_index_pattern\",\"applyGlobalQuery\":true,\"scalingType\":\"LIMIT\"},\"temporary\":false,\"id\":\"9bw8h\",\"label\":\"\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#e6194b\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"previousStyle\":null},\"type\":\"VECTOR\"},{\"id\":\"n1t6f\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"sourceDescriptor\":{\"id\":\"62eca1fc-fe42-11e8-8eb2-f2801f1b9fd1\",\"type\":\"ES_SEARCH\",\"geoField\":\"geometry\",\"limit\":2048,\"filterByMapBounds\":false,\"showTooltip\":true,\"tooltipProperties\":[],\"indexPatternRefName\":\"layer_6_source_index_pattern\",\"applyGlobalQuery\":true,\"scalingType\":\"LIMIT\"},\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"max(prop1) group by meta_for_geo_shapes*.shape_name\",\"name\":\"__kbnjoin__max_of_prop1__855ccb86-fe42-11e8-8eb2-f2801f1b9fd1\",\"origin\":\"join\"},\"color\":\"Blues\",\"fieldMetaOptions\":{\"isEnabled\":false,\"sigma\":3}}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"temporary\":true,\"previousStyle\":null},\"type\":\"VECTOR\",\"joins\":[{\"leftField\":\"name\",\"right\":{\"id\":\"855ccb86-fe42-11e8-8eb2-f2801f1b9fd1\",\"indexPatternTitle\":\"meta_for_geo_shapes*\",\"term\":\"shape_name\",\"metrics\":[{\"type\":\"max\",\"field\":\"prop1\"}],\"indexPatternRefName\":\"layer_6_join_0_index_pattern\",\"applyGlobalQuery\":true,\"type\":\"ES_TERM_SOURCE\"}}]}]", "mapStateJSON": "{\"zoom\":0.71,\"center\":{\"lon\":0.10268,\"lat\":0},\"timeFilters\":{\"from\":\"now-7d\",\"to\":\"now\"},\"refreshConfig\":{\"isPaused\":true,\"interval\":0},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"settings\":{\"autoFitToDataBounds\":false}}", "title": "layer with errors", "uiStateJSON": "{}" diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 2e1151602f31..e26ea8f598c4 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -645,8 +645,21 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont /** * Adds a new layer to the chart, fails if the chart does not support new layers */ - async createLayer() { + async createLayer(layerType: string = 'data') { await testSubjects.click('lnsLayerAddButton'); + const layerCount = (await find.allByCssSelector(`[data-test-subj^="lns-layerPanel-"]`)) + .length; + + await retry.waitFor('check for layer type support', async () => { + const fasterChecks = await Promise.all([ + (await find.allByCssSelector(`[data-test-subj^="lns-layerPanel-"]`)).length > layerCount, + testSubjects.exists(`lnsLayerAddButton-${layerType}`), + ]); + return fasterChecks.filter(Boolean).length > 0; + }); + if (await testSubjects.exists(`lnsLayerAddButton-${layerType}`)) { + await testSubjects.click(`lnsLayerAddButton-${layerType}`); + } }, /** @@ -1075,6 +1088,10 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont await testSubjects.click('lens-dimensionTabs-formula'); }, + async switchToStaticValue() { + await testSubjects.click('lens-dimensionTabs-static_value'); + }, + async toggleFullscreen() { await testSubjects.click('lnsFormula-fullscreen'); }, diff --git a/x-pack/test/functional/page_objects/search_sessions_management_page.ts b/x-pack/test/functional/page_objects/search_sessions_management_page.ts index 86391b568fdf..15c87ea45042 100644 --- a/x-pack/test/functional/page_objects/search_sessions_management_page.ts +++ b/x-pack/test/functional/page_objects/search_sessions_management_page.ts @@ -38,6 +38,7 @@ export function SearchSessionsPageProvider({ getService, getPageObjects }: FtrPr mainUrl: $.findTestSubject('sessionManagementNameCol').text(), created: $.findTestSubject('sessionManagementCreatedCol').text(), expires: $.findTestSubject('sessionManagementExpiresCol').text(), + searchesCount: Number($.findTestSubject('sessionManagementNumSearchesCol').text()), app: $.findTestSubject('sessionManagementAppIcon').attr('data-test-app-id'), view: async () => { log.debug('management ui: view the session'); diff --git a/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/sessions_in_space.ts b/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/sessions_in_space.ts index d05ac46d5b88..98eca99ff436 100644 --- a/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/sessions_in_space.ts +++ b/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/sessions_in_space.ts @@ -23,7 +23,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); const searchSessions = getService('searchSessions'); - describe('dashboard in space', () => { + // Failing: See https://github.com/elastic/kibana/issues/112732 + describe.skip('dashboard in space', () => { describe('Storing search sessions in space', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/dashboard/session_in_space'); diff --git a/x-pack/test/search_sessions_integration/tests/apps/discover/async_search.ts b/x-pack/test/search_sessions_integration/tests/apps/discover/async_search.ts index 3f02e6405632..cd13f71cf1bb 100644 --- a/x-pack/test/search_sessions_integration/tests/apps/discover/async_search.ts +++ b/x-pack/test/search_sessions_integration/tests/apps/discover/async_search.ts @@ -26,6 +26,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const searchSessions = getService('searchSessions'); const retry = getService('retry'); const kibanaServer = getService('kibanaServer'); + const toasts = getService('toasts'); describe('discover async search', () => { before(async () => { @@ -112,12 +113,20 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { // load URL to restore a saved session await PageObjects.searchSessionsManagement.goTo(); - const searchSessionList = await PageObjects.searchSessionsManagement.getList(); + const searchSessionListBeforeRestore = await PageObjects.searchSessionsManagement.getList(); + const searchesCountBeforeRestore = searchSessionListBeforeRestore[0].searchesCount; // navigate to Discover - await searchSessionList[0].view(); + await searchSessionListBeforeRestore[0].view(); await PageObjects.header.waitUntilLoadingHasFinished(); await searchSessions.expectState('restored'); expect(await PageObjects.discover.hasNoResults()).to.be(true); + expect(await toasts.getToastCount()).to.be(0); // no session restoration related warnings + + await PageObjects.searchSessionsManagement.goTo(); + const searchSessionListAfterRestore = await PageObjects.searchSessionsManagement.getList(); + const searchesCountAfterRestore = searchSessionListAfterRestore[0].searchesCount; + + expect(searchesCountBeforeRestore).to.be(searchesCountAfterRestore); // no new searches started during restore }); });