diff --git a/.ci/Jenkinsfile_visual_baseline b/.ci/Jenkinsfile_visual_baseline index 4a1e0f7d74e07..5c13ccccd9c6f 100644 --- a/.ci/Jenkinsfile_visual_baseline +++ b/.ci/Jenkinsfile_visual_baseline @@ -6,13 +6,15 @@ kibanaLibrary.load() kibanaPipeline(timeoutMinutes: 120) { catchError { parallel([ - workers.base(name: 'oss-visualRegression', label: 'linux && immutable') { - kibanaPipeline.buildOss() - kibanaPipeline.functionalTestProcess('oss-visualRegression', './test/scripts/jenkins_visual_regression.sh') + 'oss-visualRegression': { + workers.ci(name: 'oss-visualRegression', label: 'linux && immutable', ramDisk: false) { + kibanaPipeline.functionalTestProcess('oss-visualRegression', './test/scripts/jenkins_visual_regression.sh')(1) + } }, - workers.base(name: 'xpack-visualRegression', label: 'linux && immutable') { - kibanaPipeline.buildXpack() - kibanaPipeline.functionalTestProcess('xpack-visualRegression', './test/scripts/jenkins_xpack_visual_regression.sh') + 'xpack-visualRegression': { + workers.ci(name: 'xpack-visualRegression', label: 'linux && immutable', ramDisk: false) { + kibanaPipeline.functionalTestProcess('xpack-visualRegression', './test/scripts/jenkins_xpack_visual_regression.sh')(1) + } }, ]) } diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5948b9672e6d4..de74a2c42be8b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -132,6 +132,7 @@ /src/legacy/server/logging/ @elastic/kibana-platform /src/legacy/server/saved_objects/ @elastic/kibana-platform /src/legacy/server/status/ @elastic/kibana-platform +/src/plugins/status_page/ @elastic/kibana-platform /src/dev/run_check_core_api_changes.ts @elastic/kibana-platform # Security diff --git a/docs/images/intro-dashboard.png b/docs/images/intro-dashboard.png new file mode 100755 index 0000000000000..5d18acb67bef5 Binary files /dev/null and b/docs/images/intro-dashboard.png differ diff --git a/docs/images/intro-data-tutorial.png b/docs/images/intro-data-tutorial.png new file mode 100644 index 0000000000000..a00e41c8b2a4c Binary files /dev/null and b/docs/images/intro-data-tutorial.png differ diff --git a/docs/images/intro-discover.png b/docs/images/intro-discover.png new file mode 100755 index 0000000000000..27e7a2c728597 Binary files /dev/null and b/docs/images/intro-discover.png differ diff --git a/docs/images/intro-kibana.png b/docs/images/intro-kibana.png new file mode 100644 index 0000000000000..1a59230f2f166 Binary files /dev/null and b/docs/images/intro-kibana.png differ diff --git a/docs/images/intro-management.png b/docs/images/intro-management.png new file mode 100644 index 0000000000000..3c14529a53e90 Binary files /dev/null and b/docs/images/intro-management.png differ diff --git a/docs/images/intro-spaces.jpg b/docs/images/intro-spaces.jpg new file mode 100755 index 0000000000000..7569dfc16b4f7 Binary files /dev/null and b/docs/images/intro-spaces.jpg differ diff --git a/docs/management/snapshot-restore/images/snapshot_permissions.png b/docs/management/snapshot-restore/images/snapshot_permissions.png new file mode 100644 index 0000000000000..463d4d6e389c6 Binary files /dev/null and b/docs/management/snapshot-restore/images/snapshot_permissions.png differ diff --git a/docs/management/snapshot-restore/index.asciidoc b/docs/management/snapshot-restore/index.asciidoc index dc722c24af76c..7253d6eaa0f68 100644 --- a/docs/management/snapshot-restore/index.asciidoc +++ b/docs/management/snapshot-restore/index.asciidoc @@ -2,13 +2,13 @@ [[snapshot-repositories]] == Snapshot and Restore -*Snapshot and Restore* enables you to backup your {es} -indices and clusters using data and state snapshots. -Snapshots are important because they provide a copy of your data in case +*Snapshot and Restore* enables you to backup your {es} +indices and clusters using data and state snapshots. +Snapshots are important because they provide a copy of your data in case something goes wrong. If you need to roll back to an older version of your data, you can restore a snapshot from the repository. -You’ll find *Snapshot and Restore* under *Management > Elasticsearch*. +You’ll find *Snapshot and Restore* under *Management > Elasticsearch*. With this UI, you can: * Register a repository for storing your snapshots @@ -20,29 +20,42 @@ With this UI, you can: [role="screenshot"] image:management/snapshot-restore/images/snapshot_list.png["Snapshot list"] -Before using this feature, you should be familiar with how snapshots work. -{ref}/snapshot-restore.html[Snapshot and Restore] is a good source for +Before using this feature, you should be familiar with how snapshots work. +{ref}/snapshot-restore.html[Snapshot and Restore] is a good source for more detailed information. +[float] +[[snapshot-permissions]] +=== Required permissions +The minimum required permissions to access *Snapshot and Restore* include: + +* Cluster privileges: `monitor`, `manage_slm`, `cluster:admin/snapshot`, and `cluster:admin/repository` +* Index privileges: `all` on the `monitor` index if you want to access content in the *Restore Status* tab + +You can add these privileges in *Management > Security > Roles*. + +[role="screenshot"] +image:management/snapshot-restore/images/snapshot_permissions.png["Edit Role"] + [float] [[kib-snapshot-register-repository]] === Register a repository -A repository is where your snapshots live. You must register a snapshot -repository before you can perform snapshot and restore operations. +A repository is where your snapshots live. You must register a snapshot +repository before you can perform snapshot and restore operations. -If you don't have a repository, Kibana walks you through the process of -registering one. +If you don't have a repository, Kibana walks you through the process of +registering one. {kib} supports three repository types -out of the box: shared file system, read-only URL, and source-only. -For more information on these repositories and their settings, +out of the box: shared file system, read-only URL, and source-only. +For more information on these repositories and their settings, see {ref}/snapshots-register-repository.html[Repositories]. -To use other repositories, such as S3, see +To use other repositories, such as S3, see {ref}/snapshots-register-repository.html#snapshots-repository-plugins[Repository plugins]. -Once you create a repository, it is listed in the *Repositories* -view. -Click a repository name to view its type, number of snapshots, and settings, +Once you create a repository, it is listed in the *Repositories* +view. +Click a repository name to view its type, number of snapshots, and settings, and to verify status. [role="screenshot"] @@ -53,46 +66,46 @@ image:management/snapshot-restore/images/repository_list.png["Repository list"] [[kib-view-snapshot]] === View your snapshots -A snapshot is a backup taken from a running {es} cluster. You'll find an overview of -your snapshots in the *Snapshots* view, and you can drill down +A snapshot is a backup taken from a running {es} cluster. You'll find an overview of +your snapshots in the *Snapshots* view, and you can drill down into each snapshot for further investigation. [role="screenshot"] image:management/snapshot-restore/images/snapshot_details.png["Snapshot details"] -If you don’t have any snapshots, you can create them from the {kib} <>. The +If you don’t have any snapshots, you can create them from the {kib} <>. The {ref}/snapshots-take-snapshot.html[snapshot API] -takes the current state and data in your index or cluster, and then saves it to a -shared repository. +takes the current state and data in your index or cluster, and then saves it to a +shared repository. -The snapshot process is "smart." Your first snapshot is a complete copy of +The snapshot process is "smart." Your first snapshot is a complete copy of the data in your index or cluster. -All subsequent snapshots save the changes between the existing snapshots and +All subsequent snapshots save the changes between the existing snapshots and the new data. [float] [[kib-restore-snapshot]] === Restore a snapshot -The information stored in a snapshot is not tied to a specific +The information stored in a snapshot is not tied to a specific cluster or a cluster name. This enables you to -restore a snapshot made from one cluster to another cluster. You might +restore a snapshot made from one cluster to another cluster. You might use the restore operation to: * Recover data lost due to a failure * Migrate a current Elasticsearch cluster to a new version * Move data from one cluster to another cluster -To get started, go to the *Snapshots* view, find the -snapshot, and click the restore icon in the *Actions* column. +To get started, go to the *Snapshots* view, find the +snapshot, and click the restore icon in the *Actions* column. The Restore wizard presents -options for the restore operation, including which +options for the restore operation, including which indices to restore and whether to modify the index settings. -You can restore an existing index only if it’s closed and has the same +You can restore an existing index only if it’s closed and has the same number of shards as the index in the snapshot. Once you initiate the restore, you're navigated to the *Restore Status* view, -where you can track the current state for each shard in the snapshot. +where you can track the current state for each shard in the snapshot. [role="screenshot"] image:management/snapshot-restore/images/snapshot-restore.png["Snapshot details"] @@ -102,26 +115,26 @@ image:management/snapshot-restore/images/snapshot-restore.png["Snapshot details" [[kib-snapshot-policy]] === Create a snapshot lifecycle policy -Use a {ref}/snapshot-lifecycle-management-api.html[snapshot lifecycle policy] -to automate the creation and deletion +Use a {ref}/snapshot-lifecycle-management-api.html[snapshot lifecycle policy] +to automate the creation and deletion of cluster snapshots. Taking automatic snapshots: * Ensures your {es} indices and clusters are backed up on a regular basis -* Ensures a recent and relevant snapshot is available if a situation +* Ensures a recent and relevant snapshot is available if a situation arises where a cluster needs to be recovered -* Allows you to manage your snapshots in {kib}, instead of using a +* Allows you to manage your snapshots in {kib}, instead of using a third-party tool - -If you don’t have any snapshot policies, follow the -*Create policy* wizard. It walks you through defining -when and where to take snapshots, the settings you want, + +If you don’t have any snapshot policies, follow the +*Create policy* wizard. It walks you through defining +when and where to take snapshots, the settings you want, and how long to retain snapshots. [role="screenshot"] image:management/snapshot-restore/images/snapshot-retention.png["Snapshot details"] An overview of your policies is on the *Policies* view. -You can drill down into each policy to examine its settings and last successful and failed run. +You can drill down into each policy to examine its settings and last successful and failed run. You can perform the following actions on a snapshot policy: @@ -139,8 +152,8 @@ image:management/snapshot-restore/images/create-policy.png["Snapshot details"] === Delete a snapshot Delete snapshots to manage your repository storage space. -Find the snapshot in the *Snapshots* view and click the trash icon in the -*Actions* column. To delete snapshots in bulk, select their checkboxes, +Find the snapshot in the *Snapshots* view and click the trash icon in the +*Actions* column. To delete snapshots in bulk, select their checkboxes, and then click *Delete snapshots*. [[snapshot-repositories-example]] @@ -159,10 +172,10 @@ Ready to try *Snapshot and Restore*? In this tutorial, you'll learn to: ==== Before you begin -This example shows you how to register a shared file system repository +This example shows you how to register a shared file system repository and store snapshots. -Before you begin, you must register the location of the repository in the -{ref}/snapshots-register-repository.html#snapshots-filesystem-repository[path.repo] setting on +Before you begin, you must register the location of the repository in the +{ref}/snapshots-register-repository.html#snapshots-filesystem-repository[path.repo] setting on your master and data nodes. You can do this in one of two ways: * Edit your `elasticsearch.yml` to include the `path.repo` setting. @@ -175,14 +188,14 @@ your master and data nodes. You can do this in one of two ways: [[register-repo-example]] ==== Register a repository -Use *Snapshot and Restore* to register the repository where your snapshots -will live. +Use *Snapshot and Restore* to register the repository where your snapshots +will live. . Go to *Management > Elasticsearch > Snapshot and Restore*. . Click *Register a repository* in either the introductory message or *Repository view*. . Enter a name for your repository, for example, `my_backup`. . Select *Shared file system*. -+ ++ [role="screenshot"] image:management/snapshot-restore/images/register_repo.png["Register repository"] @@ -205,13 +218,13 @@ Use the {ref}/snapshots-take-snapshot.html[snapshot API] to create a snapshot. [source,js] PUT /_snapshot/my_backup/2019-04-25_snapshot?wait_for_completion=true + -In this example, the snapshot name is `2019-04-25_snapshot`. You can also +In this example, the snapshot name is `2019-04-25_snapshot`. You can also use {ref}/date-math-index-names.html[date math expression] for the snapshot name. + [role="screenshot"] image:management/snapshot-restore/images/create_snapshot.png["Create snapshot"] -. Return to *Snapshot and Restore*. +. Return to *Snapshot and Restore*. + Your new snapshot is available in the *Snapshots* view. @@ -223,7 +236,7 @@ using the repository created in the previous example. . Open the *Policies* view. . Click *Create a policy*. -+ ++ [role="screenshot"] image:management/snapshot-restore/images/create-policy-example.png["Create policy wizard"] @@ -288,17 +301,16 @@ Finally, you'll restore indices from an existing snapshot. |*Index settings* | |Modify index settings -|Toggle to overwrite index settings when they are restored, +|Toggle to overwrite index settings when they are restored, or leave in place to keep existing settings. |Reset index settings -|Toggle to reset index settings back to the default when they are restored, +|Toggle to reset index settings back to the default when they are restored, or leave in place to keep existing settings. |=== . Review your restore settings, and then click *Restore snapshot*. + -The operation loads for a few seconds, -and then you’re navigated to *Restore Status*, +The operation loads for a few seconds, +and then you’re navigated to *Restore Status*, where you can monitor the status of your restored indices. - diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index a9fa2bd18d315..9a45fb9ab1d0c 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -95,6 +95,8 @@ index for any pending Reporting jobs. Defaults to `3000` (3 seconds). [[xpack-reporting-q-timeout]]`xpack.reporting.queue.timeout`:: How long each worker has to produce a report. If your machine is slow or under heavy load, you might need to increase this timeout. Specified in milliseconds. +If a Reporting job execution time goes over this time limit, the job will be +marked as a failure and there will not be a download available. Defaults to `120000` (two minutes). [float] @@ -104,6 +106,26 @@ Defaults to `120000` (two minutes). Reporting works by capturing screenshots from Kibana. The following settings control the capturing process. +`xpack.reporting.capture.timeouts.openUrl`:: +How long to allow the Reporting browser to wait for the initial data of the +Kibana page to load. Defaults to `30000` (30 seconds). + +`xpack.reporting.capture.timeouts.waitForElements`:: +How long to allow the Reporting browser to wait for the visualization panels to +load on the Kibana page. Defaults to `30000` (30 seconds). + +`xpack.reporting.capture.timeouts.renderComplete`:: +How long to allow the Reporting brwoser to wait for each visualization to +signal that it is done renderings. Defaults to `30000` (30 seconds). + +[NOTE] +============ +If any timeouts from `xpack.reporting.capture.timeouts.*` settings occur when +running a report job, Reporting will log the error and try to continue +capturing the page with a screenshot. As a result, a download will be +available, but there will likely be errors in the visualizations in the report. +============ + `xpack.reporting.capture.maxAttempts`:: If capturing a report fails for any reason, Kibana will re-attempt othe reporting job, as many times as this setting. Defaults to `3`. diff --git a/docs/user/introduction.asciidoc b/docs/user/introduction.asciidoc index fcb072c7c925f..bbaf22b497868 100644 --- a/docs/user/introduction.asciidoc +++ b/docs/user/introduction.asciidoc @@ -1,12 +1,165 @@ [[introduction]] -== Introduction +== {kib} — your window into the Elastic Stack +++++ +What is Kibana? +++++ -Kibana is an open source analytics and visualization platform designed to work with Elasticsearch. You use Kibana to -search, view, and interact with data stored in Elasticsearch indices. You can easily perform advanced data analysis -and visualize your data in a variety of charts, tables, and maps. +**_Explore and visualize your data and manage all things Elastic Stack._** -Kibana makes it easy to understand large volumes of data. Its simple, browser-based interface enables you to quickly -create and share dynamic dashboards that display changes to Elasticsearch queries in real time. +Whether you’re a user or admin, {kib} makes your data actionable by providing +three key functions. Kibana is: -Setting up Kibana is a snap. You can install Kibana and start exploring your Elasticsearch indices in minutes -- no -code, no additional infrastructure required. +* **An open-source analytics and visualization platform.** +Use {kib} to explore your {es} data, and then build beautiful visualizations and dashboards. + +* **A UI for managing the Elastic Stack.** +Manage your security settings, assign user roles, take snapshots, roll up your data, +and more — all from the convenience of a {kib} UI. + +* **A centralized hub for Elastic's solutions.** From log analytics to +document discovery to SIEM, {kib} is the portal for accessing these and other capabilities. + +[role="screenshot"] +image::images/intro-kibana.png[] + +[float] +[[get-data-into-kibana]] +=== Getting data into {kib} + +{kib} is designed to use {es} as a data source. Think of Elasticsearch as the engine that stores +and processes the data, with {kib} sitting on top. + +From the home page, {kib} provides these options for getting data in: + +* Set up a data flow to Elasticsearch using our built-in tutorials. +(If a tutorial doesn’t exist for your data, go to the +{beats-ref}/beats-reference.html[Beats overview] to learn about other data shippers +in the {beats} family.) +* <> and take {kib} for a test drive without loading data yourself. +* Import static data using the +https://www.elastic.co/blog/importing-csv-and-log-data-into-elasticsearch-with-file-data-visualizer[file upload feature]. +* Index your data into Elasticsearch with {ref}/getting-started-index.html[REST APIs] + or https://www.elastic.co/guide/en/elasticsearch/client/index.html[client libraries]. ++ +[role="screenshot"] +image::images/intro-data-tutorial.png[Ways to get data in from the home page] + + +{kib} uses an +<> to tell it which {es} indices to explore. +If you add sample data or run a built-in tutorial, you get an index pattern for free, +and are good to start exploring. If you load your own data, you can create +an index pattern in <>. + +[float] +[[explore-and-query]] +=== Explore & query + +Ready to dive into your data? With <>, you can explore your data and +search for hidden insights and relationships. Ask your questions, and then +narrow the results to just the data you want. + +[role="screenshot"] +image::images/intro-discover.png[] + +[float] +[[visualize-and-analyze]] +=== Visualize & analyze + +A visualization is worth a thousand log lines, and {kib} provides +many options for showcasing your data. Use <>, +our drag-and-drop interface, +to rapidly build +charts, tables, metrics, and more. If there +is a better visualization for your data, *Lens* suggests it, allowing for quick +switching between visualization types. + +Once your visualizations are just the way you want, +use <> to collect them in one place. A dashboard provides +insights into your data from multiple perspectives. + +[role="screenshot"] +image::images/intro-dashboard.png[] + +{kib} also offers these visualization features: + +* <> allows you to display your data in +line charts, bar graphs, pie charts, histograms, and tables +(just to name a few). It's also home to *Lens*, mentioned above. +*Visualize* supports the ability to add interactive +controls to your dashboard, and filter dashboard content in real time. + +* <> gives you the ability to present your data in a +visually compelling, pixel-perfect report. Give your data the “wow” factor +needed to impress your CEO or to captivate people with a big-screen display. + +* <> enables you to ask (and answer) meaningful +questions of your location-based data. *Elastic Maps* supports multiple +layers and data sources, mapping of individual geo points and shapes, +and dynamic client-side styling. + +* <> allows you to combine +an infinite number of aggregations to display complex data in a meaningful way. +With TSVB, you can analyze multiple index patterns and customize +every aspect of your visualization. Choose your own date format and color +gradients, and easily switch your data view between time series, metric, +top N, gauge, and markdown. + +[float] +[[organize-and-secure]] +=== Organize & secure + +Want to share Kibana’s goodness with other people or teams? You can do so with +<>, built for organizing your visualizations, dashboards, and indices. +Think of a space as its own mini {kib} installation — it’s isolated from +all other spaces, so you can tailor it to your specific needs without impacting others. + +You can even choose which features to enable within each space. Don’t need +Machine learning in your “Executive” space? Simply turn it off. + +[role="screenshot"] +image::images/intro-spaces.jpg[] + +You can take this all one step further with Kibana’s security features, and +control which users have access to each space. {kib} allows for fine-grained +controls, so you can give a user read-only access to +dashboards in one space, but full access to all of Kibana’s features in another. + +[float] +[[manage-all-things-stack]] +=== Manage all things Elastic Stack + +<> provides guided processes for managing all +things Elastic Stack — indices, clusters, licenses, UI settings, index patterns, +and more. Want to update your {es} indices? Set user roles and privileges? +Turn on dark mode? Kibana has UIs for all that. + +[role="screenshot"] +image::images/intro-management.png[] + +[float] +[[extend-your-use-case]] +=== Extend your use case — or add a new one + +As a hub for Elastic's https://www.elastic.co/products/[solutions], {kib} +can help you find security vulnerabilities, +monitor performance, and address your business needs. Get alerted if a key +metric spikes. Detect anomalous behavior or forecast future spikes. Root out +bottlenecks in your application code. Kibana doesn’t limit or dictate how you explore your data. + +[role="screenshot"] +image::siem/images/detections-ui.png[] + +[float] +[[try-kibana]] +=== Give {kib} a try + +There is no faster way to try out {kib} than with our hosted {es} Service. +https://www.elastic.co/cloud/elasticsearch-service/signup[Sign up for a free trial] +and start exploring data in minutes. + +You can also <> — no code, no additional +infrastructure required. + +Our <> and in-product guidance can +help you get up and running, faster. Use our Help menu if you have questions or feedback. diff --git a/package.json b/package.json index 2c401724c72cd..9f12f04223103 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "@elastic/charts": "^17.1.1", "@elastic/datemath": "5.0.2", "@elastic/ems-client": "7.6.0", - "@elastic/eui": "19.0.0", + "@elastic/eui": "20.0.2", "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", "@elastic/numeral": "2.4.0", diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index e9ad227b235fa..65fd837ad17c2 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -11,7 +11,7 @@ "devDependencies": { "@elastic/charts": "^17.1.1", "abortcontroller-polyfill": "^1.4.0", - "@elastic/eui": "19.0.0", + "@elastic/eui": "20.0.2", "@kbn/babel-preset": "1.0.0", "@kbn/dev-utils": "1.0.0", "@kbn/i18n": "1.0.0", diff --git a/src/legacy/core_plugins/data/public/index.ts b/src/legacy/core_plugins/data/public/index.ts index ab3bc598bddd8..424e5ab0bf4d5 100644 --- a/src/legacy/core_plugins/data/public/index.ts +++ b/src/legacy/core_plugins/data/public/index.ts @@ -44,7 +44,6 @@ export { IFieldParamType, IMetricAggType, IpRangeKey, // only used in field formatter deserialization, which will live in data - ISchemas, OptionedParamEditorProps, // only type is used externally OptionedValueProp, // only type is used externally } from './search/types'; @@ -75,8 +74,6 @@ export { OptionedParamType, parentPipelineType, propFilter, - Schema, - Schemas, siblingPipelineType, termsAggFilter, toAbsoluteDates, diff --git a/src/legacy/core_plugins/data/public/search/aggs/agg_config.test.ts b/src/legacy/core_plugins/data/public/search/aggs/agg_config.test.ts index 4913499403c00..36d5451a4cd00 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/agg_config.test.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/agg_config.test.ts @@ -343,8 +343,7 @@ describe('AggConfig', () => { expect(typeof aggConfig.params).toBe('object'); expect(aggConfig.type).toBeInstanceOf(AggType); expect(aggConfig.type).toHaveProperty('name', 'date_histogram'); - expect(typeof aggConfig.schema).toBe('object'); - expect(aggConfig.schema).toHaveProperty('name', 'segment'); + expect(typeof aggConfig.schema).toBe('string'); const state = aggConfig.toJSON(); expect(state).toHaveProperty('id', '1'); diff --git a/src/legacy/core_plugins/data/public/search/aggs/agg_config.ts b/src/legacy/core_plugins/data/public/search/aggs/agg_config.ts index 1465731d5e82b..bf2d2f734c989 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/agg_config.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/agg_config.ts @@ -20,10 +20,8 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { IAggType } from './agg_type'; -import { AggGroupNames } from './agg_groups'; import { writeParams } from './agg_params'; import { IAggConfigs } from './agg_configs'; -import { Schema } from './schemas'; import { ISearchSource, FetchOptions, @@ -38,37 +36,9 @@ export interface AggConfigOptions { enabled?: boolean; id?: string; params?: Record; - schema?: string | Schema; + schema?: string; } -const unknownSchema: Schema = { - name: 'unknown', - title: 'Unknown', // only here for illustrative purposes - hideCustomLabel: true, - aggFilter: [], - min: 1, - max: 1, - params: [], - defaults: {}, - editor: false, - group: AggGroupNames.Metrics, - aggSettings: { - top_hits: { - allowStrings: true, - }, - }, -}; - -const getSchemaFromRegistry = (schemas: any, schema: string): Schema => { - let registeredSchema = schemas ? schemas.byName[schema] : null; - if (!registeredSchema) { - registeredSchema = Object.assign({}, unknownSchema); - registeredSchema.name = schema; - } - - return registeredSchema; -}; - /** * @name AggConfig * @@ -122,8 +92,8 @@ export class AggConfig { public params: any; public parent?: IAggConfigs; public brandNew?: boolean; + public schema?: string; - private __schema: Schema; private __type: IAggType; private __typeDecorations: any; private subAggs: AggConfig[] = []; @@ -141,14 +111,12 @@ export class AggConfig { this.setType(opts.type); if (opts.schema) { - this.setSchema(opts.schema); + this.schema = opts.schema; } // set the params to the values from opts, or just to the defaults this.setParams(opts.params || {}); - // @ts-ignore - this.__schema = this.__schema; // @ts-ignore this.__type = this.__type; } @@ -305,16 +273,13 @@ export class AggConfig { id: this.id, enabled: this.enabled, type: this.type && this.type.name, - schema: _.get(this, 'schema.name', this.schema), + schema: this.schema, params: outParams, }; } getAggParams() { - return [ - ...(_.has(this, 'type.params') ? this.type.params : []), - ...(_.has(this, 'schema.params') ? (this.schema as Schema).params : []), - ]; + return [...(_.has(this, 'type.params') ? this.type.params : [])]; } getRequestAggs() { @@ -434,9 +399,6 @@ export class AggConfig { // clear out the previous params except for a few special ones this.setParams({ - // split row/columns is "outside" of the agg, so don't reset it - row: this.params.row, - // almost every agg has fields, so we try to persist that when type changes field: availableFields.find((field: any) => field.name === this.getField()), }); @@ -445,17 +407,4 @@ export class AggConfig { public setType(type: IAggType) { this.type = type; } - - public get schema() { - return this.__schema; - } - - public set schema(schema) { - this.__schema = schema; - } - - public setSchema(schema: string | Schema) { - this.schema = - typeof schema === 'string' ? getSchemaFromRegistry(this.aggConfigs.schemas, schema) : schema; - } } diff --git a/src/legacy/core_plugins/data/public/search/aggs/agg_configs.test.ts b/src/legacy/core_plugins/data/public/search/aggs/agg_configs.test.ts index 49eed55f0233d..d69376b4026d9 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/agg_configs.test.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/agg_configs.test.ts @@ -21,8 +21,6 @@ import { indexBy } from 'lodash'; import { AggConfig } from './agg_config'; import { AggConfigs } from './agg_configs'; import { AggTypesRegistryStart } from './agg_types_registry'; -import { Schemas } from './schemas'; -import { AggGroupNames } from './agg_groups'; import { mockDataServices, mockAggTypesRegistry } from './test_helpers'; import { IndexPatternField, IndexPattern } from '../../../../../../plugins/data/public'; import { @@ -81,67 +79,6 @@ describe('AggConfigs', () => { expect(spy.mock.calls[0]).toEqual([configStates]); spy.mockRestore(); }); - - describe('defaults', () => { - const schemas = new Schemas([ - { - group: AggGroupNames.Metrics, - name: 'metric', - title: 'Simple', - min: 1, - max: 2, - defaults: [ - { schema: 'metric', type: 'count' }, - { schema: 'metric', type: 'avg' }, - { schema: 'metric', type: 'sum' }, - ], - }, - { - group: AggGroupNames.Buckets, - name: 'segment', - title: 'Example', - min: 0, - max: 1, - defaults: [ - { schema: 'segment', type: 'terms' }, - { schema: 'segment', type: 'filters' }, - ], - }, - ]); - - it('should only set the number of defaults defined by the max', () => { - const ac = new AggConfigs(indexPattern, [], { - schemas: schemas.all, - typesRegistry, - }); - expect(ac.bySchemaName('metric')).toHaveLength(2); - }); - - it('should set the defaults defined in the schema when none exist', () => { - const ac = new AggConfigs(indexPattern, [], { - schemas: schemas.all, - typesRegistry, - }); - expect(ac.aggs).toHaveLength(3); - }); - - it('should NOT set the defaults defined in the schema when some exist', () => { - const configStates = [ - { - enabled: true, - type: 'date_histogram', - params: {}, - schema: 'segment', - }, - ]; - const ac = new AggConfigs(indexPattern, configStates, { - schemas: schemas.all, - typesRegistry, - }); - expect(ac.aggs).toHaveLength(3); - expect(ac.bySchemaName('segment')[0].type.name).toEqual('date_histogram'); - }); - }); }); describe('#createAggConfig', () => { @@ -285,17 +222,6 @@ describe('AggConfigs', () => { }); describe('#toDsl', () => { - const schemas = new Schemas([ - { - group: AggGroupNames.Buckets, - name: 'segment', - }, - { - group: AggGroupNames.Buckets, - name: 'split', - }, - ]); - beforeEach(() => { indexPattern = stubIndexPattern as IndexPattern; indexPattern.fields.getByName = name => (name as unknown) as IndexPatternField; @@ -319,7 +245,6 @@ describe('AggConfigs', () => { const ac = new AggConfigs(indexPattern, configStates, { typesRegistry, - schemas: schemas.all, }); const aggInfos = ac.aggs.map(aggConfig => { @@ -390,11 +315,10 @@ describe('AggConfigs', () => { const ac = new AggConfigs(indexPattern, configStates, { typesRegistry, - schemas: schemas.all, }); const dsl = ac.toDsl(); const histo = ac.byName('date_histogram')[0]; - const metrics = ac.bySchemaGroup('metrics'); + const metrics = ac.bySchemaName('metrics'); expect(dsl).toHaveProperty(histo.id); expect(typeof dsl[histo.id]).toBe('object'); @@ -418,8 +342,8 @@ describe('AggConfigs', () => { const ac = new AggConfigs(indexPattern, configStates, { typesRegistry }); const topLevelDsl = ac.toDsl(true); - const buckets = ac.bySchemaGroup('buckets'); - const metrics = ac.bySchemaGroup('metrics'); + const buckets = ac.bySchemaName('buckets'); + const metrics = ac.bySchemaName('metrics'); (function checkLevel(dsl) { const bucket = buckets.shift(); diff --git a/src/legacy/core_plugins/data/public/search/aggs/agg_configs.ts b/src/legacy/core_plugins/data/public/search/aggs/agg_configs.ts index ab70e66b1e138..4a48f356d3f79 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/agg_configs.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/agg_configs.ts @@ -23,7 +23,6 @@ import { Assign } from '@kbn/utility-types'; import { AggConfig, AggConfigOptions, IAggConfig } from './agg_config'; import { IAggType } from './agg_type'; import { AggTypesRegistryStart } from './agg_types_registry'; -import { Schema } from './schemas'; import { AggGroupNames } from './agg_groups'; import { IndexPattern, @@ -32,8 +31,6 @@ import { TimeRange, } from '../../../../../../plugins/data/public'; -type Schemas = Record; - function removeParentAggs(obj: any) { for (const prop in obj) { if (prop === 'parentAggs') delete obj[prop]; @@ -51,7 +48,6 @@ function parseParentAggs(dslLvlCursor: any, dsl: any) { } export interface AggConfigsOptions { - schemas?: Schemas; typesRegistry: AggTypesRegistryStart; } @@ -73,7 +69,6 @@ export type IAggConfigs = AggConfigs; export class AggConfigs { public indexPattern: IndexPattern; - public schemas: any; public timeRange?: TimeRange; private readonly typesRegistry: AggTypesRegistryStart; @@ -90,37 +85,8 @@ export class AggConfigs { this.aggs = []; this.indexPattern = indexPattern; - this.schemas = opts.schemas; configStates.forEach((params: any) => this.createAggConfig(params)); - - if (this.schemas) { - this.initializeDefaultsFromSchemas(this.schemas); - } - } - - // do this wherever the schemas were passed in, & pass in state defaults instead - initializeDefaultsFromSchemas(schemas: Schemas) { - // Set the defaults for any schema which has them. If the defaults - // for some reason has more then the max only set the max number - // of defaults (not sure why a someone define more... - // but whatever). Also if a schema.name is already set then don't - // set anything. - _(schemas) - .filter((schema: Schema) => { - return Array.isArray(schema.defaults) && schema.defaults.length > 0; - }) - .each((schema: any) => { - if (!this.aggs.find((agg: AggConfig) => agg.schema && agg.schema.name === schema.name)) { - // the result here should be passable as a configState - const defaults = schema.defaults.slice(0, schema.max); - _.each(defaults, defaultState => { - const state = _.defaults({ id: AggConfig.nextId(this.aggs) }, defaultState); - this.createAggConfig(state as AggConfigOptions); - }); - } - }) - .commit(); } setTimeRange(timeRange: TimeRange) { @@ -148,7 +114,6 @@ export class AggConfigs { }; const aggConfigs = new AggConfigs(this.indexPattern, this.aggs.filter(filterAggs), { - schemas: this.schemas, typesRegistry: this.typesRegistry, }); @@ -271,23 +236,19 @@ export class AggConfigs { } byName(name: string) { - return this.aggs.filter(agg => agg.type.name === name); + return this.aggs.filter(agg => agg.type?.name === name); } byType(type: string) { - return this.aggs.filter(agg => agg.type.type === type); + return this.aggs.filter(agg => agg.type?.type === type); } byTypeName(type: string) { - return this.aggs.filter(agg => agg.type.name === type); + return this.byName(type); } bySchemaName(schema: string) { - return this.aggs.filter(agg => agg.schema && agg.schema.name === schema); - } - - bySchemaGroup(group: string) { - return this.aggs.filter(agg => agg.schema && agg.schema.group === group); + return this.aggs.filter(agg => agg.schema === schema); } getRequestAggs(): AggConfig[] { diff --git a/src/legacy/core_plugins/data/public/search/aggs/buckets/terms.ts b/src/legacy/core_plugins/data/public/search/aggs/buckets/terms.ts index 8fd95c86d8476..b387e9b7d306a 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/buckets/terms.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/buckets/terms.ts @@ -40,8 +40,6 @@ import { mergeOtherBucketAggResponse, updateMissingBucket, } from './_terms_other_bucket_helper'; -import { Schemas } from '../schemas'; -import { AggGroupNames } from '../agg_groups'; export const termsAggFilter = [ '!top_hits', @@ -58,17 +56,6 @@ export const termsAggFilter = [ '!sum_bucket', ]; -const [orderAggSchema] = new Schemas([ - { - group: AggGroupNames.None, - name: 'orderAgg', - // This string is never visible to the user so it doesn't need to be translated - title: 'Order Agg', - hideCustomLabel: true, - aggFilter: termsAggFilter, - }, -]).all; - const termsTitle = i18n.translate('data.search.aggs.buckets.termsTitle', { defaultMessage: 'Terms', }); @@ -158,10 +145,11 @@ export const termsBucketAgg = new BucketAggType({ { name: 'orderAgg', type: 'agg', + allowedAggs: termsAggFilter, default: null, makeAgg(termsAgg, state) { state = state || {}; - state.schema = orderAggSchema; + state.schema = 'orderAgg'; const orderAgg = termsAgg.aggConfigs.createAggConfig(state, { addToAggConfigs: false, }); diff --git a/src/legacy/core_plugins/data/public/search/aggs/filter/agg_type_filters.test.ts b/src/legacy/core_plugins/data/public/search/aggs/filter/agg_type_filters.test.ts index 0de1c31d02f96..90c29675c0db2 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/filter/agg_type_filters.test.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/filter/agg_type_filters.test.ts @@ -32,7 +32,7 @@ describe('AggTypeFilters', () => { it('should filter nothing without registered filters', async () => { const aggTypes = [{ name: 'count' }, { name: 'sum' }] as IAggType[]; - const filtered = registry.filter(aggTypes, indexPattern, aggConfig); + const filtered = registry.filter(aggTypes, indexPattern, aggConfig, []); expect(filtered).toEqual(aggTypes); }); @@ -40,23 +40,23 @@ describe('AggTypeFilters', () => { const aggTypes = [{ name: 'count' }, { name: 'sum' }] as IAggType[]; const filter = jest.fn(); registry.addFilter(filter); - registry.filter(aggTypes, indexPattern, aggConfig); - expect(filter).toHaveBeenCalledWith(aggTypes[0], indexPattern, aggConfig); - expect(filter).toHaveBeenCalledWith(aggTypes[1], indexPattern, aggConfig); + registry.filter(aggTypes, indexPattern, aggConfig, []); + expect(filter).toHaveBeenCalledWith(aggTypes[0], indexPattern, aggConfig, []); + expect(filter).toHaveBeenCalledWith(aggTypes[1], indexPattern, aggConfig, []); }); it('should allow registered filters to filter out aggTypes', async () => { const aggTypes = [{ name: 'count' }, { name: 'sum' }, { name: 'avg' }] as IAggType[]; - let filtered = registry.filter(aggTypes, indexPattern, aggConfig); + let filtered = registry.filter(aggTypes, indexPattern, aggConfig, []); expect(filtered).toEqual(aggTypes); registry.addFilter(() => true); registry.addFilter(aggType => aggType.name !== 'count'); - filtered = registry.filter(aggTypes, indexPattern, aggConfig); + filtered = registry.filter(aggTypes, indexPattern, aggConfig, []); expect(filtered).toEqual([aggTypes[1], aggTypes[2]]); registry.addFilter(aggType => aggType.name !== 'avg'); - filtered = registry.filter(aggTypes, indexPattern, aggConfig); + filtered = registry.filter(aggTypes, indexPattern, aggConfig, []); expect(filtered).toEqual([aggTypes[1]]); }); }); diff --git a/src/legacy/core_plugins/data/public/search/aggs/filter/agg_type_filters.ts b/src/legacy/core_plugins/data/public/search/aggs/filter/agg_type_filters.ts index 13a4cc0856b09..8da547e592af9 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/filter/agg_type_filters.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/filter/agg_type_filters.ts @@ -23,7 +23,8 @@ import { IAggConfig, IAggType } from '../types'; type AggTypeFilter = ( aggType: IAggType, indexPattern: IndexPattern, - aggConfig: IAggConfig + aggConfig: IAggConfig, + aggFilter: string[] ) => boolean; /** @@ -48,12 +49,20 @@ class AggTypeFilters { * @param aggTypes A list of aggTypes that will be filtered down by this registry. * @param indexPattern The indexPattern for which this list should be filtered down. * @param aggConfig The aggConfig for which the returning list will be used. + * @param schema * @return A filtered list of the passed aggTypes. */ - public filter(aggTypes: IAggType[], indexPattern: IndexPattern, aggConfig: IAggConfig) { + public filter( + aggTypes: IAggType[], + indexPattern: IndexPattern, + aggConfig: IAggConfig, + aggFilter: string[] + ) { const allFilters = Array.from(this.filters); const allowedAggTypes = aggTypes.filter(aggType => { - const isAggTypeAllowed = allFilters.every(filter => filter(aggType, indexPattern, aggConfig)); + const isAggTypeAllowed = allFilters.every(filter => + filter(aggType, indexPattern, aggConfig, aggFilter) + ); return isAggTypeAllowed; }); return allowedAggTypes; diff --git a/src/legacy/core_plugins/data/public/search/aggs/index.ts b/src/legacy/core_plugins/data/public/search/aggs/index.ts index be44e04a0129b..8d6fbeacd606a 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/index.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/index.ts @@ -56,7 +56,6 @@ export { OptionedParamType } from './param_types/optioned'; export { isValidJson, isValidInterval } from './utils'; export { BUCKET_TYPES } from './buckets/bucket_agg_types'; export { METRIC_TYPES } from './metrics/metric_agg_types'; -export { ISchemas, Schema, Schemas } from './schemas'; // types export { CreateAggConfigParams, IAggConfig, IAggConfigs } from './types'; diff --git a/src/legacy/core_plugins/data/public/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts b/src/legacy/core_plugins/data/public/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts index 88549ee3019ee..df4cbaf49c8b3 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts @@ -23,7 +23,7 @@ import { noop, identity } from 'lodash'; import { forwardModifyAggConfigOnSearchRequestStart } from './nested_agg_helpers'; import { IMetricAggConfig, MetricAggParam } from '../metric_agg_type'; import { parentPipelineAggWriter } from './parent_pipeline_agg_writer'; -import { Schemas } from '../../schemas'; + import { fieldFormats } from '../../../../../../../../plugins/data/public'; const metricAggFilter = [ @@ -36,20 +36,6 @@ const metricAggFilter = [ '!geo_centroid', ]; -const metricAggTitle = i18n.translate('data.search.aggs.metrics.metricAggTitle', { - defaultMessage: 'Metric agg', -}); - -const [metricAggSchema] = new Schemas([ - { - group: 'none', - name: 'metricAgg', - title: metricAggTitle, - hideCustomLabel: true, - aggFilter: metricAggFilter, - }, -]).all; - const parentPipelineType = i18n.translate( 'data.search.aggs.metrics.parentPipelineAggregationsSubtypeTitle', { @@ -69,9 +55,9 @@ const parentPipelineAggHelper = { { name: 'customMetric', type: 'agg', + allowedAggs: metricAggFilter, makeAgg(termsAgg, state: any) { state = state || { type: 'count' }; - state.schema = metricAggSchema; const metricAgg = termsAgg.aggConfigs.createAggConfig(state, { addToAggConfigs: false }); diff --git a/src/legacy/core_plugins/data/public/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts b/src/legacy/core_plugins/data/public/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts index 05e009cc9da30..33d6d72540868 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts @@ -22,7 +22,6 @@ import { i18n } from '@kbn/i18n'; import { siblingPipelineAggWriter } from './sibling_pipeline_agg_writer'; import { forwardModifyAggConfigOnSearchRequestStart } from './nested_agg_helpers'; import { IMetricAggConfig, MetricAggParam } from '../metric_agg_type'; -import { Schemas } from '../../schemas'; import { fieldFormats } from '../../../../../../../../plugins/data/public'; const metricAggFilter: string[] = [ @@ -44,28 +43,6 @@ const metricAggFilter: string[] = [ ]; const bucketAggFilter: string[] = []; -const [metricAggSchema] = new Schemas([ - { - group: 'none', - name: 'metricAgg', - title: i18n.translate('data.search.aggs.metrics.metricAggTitle', { - defaultMessage: 'Metric agg', - }), - aggFilter: metricAggFilter, - }, -]).all; - -const [bucketAggSchema] = new Schemas([ - { - group: 'none', - title: i18n.translate('data.search.aggs.metrics.bucketAggTitle', { - defaultMessage: 'Bucket agg', - }), - name: 'bucketAgg', - aggFilter: bucketAggFilter, - }, -]).all; - const siblingPipelineType = i18n.translate( 'data.search.aggs.metrics.siblingPipelineAggregationsSubtypeTitle', { @@ -80,10 +57,10 @@ const siblingPipelineAggHelper = { { name: 'customBucket', type: 'agg', + allowedAggs: bucketAggFilter, default: null, makeAgg(agg: IMetricAggConfig, state: any) { state = state || { type: 'date_histogram' }; - state.schema = bucketAggSchema; const orderAgg = agg.aggConfigs.createAggConfig(state, { addToAggConfigs: false }); orderAgg.id = agg.id + '-bucket'; @@ -97,10 +74,10 @@ const siblingPipelineAggHelper = { { name: 'customMetric', type: 'agg', + allowedAggs: metricAggFilter, default: null, makeAgg(agg: IMetricAggConfig, state: any) { state = state || { type: 'count' }; - state.schema = metricAggSchema; const orderAgg = agg.aggConfigs.createAggConfig(state, { addToAggConfigs: false }); orderAgg.id = agg.id + '-metric'; diff --git a/src/legacy/core_plugins/data/public/search/aggs/metrics/metric_agg_type.ts b/src/legacy/core_plugins/data/public/search/aggs/metrics/metric_agg_type.ts index 952dcc96de833..82b042a1e3378 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/metrics/metric_agg_type.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/metrics/metric_agg_type.ts @@ -21,11 +21,11 @@ import { i18n } from '@kbn/i18n'; import { AggType, AggTypeConfig } from '../agg_type'; import { AggParamType } from '../param_types/agg'; import { AggConfig } from '../agg_config'; -import { FilterFieldTypes } from '../param_types/field'; import { METRIC_TYPES } from './metric_agg_types'; import { KBN_FIELD_TYPES } from '../../../../../../../plugins/data/public'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { getFieldFormats } from '../../../../../../../plugins/data/public/services'; +import { FieldTypes } from '../param_types'; export interface IMetricAggConfig extends AggConfig { type: InstanceType; @@ -33,7 +33,7 @@ export interface IMetricAggConfig extends AggConfig { export interface MetricAggParam extends AggParamType { - filterFieldTypes?: FilterFieldTypes; + filterFieldTypes?: FieldTypes; onlyAggregatable?: boolean; } diff --git a/src/legacy/core_plugins/data/public/search/aggs/metrics/parent_pipeline.test.ts b/src/legacy/core_plugins/data/public/search/aggs/metrics/parent_pipeline.test.ts index 58b4ee530a8c2..02e63f653f94f 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/metrics/parent_pipeline.test.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/metrics/parent_pipeline.test.ts @@ -25,15 +25,6 @@ import { AggConfigs } from '../agg_configs'; import { mockDataServices, mockAggTypesRegistry } from '../test_helpers'; import { IMetricAggConfig, MetricAggType } from './metric_agg_type'; -jest.mock('../schemas', () => { - class MockedSchemas { - all = [{}]; - } - return { - Schemas: jest.fn().mockImplementation(() => new MockedSchemas()), - }; -}); - describe('parent pipeline aggs', function() { beforeEach(() => { mockDataServices(); diff --git a/src/legacy/core_plugins/data/public/search/aggs/metrics/sibling_pipeline.test.ts b/src/legacy/core_plugins/data/public/search/aggs/metrics/sibling_pipeline.test.ts index d3456bacceb6a..8389ed8262ce5 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/metrics/sibling_pipeline.test.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/metrics/sibling_pipeline.test.ts @@ -26,15 +26,6 @@ import { AggConfigs } from '../agg_configs'; import { IMetricAggConfig, MetricAggType } from './metric_agg_type'; import { mockDataServices, mockAggTypesRegistry } from '../test_helpers'; -jest.mock('../schemas', () => { - class MockedSchemas { - all = [{}]; - } - return { - Schemas: jest.fn().mockImplementation(() => new MockedSchemas()), - }; -}); - describe('sibling pipeline aggs', () => { beforeEach(() => { mockDataServices(); diff --git a/src/legacy/core_plugins/data/public/search/aggs/metrics/top_hit.ts b/src/legacy/core_plugins/data/public/search/aggs/metrics/top_hit.ts index 3112d882bb87e..c850eb4ff2220 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/metrics/top_hit.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/metrics/top_hit.ts @@ -63,10 +63,7 @@ export const topHitMetricAgg = new MetricAggType({ name: 'field', type: 'field', onlyAggregatable: false, - filterFieldTypes: (aggConfig: IMetricAggConfig) => - _.get(aggConfig.schema, 'aggSettings.top_hits.allowStrings', false) - ? '*' - : KBN_FIELD_TYPES.NUMBER, + filterFieldTypes: '*', write(agg, output) { const field = agg.getParam('field'); output.params = {}; @@ -133,7 +130,7 @@ export const topHitMetricAgg = new MetricAggType({ defaultMessage: 'Concatenate', }), isCompatible(aggConfig: IMetricAggConfig) { - return _.get(aggConfig.schema, 'aggSettings.top_hits.allowStrings', false); + return _.get(aggConfig.params, 'field.filterFieldTypes', '*') === '*'; }, disabled: true, value: 'concat', diff --git a/src/legacy/core_plugins/data/public/search/aggs/param_types/agg.ts b/src/legacy/core_plugins/data/public/search/aggs/param_types/agg.ts index d31abe64491d0..e5b53020c3159 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/param_types/agg.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/param_types/agg.ts @@ -24,10 +24,15 @@ export class AggParamType extends Ba TAggConfig > { makeAgg: (agg: TAggConfig, state?: any) => TAggConfig; + allowedAggs: string[] = []; constructor(config: Record) { super(config); + if (config.allowedAggs) { + this.allowedAggs = config.allowedAggs; + } + if (!config.write) { this.write = (aggConfig: TAggConfig, output: Record) => { if (aggConfig.params[this.name] && aggConfig.params[this.name].length) { diff --git a/src/legacy/core_plugins/data/public/search/aggs/param_types/field.test.ts b/src/legacy/core_plugins/data/public/search/aggs/param_types/field.test.ts index 7338c41f920d7..18b666f454664 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/param_types/field.test.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/param_types/field.test.ts @@ -17,13 +17,10 @@ * under the License. */ -import { get } from 'lodash'; import { BaseParamType } from './base'; import { FieldParamType } from './field'; import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '../../../../../../../plugins/data/public'; import { IAggConfig } from '../agg_config'; -import { IMetricAggConfig } from '../metrics/metric_agg_type'; -import { Schema } from '../schemas'; describe('Field', () => { const indexPattern = { @@ -105,43 +102,5 @@ describe('Field', () => { expect(fields.length).toBe(2); }); - - it('should return only numeric fields if filterFieldTypes was specified as a function', () => { - const aggParam = new FieldParamType({ - name: 'field', - type: 'field', - filterFieldTypes: (aggConfig: IMetricAggConfig) => - get(aggConfig.schema, 'aggSettings.top_hits.allowStrings', false) - ? '*' - : KBN_FIELD_TYPES.NUMBER, - }); - const fields = aggParam.getAvailableFields(agg); - - expect(fields.length).toBe(1); - expect(fields[0].type).toBe(KBN_FIELD_TYPES.NUMBER); - }); - - it('should return all fields if filterFieldTypes was specified as a function and aggSettings allow string type fields', () => { - const aggParam = new FieldParamType({ - name: 'field', - type: 'field', - filterFieldTypes: (aggConfig: IMetricAggConfig) => - get(aggConfig.schema, 'aggSettings.top_hits.allowStrings', false) - ? '*' - : KBN_FIELD_TYPES.NUMBER, - }); - - agg.schema = { - aggSettings: { - top_hits: { - allowStrings: true, - }, - }, - } as Schema; - - const fields = aggParam.getAvailableFields(agg); - - expect(fields.length).toBe(2); - }); }); }); diff --git a/src/legacy/core_plugins/data/public/search/aggs/param_types/field.ts b/src/legacy/core_plugins/data/public/search/aggs/param_types/field.ts index bb5707cbb482e..6882b8aa39e7e 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/param_types/field.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/param_types/field.ts @@ -18,12 +18,10 @@ */ import { i18n } from '@kbn/i18n'; -import { isFunction } from 'lodash'; import { IAggConfig } from '../agg_config'; import { SavedObjectNotFound } from '../../../../../../../plugins/kibana_utils/public'; import { BaseParamType } from './base'; import { propFilter } from '../filter'; -import { IMetricAggConfig } from '../metrics/metric_agg_type'; import { IndexPatternField, indexPatterns, @@ -34,15 +32,14 @@ import { getNotifications } from '../../../../../../../plugins/data/public/servi const filterByType = propFilter('type'); -type FieldTypes = KBN_FIELD_TYPES | KBN_FIELD_TYPES[] | '*'; -export type FilterFieldTypes = ((aggConfig: IMetricAggConfig) => FieldTypes) | FieldTypes; +export type FieldTypes = KBN_FIELD_TYPES | KBN_FIELD_TYPES[] | '*'; // TODO need to make a more explicit interface for this export type IFieldParamType = FieldParamType; export class FieldParamType extends BaseParamType { required = true; scriptable = true; - filterFieldTypes: FilterFieldTypes; + filterFieldTypes: FieldTypes; onlyAggregatable: boolean; constructor(config: Record) { @@ -127,12 +124,6 @@ export class FieldParamType extends BaseParamType { return false; } - if (isFunction(filterFieldTypes)) { - const filter = filterFieldTypes(aggConfig as IMetricAggConfig); - - return filterByType([field], filter).length !== 0; - } - return filterByType([field], filterFieldTypes).length !== 0; }); diff --git a/src/legacy/core_plugins/data/public/search/aggs/types.ts b/src/legacy/core_plugins/data/public/search/aggs/types.ts index 5d02f426b5896..069a933fd994a 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/types.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/types.ts @@ -26,4 +26,3 @@ export { IMetricAggType } from './metrics/metric_agg_type'; export { DateRangeKey } from './buckets/date_range'; export { IpRangeKey } from './buckets/ip_range'; export { OptionedValueProp, OptionedParamEditorProps } from './param_types/optioned'; -export { ISchemas } from './schemas'; diff --git a/src/legacy/core_plugins/data/public/search/mocks.ts b/src/legacy/core_plugins/data/public/search/mocks.ts index 5629f597edff4..46c26dc8f1bd0 100644 --- a/src/legacy/core_plugins/data/public/search/mocks.ts +++ b/src/legacy/core_plugins/data/public/search/mocks.ts @@ -71,7 +71,6 @@ export const searchStartMock = (): SearchStart => ({ calculateAutoTimeExpression: getCalculateAutoTimeExpression(coreMock.createStart().uiSettings), createAggConfigs: jest.fn().mockImplementation((indexPattern, configStates = [], schemas) => { return new AggConfigs(indexPattern, configStates, { - schemas, typesRegistry: mockAggTypesRegistry(), }); }), diff --git a/src/legacy/core_plugins/data/public/search/search_service.ts b/src/legacy/core_plugins/data/public/search/search_service.ts index a38cc98c837ce..2d01ac446d951 100644 --- a/src/legacy/core_plugins/data/public/search/search_service.ts +++ b/src/legacy/core_plugins/data/public/search/search_service.ts @@ -99,7 +99,6 @@ export class SearchService { calculateAutoTimeExpression: getCalculateAutoTimeExpression(core.uiSettings), createAggConfigs: (indexPattern, configStates = [], schemas) => { return new AggConfigs(indexPattern, configStates, { - schemas, typesRegistry: aggTypesStart, }); }, diff --git a/src/legacy/core_plugins/data/public/search/tabify/get_columns.test.ts b/src/legacy/core_plugins/data/public/search/tabify/get_columns.test.ts index 6c5dc790ef976..b7dadc3f65d82 100644 --- a/src/legacy/core_plugins/data/public/search/tabify/get_columns.test.ts +++ b/src/legacy/core_plugins/data/public/search/tabify/get_columns.test.ts @@ -19,7 +19,7 @@ import { tabifyGetColumns } from './get_columns'; import { TabbedAggColumn } from './types'; -import { AggConfigs, AggGroupNames, Schemas } from '../aggs'; +import { AggConfigs } from '../aggs'; import { mockAggTypesRegistry, mockDataServices } from '../aggs/test_helpers'; describe('get columns', () => { @@ -45,26 +45,10 @@ describe('get columns', () => { return new AggConfigs(indexPattern, aggs, { typesRegistry, - schemas: new Schemas([ - { - group: AggGroupNames.Metrics, - name: 'metric', - min: 1, - defaults: [{ schema: 'metric', type: 'count' }], - }, - ]).all, }); }; - test('should inject a count metric if no aggs exist', () => { - const columns = tabifyGetColumns(createAggConfigs().aggs, true); - - expect(columns).toHaveLength(1); - expect(columns[0]).toHaveProperty('aggConfig'); - expect(columns[0].aggConfig.type).toHaveProperty('name', 'count'); - }); - - test('should inject a count metric if only buckets exist', () => { + test('should inject the metric after each bucket if the vis is hierarchical', () => { const columns = tabifyGetColumns( createAggConfigs([ { @@ -72,18 +56,6 @@ describe('get columns', () => { schema: 'segment', params: { field: '@timestamp', interval: '10s' }, }, - ]).aggs, - true - ); - - expect(columns).toHaveLength(2); - expect(columns[1]).toHaveProperty('aggConfig'); - expect(columns[1].aggConfig.type).toHaveProperty('name', 'count'); - }); - - test('should inject the metric after each bucket if the vis is hierarchical', () => { - const columns = tabifyGetColumns( - createAggConfigs([ { type: 'date_histogram', schema: 'segment', @@ -100,9 +72,7 @@ describe('get columns', () => { params: { field: '@timestamp', interval: '10s' }, }, { - type: 'date_histogram', - schema: 'segment', - params: { field: '@timestamp', interval: '10s' }, + type: 'count', }, ]).aggs, false diff --git a/src/legacy/core_plugins/data/public/search/tabify/response_writer.test.ts b/src/legacy/core_plugins/data/public/search/tabify/response_writer.test.ts index 94301eedac74a..91835bc948abb 100644 --- a/src/legacy/core_plugins/data/public/search/tabify/response_writer.test.ts +++ b/src/legacy/core_plugins/data/public/search/tabify/response_writer.test.ts @@ -18,7 +18,7 @@ */ import { TabbedAggResponseWriter } from './response_writer'; -import { AggConfigs, AggGroupNames, Schemas, BUCKET_TYPES } from '../aggs'; +import { AggConfigs, BUCKET_TYPES } from '../aggs'; import { mockDataServices, mockAggTypesRegistry } from '../aggs/test_helpers'; import { TabbedResponseWriterOptions } from './types'; @@ -39,6 +39,7 @@ describe('TabbedAggResponseWriter class', () => { field: 'geo.src', }, }, + { type: 'count' }, ]; const twoSplitsAggConfig = [ @@ -54,6 +55,7 @@ describe('TabbedAggResponseWriter class', () => { field: 'machine.os.raw', }, }, + { type: 'count' }, ]; const createResponseWritter = (aggs: any[] = [], opts?: Partial) => { @@ -73,14 +75,6 @@ describe('TabbedAggResponseWriter class', () => { return new TabbedAggResponseWriter( new AggConfigs(indexPattern, aggs, { typesRegistry, - schemas: new Schemas([ - { - group: AggGroupNames.Metrics, - name: 'metric', - min: 1, - defaults: [{ schema: 'metric', type: 'count' }], - }, - ]).all, }), { metricsAtAllLevels: false, diff --git a/src/legacy/core_plugins/data/public/search/tabify/tabify.test.ts b/src/legacy/core_plugins/data/public/search/tabify/tabify.test.ts index db4ad3bdea96b..7e7748c00ab43 100644 --- a/src/legacy/core_plugins/data/public/search/tabify/tabify.test.ts +++ b/src/legacy/core_plugins/data/public/search/tabify/tabify.test.ts @@ -19,7 +19,7 @@ import { IndexPattern } from '../../../../../../plugins/data/public'; import { tabifyAggResponse } from './tabify'; -import { IAggConfig, IAggConfigs, AggGroupNames, Schemas, AggConfigs } from '../aggs'; +import { IAggConfig, IAggConfigs, AggConfigs } from '../aggs'; import { mockAggTypesRegistry } from '../aggs/test_helpers'; import { metricOnly, threeTermBuckets } from 'fixtures/fake_hierarchical_data'; @@ -42,21 +42,13 @@ describe('tabifyAggResponse Integration', () => { return new AggConfigs(indexPattern, aggs, { typesRegistry, - schemas: new Schemas([ - { - group: AggGroupNames.Metrics, - name: 'metric', - min: 1, - defaults: [{ schema: 'metric', type: 'count' }], - }, - ]).all, }); }; const mockAggConfig = (agg: any): IAggConfig => (agg as unknown) as IAggConfig; test('transforms a simple response properly', () => { - const aggConfigs = createAggConfigs(); + const aggConfigs = createAggConfigs([{ type: 'count' } as any]); const resp = tabifyAggResponse(aggConfigs, metricOnly, { metricsAtAllLevels: true, diff --git a/src/legacy/core_plugins/input_control_vis/public/components/editor/field_select.tsx b/src/legacy/core_plugins/input_control_vis/public/components/editor/field_select.tsx index bde2f09ab0a47..68cca9bf6c4f2 100644 --- a/src/legacy/core_plugins/input_control_vis/public/components/editor/field_select.tsx +++ b/src/legacy/core_plugins/input_control_vis/public/components/editor/field_select.tsx @@ -22,13 +22,13 @@ import React, { Component } from 'react'; import { InjectedIntlProps } from 'react-intl'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; -import { EuiFormRow, EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { IIndexPattern, IFieldType } from '../../../../../../plugins/data/public'; interface FieldSelectUiState { isLoading: boolean; - fields: Array>; + fields: Array>; indexPatternId: string; } @@ -105,7 +105,7 @@ class FieldSelectUi extends Component { } const fieldsByTypeMap = new Map(); - const fields: Array> = []; + const fields: Array> = []; indexPattern.fields .filter(this.props.filterField ?? (() => true)) .forEach((field: IFieldType) => { @@ -135,7 +135,7 @@ class FieldSelectUi extends Component { }); }, 300); - onChange = (selectedOptions: Array>) => { + onChange = (selectedOptions: Array>) => { this.props.onChange(_.get(selectedOptions, '0.value')); }; diff --git a/src/legacy/core_plugins/input_control_vis/public/components/vis/list_control.tsx b/src/legacy/core_plugins/input_control_vis/public/components/vis/list_control.tsx index d01cef15ea41b..6ded66917a3fd 100644 --- a/src/legacy/core_plugins/input_control_vis/public/components/vis/list_control.tsx +++ b/src/legacy/core_plugins/input_control_vis/public/components/vis/list_control.tsx @@ -76,7 +76,7 @@ class ListControlUi extends PureComponent { + setTextInputRef = (ref: HTMLInputElement | null) => { this.textInput = ref; }; diff --git a/src/legacy/core_plugins/input_control_vis/public/input_control_vis_type.ts b/src/legacy/core_plugins/input_control_vis/public/input_control_vis_type.ts index 9473ea5a20b35..1bdff06b3a59f 100644 --- a/src/legacy/core_plugins/input_control_vis/public/input_control_vis_type.ts +++ b/src/legacy/core_plugins/input_control_vis/public/input_control_vis_type.ts @@ -34,7 +34,7 @@ export function createInputControlVisTypeDefinition(deps: InputControlVisDepende title: i18n.translate('inputControl.register.controlsTitle', { defaultMessage: 'Controls', }), - icon: 'visControls', + icon: 'controlsHorizontal', description: i18n.translate('inputControl.register.controlsDescription', { defaultMessage: 'Create interactive controls for easy dashboard manipulation.', }), diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html index 3fd3c5b5b7633..18254aeca5094 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html @@ -23,7 +23,7 @@

{{screenTitle}}

-
diff --git a/src/plugins/status_page/kibana.json b/src/plugins/status_page/kibana.json index edebf8cb12239..0d54f6a39e2b1 100644 --- a/src/plugins/status_page/kibana.json +++ b/src/plugins/status_page/kibana.json @@ -1,5 +1,5 @@ { - "id": "status_page", + "id": "statusPage", "version": "kibana", "server": false, "ui": true diff --git a/test/functional/apps/discover/_discover_histogram.js b/test/functional/apps/discover/_discover_histogram.js index 4780f36fc27c6..9310838666256 100644 --- a/test/functional/apps/discover/_discover_histogram.js +++ b/test/functional/apps/discover/_discover_histogram.js @@ -23,6 +23,7 @@ export default function({ getService, getPageObjects }) { const log = getService('log'); const esArchiver = getService('esArchiver'); const browser = getService('browser'); + const elasticChart = getService('elasticChart'); const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['settings', 'common', 'discover', 'header', 'timePicker']); const defaultSettings = { @@ -64,7 +65,7 @@ export default function({ getService, getPageObjects }) { await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await PageObjects.discover.setChartInterval('Monthly'); await PageObjects.header.waitUntilLoadingHasFinished(); - const chartCanvasExist = await PageObjects.discover.chartCanvasExist(); + const chartCanvasExist = await elasticChart.canvasExists(); expect(chartCanvasExist).to.be(true); }); it('should visualize weekly data with within DST changes', async () => { @@ -74,7 +75,7 @@ export default function({ getService, getPageObjects }) { await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await PageObjects.discover.setChartInterval('Weekly'); await PageObjects.header.waitUntilLoadingHasFinished(); - const chartCanvasExist = await PageObjects.discover.chartCanvasExist(); + const chartCanvasExist = await elasticChart.canvasExists(); expect(chartCanvasExist).to.be(true); }); it('should visualize monthly data with different years Scaled to 30d', async () => { @@ -84,7 +85,7 @@ export default function({ getService, getPageObjects }) { await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await PageObjects.discover.setChartInterval('Daily'); await PageObjects.header.waitUntilLoadingHasFinished(); - const chartCanvasExist = await PageObjects.discover.chartCanvasExist(); + const chartCanvasExist = await elasticChart.canvasExists(); expect(chartCanvasExist).to.be(true); }); }); diff --git a/test/functional/apps/discover/_source_filters.js b/test/functional/apps/discover/_source_filters.js index e9cb2f3d622a2..74d0da7cdb3e7 100644 --- a/test/functional/apps/discover/_source_filters.js +++ b/test/functional/apps/discover/_source_filters.js @@ -49,7 +49,6 @@ export default function({ getService, getPageObjects }) { }); it('should not get the field referer', async function() { - //let fieldNames; const fieldNames = await PageObjects.discover.getAllFieldNames(); expect(fieldNames).to.not.contain('referer'); const relatedContentFields = fieldNames.filter( diff --git a/test/functional/page_objects/discover_page.js b/test/functional/page_objects/discover_page.ts similarity index 58% rename from test/functional/page_objects/discover_page.js rename to test/functional/page_objects/discover_page.ts index 46aadc63c64f1..f018a1ceda507 100644 --- a/test/functional/page_objects/discover_page.js +++ b/test/functional/page_objects/discover_page.ts @@ -18,40 +18,34 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; -export function DiscoverPageProvider({ getService, getPageObjects }) { +export function DiscoverPageProvider({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const retry = getService('retry'); const testSubjects = getService('testSubjects'); const find = getService('find'); const flyout = getService('flyout'); - const PageObjects = getPageObjects(['header', 'common']); + const { header } = getPageObjects(['header']); const browser = getService('browser'); const globalNav = getService('globalNav'); const config = getService('config'); const defaultFindTimeout = config.get('timeouts.find'); const elasticChart = getService('elasticChart'); + const docTable = getService('docTable'); class DiscoverPage { - async getQueryField() { - return await find.byCssSelector("input[ng-model='state.query']"); - } - - async getQuerySearchButton() { - return await find.byCssSelector("button[aria-label='Search']"); - } - - async getChartTimespan() { + public async getChartTimespan() { const el = await find.byCssSelector('.small > label[for="dscResultsIntervalSelector"]'); return await el.getVisibleText(); } - async saveSearch(searchName) { + public async saveSearch(searchName: string) { log.debug('saveSearch'); await this.clickSaveSearchButton(); await testSubjects.setValue('savedObjectTitle', searchName); await testSubjects.click('confirmSaveSavedObjectButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); + await header.waitUntilLoadingHasFinished(); // LeeDr - this additional checking for the saved search name was an attempt // to cause this method to wait for the reloading of the page to complete so // that the next action wouldn't have to retry. But it doesn't really solve @@ -63,30 +57,29 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { }); } - async inputSavedSearchTitle(searchName) { + public async inputSavedSearchTitle(searchName: string) { await testSubjects.setValue('savedObjectTitle', searchName); } - async clickConfirmSavedSearch() { + public async clickConfirmSavedSearch() { await testSubjects.click('confirmSaveSavedObjectButton'); } - async openAddFilterPanel() { + public async openAddFilterPanel() { await testSubjects.click('addFilter'); } - async waitUntilSearchingHasFinished() { + public async waitUntilSearchingHasFinished() { const spinner = await testSubjects.find('loadingSpinner'); await find.waitForElementHidden(spinner, defaultFindTimeout * 10); } - async getColumnHeaders() { - const headerElements = await testSubjects.findAll('docTableHeaderField'); - return await Promise.all(headerElements.map(async el => await el.getVisibleText())); + public async getColumnHeaders() { + return await docTable.getHeaderFields('embeddedSavedSearchDocTable'); } - async openLoadSavedSearchPanel() { - const isOpen = await testSubjects.exists('loadSearchForm'); + public async openLoadSavedSearchPanel() { + let isOpen = await testSubjects.exists('loadSearchForm'); if (isOpen) { return; } @@ -95,55 +88,47 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { // saving a search cause reloading of the page and the "Open" menu item goes stale. await retry.try(async () => { await this.clickLoadSavedSearchButton(); - await PageObjects.header.waitUntilLoadingHasFinished(); - const isOpen = await testSubjects.exists('loadSearchForm'); + await header.waitUntilLoadingHasFinished(); + isOpen = await testSubjects.exists('loadSearchForm'); expect(isOpen).to.be(true); }); } - async closeLoadSaveSearchPanel() { + public async closeLoadSaveSearchPanel() { await flyout.ensureClosed('loadSearchForm'); } - async hasSavedSearch(searchName) { + public async hasSavedSearch(searchName: string) { const searchLink = await find.byButtonText(searchName); - return searchLink.isDisplayed(); + return await searchLink.isDisplayed(); } - async loadSavedSearch(searchName) { + public async loadSavedSearch(searchName: string) { await this.openLoadSavedSearchPanel(); const searchLink = await find.byButtonText(searchName); await searchLink.click(); - await PageObjects.header.waitUntilLoadingHasFinished(); + await header.waitUntilLoadingHasFinished(); } - async clickNewSearchButton() { + public async clickNewSearchButton() { await testSubjects.click('discoverNewButton'); } - async clickSaveSearchButton() { + public async clickSaveSearchButton() { await testSubjects.click('discoverSaveButton'); } - async clickLoadSavedSearchButton() { + public async clickLoadSavedSearchButton() { await testSubjects.moveMouseTo('discoverOpenButton'); await testSubjects.click('discoverOpenButton'); } - async closeLoadSavedSearchPanel() { + public async closeLoadSavedSearchPanel() { await testSubjects.click('euiFlyoutCloseButton'); } - async getChartCanvas() { - return await find.byCssSelector('.echChart canvas:last-of-type'); - } - - async chartCanvasExist() { - return await find.existsByCssSelector('.echChart canvas:last-of-type'); - } - - async clickHistogramBar() { - const el = await this.getChartCanvas(); + public async clickHistogramBar() { + const el = await elasticChart.getCanvas(); await browser .getActions() @@ -152,8 +137,8 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { .perform(); } - async brushHistogram() { - const el = await this.getChartCanvas(); + public async brushHistogram() { + const el = await elasticChart.getCanvas(); await browser.dragAndDrop( { location: el, offset: { x: 200, y: 20 } }, @@ -161,169 +146,154 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { ); } - async getCurrentQueryName() { + public async getCurrentQueryName() { return await globalNav.getLastBreadcrumb(); } - async getBarChartData() { - let yAxisLabel = 0; - - await PageObjects.header.waitUntilLoadingHasFinished(); - const y = await find.byCssSelector( - 'div.visAxis__splitAxes--y > div > svg > g > g:last-of-type' - ); - const yLabel = await y.getVisibleText(); - yAxisLabel = yLabel.replace(',', ''); - log.debug('yAxisLabel = ' + yAxisLabel); - // #kibana-body > div.content > div > div > div > div.visEditor__canvas > visualize > div.visChart > div > div.visWrapper__column > div.visWrapper__chart > div > svg > g > g.series.\30 > rect:nth-child(1) - const svg = await find.byCssSelector('div.chart > svg'); - const $ = await svg.parseDomContent(); - const yAxisHeight = $('rect.background').attr('height'); - log.debug('theHeight = ' + yAxisHeight); - const bars = $('g > g.series > rect') - .toArray() - .map(chart => { - const barHeight = $(chart).attr('height'); - return Math.round((barHeight / yAxisHeight) * yAxisLabel); - }); - - return bars; - } - - async getChartInterval() { + public async getChartInterval() { const selectedValue = await testSubjects.getAttribute('discoverIntervalSelect', 'value'); - const selectedOption = await find.byCssSelector('option[value="' + selectedValue + '"]'); + const selectedOption = await find.byCssSelector(`option[value="${selectedValue}"]`); return selectedOption.getVisibleText(); } - async setChartInterval(interval) { - const optionElement = await find.byCssSelector('option[label="' + interval + '"]', 5000); + public async setChartInterval(interval: string) { + const optionElement = await find.byCssSelector(`option[label="${interval}"]`, 5000); await optionElement.click(); - return await PageObjects.header.waitUntilLoadingHasFinished(); + return await header.waitUntilLoadingHasFinished(); } - async getHitCount() { - await PageObjects.header.waitUntilLoadingHasFinished(); + public async getHitCount() { + await header.waitUntilLoadingHasFinished(); return await testSubjects.getVisibleText('discoverQueryHits'); } - async getDocHeader() { - const header = await find.byCssSelector('thead > tr:nth-child(1)'); - return await header.getVisibleText(); + public async getDocHeader() { + const docHeader = await find.byCssSelector('thead > tr:nth-child(1)'); + return await docHeader.getVisibleText(); } - async getDocTableIndex(index) { - const row = await find.byCssSelector('tr.kbnDocTable__row:nth-child(' + index + ')'); + public async getDocTableIndex(index: number) { + const row = await find.byCssSelector(`tr.kbnDocTable__row:nth-child(${index})`); return await row.getVisibleText(); } - async getDocTableField(index) { + public async getDocTableField(index: number) { const field = await find.byCssSelector( `tr.kbnDocTable__row:nth-child(${index}) > [data-test-subj='docTableField']` ); return await field.getVisibleText(); } - async clickDocSortDown() { + public async clickDocSortDown() { await find.clickByCssSelector('.fa-sort-down'); } - async clickDocSortUp() { + public async clickDocSortUp() { await find.clickByCssSelector('.fa-sort-up'); } - async getMarks() { - const marks = await find.allByCssSelector('mark'); - return await Promise.all(marks.map(mark => mark.getVisibleText())); + public async getMarks() { + const table = await docTable.getTable(); + const $ = await table.parseDomContent(); + return $('mark') + .toArray() + .map(mark => $(mark).text()); } - async toggleSidebarCollapse() { + public async toggleSidebarCollapse() { return await testSubjects.click('collapseSideBarButton'); } - async getAllFieldNames() { - const items = await find.allByCssSelector('.sidebar-item'); - return await Promise.all(items.map(item => item.getVisibleText())); + public async getAllFieldNames() { + const sidebar = await testSubjects.find('discover-sidebar'); + const $ = await sidebar.parseDomContent(); + return $('.sidebar-item[attr-field]') + .toArray() + .map(field => + $(field) + .find('span.eui-textTruncate') + .text() + ); } - async getSidebarWidth() { + public async getSidebarWidth() { const sidebar = await find.byCssSelector('.sidebar-list'); return await sidebar.getAttribute('clientWidth'); } - async hasNoResults() { + public async hasNoResults() { return await testSubjects.exists('discoverNoResults'); } - async hasNoResultsTimepicker() { + public async hasNoResultsTimepicker() { return await testSubjects.exists('discoverNoResultsTimefilter'); } - async clickFieldListItem(field) { + public async clickFieldListItem(field: string) { return await testSubjects.click(`field-${field}`); } - async clickFieldListItemAdd(field) { + public async clickFieldListItemAdd(field: string) { await testSubjects.moveMouseTo(`field-${field}`); await testSubjects.click(`fieldToggle-${field}`); } - async clickFieldListItemVisualize(field) { + public async clickFieldListItemVisualize(field: string) { return await retry.try(async () => { await testSubjects.click(`fieldVisualize-${field}`); }); } - async expectFieldListItemVisualize(field) { + public async expectFieldListItemVisualize(field: string) { await testSubjects.existOrFail(`fieldVisualize-${field}`); } - async expectMissingFieldListItemVisualize(field) { + public async expectMissingFieldListItemVisualize(field: string) { await testSubjects.missingOrFail(`fieldVisualize-${field}`, { allowHidden: true }); } - async clickFieldListPlusFilter(field, value) { + public async clickFieldListPlusFilter(field: string, value: string) { // this method requires the field details to be open from clickFieldListItem() // testSubjects.find doesn't handle spaces in the data-test-subj value await find.clickByCssSelector(`[data-test-subj="plus-${field}-${value}"]`); - await PageObjects.header.waitUntilLoadingHasFinished(); + await header.waitUntilLoadingHasFinished(); } - async clickFieldListMinusFilter(field, value) { + public async clickFieldListMinusFilter(field: string, value: string) { // this method requires the field details to be open from clickFieldListItem() // testSubjects.find doesn't handle spaces in the data-test-subj value await find.clickByCssSelector('[data-test-subj="minus-' + field + '-' + value + '"]'); - await PageObjects.header.waitUntilLoadingHasFinished(); + await header.waitUntilLoadingHasFinished(); } - async selectIndexPattern(indexPattern) { + public async selectIndexPattern(indexPattern: string) { await testSubjects.click('indexPattern-switch-link'); await find.clickByCssSelector( `[data-test-subj="indexPattern-switcher"] [title="${indexPattern}"]` ); - await PageObjects.header.waitUntilLoadingHasFinished(); + await header.waitUntilLoadingHasFinished(); } - async removeHeaderColumn(name) { + public async removeHeaderColumn(name: string) { await testSubjects.moveMouseTo(`docTableHeader-${name}`); await testSubjects.click(`docTableRemoveHeader-${name}`); } - async openSidebarFieldFilter() { + public async openSidebarFieldFilter() { await testSubjects.click('toggleFieldFilterButton'); await testSubjects.existOrFail('filterSelectionPanel'); } - async closeSidebarFieldFilter() { + public async closeSidebarFieldFilter() { await testSubjects.click('toggleFieldFilterButton'); await testSubjects.missingOrFail('filterSelectionPanel', { allowHidden: true }); } - async waitForChartLoadingComplete(renderCount) { + public async waitForChartLoadingComplete(renderCount: number) { await elasticChart.waitForRenderingCount('discoverChart', renderCount); } - async waitForDocTableLoadingComplete() { + public async waitForDocTableLoadingComplete() { await testSubjects.waitForAttributeToChange( 'discoverDocTable', 'data-render-complete', diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index 4ba8ddb035913..db58c3c2c7d19 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -23,13 +23,11 @@ import { ConsolePageProvider } from './console_page'; // @ts-ignore not TS yet import { ContextPageProvider } from './context_page'; import { DashboardPageProvider } from './dashboard_page'; -// @ts-ignore not TS yet import { DiscoverPageProvider } from './discover_page'; // @ts-ignore not TS yet import { ErrorPageProvider } from './error_page'; // @ts-ignore not TS yet import { HeaderPageProvider } from './header_page'; -// @ts-ignore not TS yet import { HomePageProvider } from './home_page'; // @ts-ignore not TS yet import { MonitoringPageProvider } from './monitoring_page'; diff --git a/test/functional/services/doc_table.ts b/test/functional/services/doc_table.ts index 2530831e0f6f9..cb3daf20c641a 100644 --- a/test/functional/services/doc_table.ts +++ b/test/functional/services/doc_table.ts @@ -30,8 +30,8 @@ export function DocTableProvider({ getService, getPageObjects }: FtrProviderCont } class DocTable { - public async getTable() { - return await testSubjects.find('docTable'); + public async getTable(selector?: string) { + return await testSubjects.find(selector ? selector : 'docTable'); } public async getRowsText() { @@ -106,8 +106,8 @@ export function DocTableProvider({ getService, getPageObjects }: FtrProviderCont ); } - public async getHeaderFields(): Promise { - const table = await this.getTable(); + public async getHeaderFields(selector?: string): Promise { + const table = await this.getTable(selector); const $ = await table.parseDomContent(); return $.findTestSubjects('~docTableHeaderField') .toArray() diff --git a/test/functional/services/elastic_chart.ts b/test/functional/services/elastic_chart.ts index afae3f830b3bf..1c3071ac01587 100644 --- a/test/functional/services/elastic_chart.ts +++ b/test/functional/services/elastic_chart.ts @@ -22,10 +22,19 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function ElasticChartProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); + const find = getService('find'); const retry = getService('retry'); const log = getService('log'); class ElasticChart { + public async getCanvas() { + return await find.byCssSelector('.echChart canvas:last-of-type'); + } + + public async canvasExists() { + return await find.existsByCssSelector('.echChart canvas:last-of-type'); + } + public async waitForRenderComplete(dataTestSubj: string) { const chart = await testSubjects.find(dataTestSubj); const rendered = await chart.findAllByCssSelector('.echChart[data-ech-render-complete=true]'); @@ -42,11 +51,11 @@ export function ElasticChartProvider({ getService }: FtrProviderContext) { return Number(renderingCount); } - public async waitForRenderingCount(dataTestSubj: string, previousCount = 1) { - await retry.waitFor(`rendering count to be equal to [${previousCount + 1}]`, async () => { + public async waitForRenderingCount(dataTestSubj: string, minimumCount: number) { + await retry.waitFor(`rendering count to be equal to [${minimumCount}]`, async () => { const currentRenderingCount = await this.getVisualizationRenderingCount(dataTestSubj); log.debug(`-- currentRenderingCount=${currentRenderingCount}`); - return currentRenderingCount === previousCount + 1; + return currentRenderingCount >= minimumCount; }); } } diff --git a/test/functional/services/saved_query_management_component.ts b/test/functional/services/saved_query_management_component.ts index b94558c209e6a..244c1cd214de5 100644 --- a/test/functional/services/saved_query_management_component.ts +++ b/test/functional/services/saved_query_management_component.ts @@ -164,6 +164,10 @@ export function SavedQueryManagementComponentProvider({ getService }: FtrProvide if (isOpenAlready) return; await testSubjects.click('saved-query-management-popover-button'); + await retry.waitFor('saved query management popover to have any text', async () => { + const queryText = await testSubjects.getVisibleText('saved-query-management-popover'); + return queryText.length > 0; + }); } async closeSavedQueryManagementComponent() { diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json index cb0b9de01c4ed..594823ad047a7 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json @@ -7,7 +7,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@elastic/eui": "19.0.0", + "@elastic/eui": "20.0.2", "react": "^16.12.0", "react-dom": "^16.12.0" } diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json index c68ef6dcd0202..56f5719b5dbef 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json @@ -7,7 +7,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@elastic/eui": "19.0.0", + "@elastic/eui": "20.0.2", "react": "^16.12.0" } } diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/public/self_changing_vis/self_changing_vis.js b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/public/self_changing_vis/self_changing_vis.js index 1c6acab4aba16..2976a6cd98e30 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/public/self_changing_vis/self_changing_vis.js +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/public/self_changing_vis/self_changing_vis.js @@ -25,7 +25,7 @@ import { setup as visualizations } from '../../../../../../src/legacy/core_plugi visualizations.types.createReactVisualization({ name: 'self_changing_vis', title: 'Self Changing Vis', - icon: 'visControls', + icon: 'controlsHorizontal', description: 'This visualization is able to change its own settings, that you could also set in the editor.', visConfig: { diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/package.json b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/package.json index d4e4c6bf2fee0..d12c15d0688b2 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/package.json +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/package.json @@ -8,7 +8,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@elastic/eui": "19.0.0", + "@elastic/eui": "20.0.2", "react": "^16.12.0" }, "scripts": { diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/package.json b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/package.json index 3ade079419a55..eb24035f9acbe 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/package.json +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/package.json @@ -8,7 +8,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@elastic/eui": "19.0.0", + "@elastic/eui": "20.0.2", "react": "^16.12.0" }, "scripts": { diff --git a/test/scripts/jenkins_visual_regression.sh b/test/scripts/jenkins_visual_regression.sh index dda966dea98d0..4fdd197147eac 100755 --- a/test/scripts/jenkins_visual_regression.sh +++ b/test/scripts/jenkins_visual_regression.sh @@ -1,10 +1,18 @@ #!/usr/bin/env bash -source test/scripts/jenkins_test_setup_xpack.sh +source src/dev/ci_setup/setup_env.sh source "$KIBANA_DIR/src/dev/ci_setup/setup_percy.sh" -checks-reporter-with-killswitch "Kibana visual regression tests" \ - yarn run percy exec -t 500 \ +echo " -> building and extracting OSS Kibana distributable for use in functional tests" +node scripts/build --debug --oss +linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" +installDir="$PARENT_DIR/install/kibana" +mkdir -p "$installDir" +tar -xzf "$linuxBuild" -C "$installDir" --strip=1 + +echo " -> running visual regression tests from kibana directory" +checks-reporter-with-killswitch "X-Pack visual regression tests" \ + yarn percy exec -t 500 \ node scripts/functional_tests \ --debug --bail \ --kibana-install-dir "$installDir" \ diff --git a/test/scripts/jenkins_xpack.sh b/test/scripts/jenkins_xpack.sh index b629e064b39b5..5055997df642a 100755 --- a/test/scripts/jenkins_xpack.sh +++ b/test/scripts/jenkins_xpack.sh @@ -11,7 +11,7 @@ if [[ -z "$CODE_COVERAGE" ]] ; then echo " -> Running jest tests" cd "$XPACK_DIR" - checks-reporter-with-killswitch "X-Pack Jest" node scripts/jest --ci --verbose --detectOpenHandles + checks-reporter-with-killswitch "X-Pack Jest" node --max-old-space-size=6144 scripts/jest --ci --verbose --detectOpenHandles echo "" echo "" diff --git a/test/scripts/jenkins_xpack_visual_regression.sh b/test/scripts/jenkins_xpack_visual_regression.sh index 6e3d4dd7c249b..73e92da3bad63 100755 --- a/test/scripts/jenkins_xpack_visual_regression.sh +++ b/test/scripts/jenkins_xpack_visual_regression.sh @@ -1,11 +1,21 @@ #!/usr/bin/env bash -source test/scripts/jenkins_test_setup_xpack.sh +source src/dev/ci_setup/setup_env.sh source "$KIBANA_DIR/src/dev/ci_setup/setup_percy.sh" +echo " -> building and extracting default Kibana distributable" +cd "$KIBANA_DIR" +node scripts/build --debug --no-oss +linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" +installDir="$PARENT_DIR/install/kibana" +mkdir -p "$installDir" +tar -xzf "$linuxBuild" -C "$installDir" --strip=1 + +echo " -> running visual regression tests from x-pack directory" +cd "$XPACK_DIR" checks-reporter-with-killswitch "X-Pack visual regression tests" \ - yarn run percy exec -t 500 \ + yarn percy exec -t 500 \ node scripts/functional_tests \ --debug --bail \ - --kibana-install-dir "$KIBANA_INSTALL_DIR" \ - --config test/visual_regression/config.js; + --kibana-install-dir "$installDir" \ + --config test/visual_regression/config.ts; diff --git a/test/visual_regression/services/visual_testing/visual_testing.ts b/test/visual_regression/services/visual_testing/visual_testing.ts index 4ad97f8d98717..0882beecf7f5c 100644 --- a/test/visual_regression/services/visual_testing/visual_testing.ts +++ b/test/visual_regression/services/visual_testing/visual_testing.ts @@ -71,6 +71,13 @@ export async function VisualTestingProvider({ getService }: FtrProviderContext) return new (class VisualTesting { public async snapshot(options: SnapshotOptions = {}) { + if (process.env.DISABLE_VISUAL_TESTING) { + log.warning( + 'Capturing of percy snapshots disabled, would normally capture a snapshot here!' + ); + return; + } + log.debug('Capturing percy snapshot'); if (!currentTest) { diff --git a/test/visual_regression/tests/discover/chart_visualization.js b/test/visual_regression/tests/discover/chart_visualization.ts similarity index 55% rename from test/visual_regression/tests/discover/chart_visualization.js rename to test/visual_regression/tests/discover/chart_visualization.ts index 10ac559b9f982..49c3057a27cb0 100644 --- a/test/visual_regression/tests/discover/chart_visualization.js +++ b/test/visual_regression/tests/discover/chart_visualization.ts @@ -19,8 +19,9 @@ import expect from '@kbn/expect'; -export default function({ getService, getPageObjects }) { - const log = getService('log'); +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const esArchiver = getService('esArchiver'); const browser = getService('browser'); @@ -34,58 +35,56 @@ export default function({ getService, getPageObjects }) { describe('discover', function describeIndexTests() { before(async function() { - log.debug('load kibana index with default index pattern'); await esArchiver.load('discover'); // and load a set of makelogs data await esArchiver.loadIfNeeded('logstash_functional'); await kibanaServer.uiSettings.replace(defaultSettings); - log.debug('discover'); await PageObjects.common.navigateToApp('discover'); await PageObjects.timePicker.setDefaultAbsoluteRange(); }); + after(function unloadMakelogs() { + return esArchiver.unload('logstash_functional'); + }); + + async function refreshDiscover() { + await browser.refresh(); + await PageObjects.header.awaitKibanaChrome(); + await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitForChartLoadingComplete(1); + } + + async function takeSnapshot() { + await refreshDiscover(); + await visualTesting.snapshot({ + show: ['discoverChart'], + }); + } + describe('query', function() { this.tags(['skipFirefox']); - let renderCounter = 0; it('should show bars in the correct time zone', async function() { await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await PageObjects.discover.waitForChartLoadingComplete(++renderCounter); - await visualTesting.snapshot({ - show: ['discoverChart'], - }); + await takeSnapshot(); }); it('should show correct data for chart interval Hourly', async function() { - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); - await PageObjects.discover.waitUntilSearchingHasFinished(); await PageObjects.discover.setChartInterval('Hourly'); - await PageObjects.discover.waitForChartLoadingComplete(++renderCounter); - await visualTesting.snapshot({ - show: ['discoverChart'], - }); + await takeSnapshot(); }); it('should show correct data for chart interval Daily', async function() { - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); - await PageObjects.discover.waitUntilSearchingHasFinished(); await PageObjects.discover.setChartInterval('Daily'); - await PageObjects.discover.waitForChartLoadingComplete(++renderCounter); - await visualTesting.snapshot({ - show: ['discoverChart'], - }); + await takeSnapshot(); }); it('should show correct data for chart interval Weekly', async function() { - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); - await PageObjects.discover.waitUntilSearchingHasFinished(); await PageObjects.discover.setChartInterval('Weekly'); - await PageObjects.discover.waitForChartLoadingComplete(++renderCounter); - await visualTesting.snapshot({ - show: ['discoverChart'], - }); + await takeSnapshot(); }); it('browser back button should show previous interval Daily', async function() { @@ -94,57 +93,31 @@ export default function({ getService, getPageObjects }) { const actualInterval = await PageObjects.discover.getChartInterval(); expect(actualInterval).to.be('Daily'); }); - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); - await PageObjects.discover.waitUntilSearchingHasFinished(); - await PageObjects.discover.waitForChartLoadingComplete(++renderCounter); - await visualTesting.snapshot({ - show: ['discoverChart'], - }); + await takeSnapshot(); }); it('should show correct data for chart interval Monthly', async function() { - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); - await PageObjects.discover.waitUntilSearchingHasFinished(); await PageObjects.discover.setChartInterval('Monthly'); - await PageObjects.discover.waitForChartLoadingComplete(++renderCounter); - await visualTesting.snapshot({ - show: ['discoverChart'], - }); + await takeSnapshot(); }); it('should show correct data for chart interval Yearly', async function() { - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); - await PageObjects.discover.waitUntilSearchingHasFinished(); await PageObjects.discover.setChartInterval('Yearly'); - await PageObjects.discover.waitForChartLoadingComplete(++renderCounter); - await visualTesting.snapshot({ - show: ['discoverChart'], - }); + await takeSnapshot(); }); it('should show correct data for chart interval Auto', async function() { - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); - await PageObjects.discover.waitUntilSearchingHasFinished(); await PageObjects.discover.setChartInterval('Auto'); - await PageObjects.discover.waitForChartLoadingComplete(++renderCounter); - await visualTesting.snapshot({ - show: ['discoverChart'], - }); + await takeSnapshot(); }); }); describe('time zone switch', () => { it('should show bars in the correct time zone after switching', async function() { await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'America/Phoenix' }); - await browser.refresh(); - await PageObjects.header.awaitKibanaChrome(); + await refreshDiscover(); await PageObjects.timePicker.setDefaultAbsoluteRange(); - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); - await PageObjects.discover.waitUntilSearchingHasFinished(); - await PageObjects.discover.waitForChartLoadingComplete(1); - await visualTesting.snapshot({ - show: ['discoverChart'], - }); + await takeSnapshot(); }); }); }); diff --git a/test/visual_regression/tests/discover/index.js b/test/visual_regression/tests/discover/index.ts similarity index 86% rename from test/visual_regression/tests/discover/index.js rename to test/visual_regression/tests/discover/index.ts index f98aac52aa4cb..d036327ae7475 100644 --- a/test/visual_regression/tests/discover/index.js +++ b/test/visual_regression/tests/discover/index.ts @@ -18,12 +18,12 @@ */ import { DEFAULT_OPTIONS } from '../../services/visual_testing/visual_testing'; +import { FtrProviderContext } from '../../ftr_provider_context'; // Width must be the same as visual_testing or canvas image widths will get skewed const [SCREEN_WIDTH] = DEFAULT_OPTIONS.widths || []; -export default function({ getService, loadTestFile }) { - const esArchiver = getService('esArchiver'); +export default function({ getService, loadTestFile }: FtrProviderContext) { const browser = getService('browser'); describe('discover app', function() { @@ -33,10 +33,6 @@ export default function({ getService, loadTestFile }) { return browser.setWindowSize(SCREEN_WIDTH, 1000); }); - after(function unloadMakelogs() { - return esArchiver.unload('logstash_functional'); - }); - loadTestFile(require.resolve('./chart_visualization')); }); } diff --git a/typings/@elastic/eui/index.d.ts b/typings/@elastic/eui/index.d.ts index 9268f72724141..db07861d63cfe 100644 --- a/typings/@elastic/eui/index.d.ts +++ b/typings/@elastic/eui/index.d.ts @@ -21,6 +21,5 @@ import { Direction } from '@elastic/eui/src/services/sort/sort_direction'; // TODO: Remove once typescript definitions are in EUI declare module '@elastic/eui' { - export const EuiCodeEditor: React.FC; export const Query: any; } diff --git a/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap index 7809734dbf2ad..d5764001a7f18 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ b/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap @@ -22,6 +22,7 @@ exports[`Home component should render services 1`] = ` }, "notifications": Object { "toasts": Object { + "addDanger": [Function], "addWarning": [Function], }, }, @@ -61,6 +62,7 @@ exports[`Home component should render traces 1`] = ` }, "notifications": Object { "toasts": Object { + "addDanger": [Function], "addWarning": [Function], }, }, diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index d5f0728a7ff12..9a93c67f08187 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -21,7 +21,6 @@ import { isValidPlatinumLicense } from '../../../../../../../plugins/apm/common/ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ServiceMapAPIResponse } from '../../../../../../../plugins/apm/server/lib/service_map/get_service_map'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; -import { useCallApmApi } from '../../../hooks/useCallApmApi'; import { useDeepObjectIdentity } from '../../../hooks/useDeepObjectIdentity'; import { useLicense } from '../../../hooks/useLicense'; import { useLoadingIndicator } from '../../../hooks/useLoadingIndicator'; @@ -33,6 +32,7 @@ import { getCytoscapeElements } from './get_cytoscape_elements'; import { PlatinumLicensePrompt } from './PlatinumLicensePrompt'; import { Popover } from './Popover'; import { useRefDimensions } from './useRefDimensions'; +import { callApmApi } from '../../../services/rest/createCallApmApi'; interface ServiceMapProps { serviceName?: string; @@ -61,7 +61,6 @@ ${theme.euiColorLightShade}`, const MAX_REQUESTS = 5; export function ServiceMap({ serviceName }: ServiceMapProps) { - const callApmApi = useCallApmApi(); const license = useLicense(); const { search } = useLocation(); const { urlParams, uiFilters } = useUrlParams(); @@ -137,7 +136,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { } } }, - [params, setIsLoading, callApmApi, responses.length, notifications.toasts] + [params, setIsLoading, responses.length, notifications.toasts] ); useEffect(() => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx index 1564f1ae746a9..997df371b51ed 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx @@ -8,10 +8,9 @@ import React, { useState } from 'react'; import { EuiButtonEmpty } from '@elastic/eui'; import { NotificationsStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; -import { useCallApmApi } from '../../../../../hooks/useCallApmApi'; import { Config } from '../index'; import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { APMClient } from '../../../../../services/rest/createCallApmApi'; +import { callApmApi } from '../../../../../services/rest/createCallApmApi'; import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext'; interface Props { @@ -22,7 +21,6 @@ interface Props { export function DeleteButton({ onDeleted, selectedConfig }: Props) { const [isDeleting, setIsDeleting] = useState(false); const { toasts } = useApmPluginContext().core.notifications; - const callApmApi = useCallApmApi(); return ( { setIsDeleting(true); - await deleteConfig(callApmApi, selectedConfig, toasts); + await deleteConfig(selectedConfig, toasts); setIsDeleting(false); onDeleted(); }} @@ -45,7 +43,6 @@ export function DeleteButton({ onDeleted, selectedConfig }: Props) { } async function deleteConfig( - callApmApi: APMClient, selectedConfig: Config, toasts: NotificationsStart['toasts'] ) { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ServiceForm/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx similarity index 82% rename from x-pack/legacy/plugins/apm/public/components/shared/ServiceForm/index.tsx rename to x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx index ab3accec90d1d..537bdace50e24 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/ServiceForm/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx @@ -10,12 +10,12 @@ import { i18n } from '@kbn/i18n'; import { omitAllOption, getOptionLabel -} from '../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { useFetcher } from '../../../hooks/useFetcher'; -import { SelectWithPlaceholder } from '../SelectWithPlaceholder'; +} from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; +import { useFetcher } from '../../../../../hooks/useFetcher'; +import { SelectWithPlaceholder } from '../../../../shared/SelectWithPlaceholder'; const SELECT_PLACEHOLDER_LABEL = `- ${i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceForm.selectPlaceholder', + 'xpack.apm.settings.agentConf.flyOut.serviceSection.selectPlaceholder', { defaultMessage: 'Select' } )} -`; @@ -27,7 +27,7 @@ interface Props { onEnvironmentChange: (env: string) => void; } -export function ServiceForm({ +export function ServiceSection({ isReadOnly, serviceName, onServiceNameChange, @@ -60,7 +60,7 @@ export function ServiceForm({ ); const ALREADY_CONFIGURED_TRANSLATED = i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceForm.alreadyConfiguredOption', + 'xpack.apm.settings.agentConf.flyOut.serviceSection.alreadyConfiguredOption', { defaultMessage: 'already configured' } ); @@ -83,7 +83,7 @@ export function ServiceForm({

{i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceForm.title', + 'xpack.apm.settings.agentConf.flyOut.serviceSection.title', { defaultMessage: 'Service' } )}

@@ -93,13 +93,13 @@ export function ServiceForm({ - ; }) { await callApmApi({ @@ -94,11 +91,11 @@ export function ApmIndices() { const [apmIndices, setApmIndices] = useState>({}); const [isSaving, setIsSaving] = useState(false); - const callApmApiFromHook = useCallApmApi(); - const { data = INITIAL_STATE, status, refetch } = useFetcher( - callApmApi => - callApmApi({ pathname: `/api/apm/settings/apm-index-settings` }), + _callApmApi => + _callApmApi({ + pathname: `/api/apm/settings/apm-index-settings` + }), [] ); @@ -122,10 +119,7 @@ export function ApmIndices() { event.preventDefault(); setIsSaving(true); try { - await saveApmIndices({ - callApmApi: callApmApiFromHook, - apmIndices - }); + await saveApmIndices({ apmIndices }); toasts.addSuccess({ title: i18n.translate( 'xpack.apm.settings.apmIndices.applyChanges.succeeded.title', diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/SettingsSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/SettingsSection.tsx deleted file mode 100644 index 8cb604d367549..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/SettingsSection.tsx +++ /dev/null @@ -1,81 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import { EuiFieldText, EuiFormRow, EuiSpacer, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; - -interface Props { - label: string; - onLabelChange: (label: string) => void; - url: string; - onURLChange: (url: string) => void; -} - -export const SettingsSection = ({ - label, - onLabelChange, - url, - onURLChange -}: Props) => { - return ( - <> - -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.settingsSection.title', - { defaultMessage: 'Action' } - )} -

-
- - - { - onLabelChange(e.target.value); - }} - /> - - - { - onURLChange(e.target.value); - }} - /> - - - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/index.tsx deleted file mode 100644 index d04cdd62c303b..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/index.tsx +++ /dev/null @@ -1,109 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import { - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiPortal, - EuiSpacer, - EuiText, - EuiTitle -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { useState } from 'react'; -import { SettingsSection } from './SettingsSection'; -import { ServiceForm } from '../../../../../shared/ServiceForm'; - -interface Props { - onClose: () => void; -} - -export const CustomActionsFlyout = ({ onClose }: Props) => { - const [serviceName, setServiceName] = useState(''); - const [environment, setEnvironment] = useState(''); - const [label, setLabel] = useState(''); - const [url, setURL] = useState(''); - return ( - - - - -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.title', - { - defaultMessage: 'Create custom action' - } - )} -

-
-
- - -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.label', - { - defaultMessage: - "This action will be shown in the 'Actions' context menu for the trace and error detail components. You can specify any number of links, but only the first three will be shown, in alphabetical order." - } - )} -

-
- - - - - - -
- - - - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.close', - { - defaultMessage: 'Close' - } - )} - - - - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.save', - { - defaultMessage: 'Save' - } - )} - - - - -
-
- ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/EmptyPrompt.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/EmptyPrompt.tsx deleted file mode 100644 index f39e4b307b24c..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/EmptyPrompt.tsx +++ /dev/null @@ -1,52 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; - -export const EmptyPrompt = ({ - onCreateCustomActionClick -}: { - onCreateCustomActionClick: () => void; -}) => { - return ( - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.emptyPromptTitle', - { - defaultMessage: 'No actions found.' - } - )} - - } - body={ - <> -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.emptyPromptText', - { - defaultMessage: - "Let's change that! You can add custom actions to the Actions context menu by the trace and error details for each service. This could be linking to a Kibana dashboard or going to your organization's support portal" - } - )} -

- - } - actions={ - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.createCustomAction', - { defaultMessage: 'Create custom action' } - )} - - } - /> - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/__test__/CustomActions.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/__test__/CustomActions.test.tsx deleted file mode 100644 index 970de66c64a9a..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/__test__/CustomActions.test.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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; -import { CustomActionsOverview } from '../'; -import { expectTextsInDocument } from '../../../../../../utils/testHelpers'; -import * as hooks from '../../../../../../hooks/useFetcher'; - -describe('CustomActions', () => { - afterEach(() => jest.restoreAllMocks()); - - describe('empty prompt', () => { - it('shows when any actions are available', () => { - // TODO: mock return items - const component = render(); - expectTextsInDocument(component, ['No actions found.']); - }); - it('opens flyout when click to create new action', () => { - spyOn(hooks, 'useFetcher').and.returnValue({ - data: [], - status: 'success' - }); - const { queryByText, getByText } = render(); - expect(queryByText('Service')).not.toBeInTheDocument(); - fireEvent.click(getByText('Create custom action')); - expect(queryByText('Service')).toBeInTheDocument(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/index.tsx deleted file mode 100644 index ae2972f251fc2..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/index.tsx +++ /dev/null @@ -1,75 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiPanel, EuiSpacer } from '@elastic/eui'; -import { isEmpty } from 'lodash'; -import React, { useState } from 'react'; -import { ManagedTable } from '../../../../shared/ManagedTable'; -import { Title } from './Title'; -import { EmptyPrompt } from './EmptyPrompt'; -import { CustomActionsFlyout } from './CustomActionsFlyout'; - -export const CustomActionsOverview = () => { - const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); - - // TODO: change it to correct fields fetched from ES - const columns = [ - { - field: 'actionName', - name: 'Action Name', - truncateText: true - }, - { - field: 'serviceName', - name: 'Service Name' - }, - { - field: 'environment', - name: 'Environment' - }, - { - field: 'lastUpdate', - name: 'Last update' - }, - { - field: 'actions', - name: 'Actions' - } - ]; - - // TODO: change to items fetched from ES. - const items: object[] = []; - - const onCloseFlyout = () => { - setIsFlyoutOpen(false); - }; - - const onCreateCustomActionClick = () => { - setIsFlyoutOpen(true); - }; - - return ( - <> - - - <EuiSpacer size="m" /> - {isFlyoutOpen && <CustomActionsFlyout onClose={onCloseFlyout} />} - {isEmpty(items) ? ( - <EmptyPrompt onCreateCustomActionClick={onCreateCustomActionClick} /> - ) : ( - <ManagedTable - items={items} - columns={columns} - initialPageSize={25} - initialSortField="occurrenceCount" - initialSortDirection="desc" - sortItems={false} - /> - )} - </EuiPanel> - </> - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx new file mode 100644 index 0000000000000..415d2557c23c3 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export const CreateCustomLinkButton = ({ + onClick +}: { + onClick: () => void; +}) => ( + <EuiButton color="primary" fill onClick={onClick}> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.createCustomLink', + { defaultMessage: 'Create custom link' } + )} + </EuiButton> +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx new file mode 100644 index 0000000000000..2b3a5cbe87992 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'kibana/public'; +import React, { useState } from 'react'; +import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; +import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; + +interface Props { + onDelete: () => void; + customLinkId: string; +} + +export function DeleteButton({ onDelete, customLinkId }: Props) { + const [isDeleting, setIsDeleting] = useState(false); + const { toasts } = useApmPluginContext().core.notifications; + + return ( + <EuiButtonEmpty + color="danger" + isLoading={isDeleting} + iconSide="right" + onClick={async () => { + setIsDeleting(true); + await deleteConfig(customLinkId, toasts); + setIsDeleting(false); + onDelete(); + }} + > + {i18n.translate('xpack.apm.settings.customizeUI.customLink.delete', { + defaultMessage: 'Delete' + })} + </EuiButtonEmpty> + ); +} + +async function deleteConfig( + customLinkId: string, + toasts: NotificationsStart['toasts'] +) { + try { + await callApmApi({ + pathname: '/api/apm/settings/custom_links/{id}', + method: 'DELETE', + params: { + path: { id: customLinkId } + } + }); + toasts.addSuccess({ + iconType: 'trash', + title: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.delete.successed', + { defaultMessage: 'Deleted custom link.' } + ) + }); + } catch (error) { + toasts.addDanger({ + iconType: 'cross', + title: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.delete.failed', + { defaultMessage: 'Custom link could not be deleted' } + ) + }); + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx new file mode 100644 index 0000000000000..69fecf25f5143 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + EuiButtonEmpty, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiSelect, + EuiSpacer, + EuiText, + EuiTitle +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isEmpty } from 'lodash'; +import React from 'react'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { FilterOptions } from '../../../../../../../../../../plugins/apm/server/routes/settings/custom_link'; +import { + DEFAULT_OPTION, + Filters, + filterSelectOptions, + getSelectOptions +} from './helper'; + +export const FiltersSection = ({ + filters, + onChangeFilters +}: { + filters: Filters; + onChangeFilters: (filters: Filters) => void; +}) => { + const onChangeFilter = (filter: Filters[0], idx: number) => { + const newFilters = [...filters]; + newFilters[idx] = filter; + onChangeFilters(newFilters); + }; + + const onRemoveFilter = (idx: number) => { + // remove without mutating original array + const newFilters = [...filters].splice(idx, 1); + + // if there is only one item left it should not be removed + // but reset to empty + if (isEmpty(newFilters)) { + onChangeFilters([['', '']]); + } else { + onChangeFilters(newFilters); + } + }; + + const handleAddFilter = () => { + onChangeFilters([...filters, ['', '']]); + }; + + return ( + <> + <EuiTitle size="xs"> + <h3> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.filters.title', + { + defaultMessage: 'Filters' + } + )} + </h3> + </EuiTitle> + <EuiSpacer size="s" /> + <EuiText size="xs"> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.filters.subtitle', + { + defaultMessage: + 'Add additional values within the same field by comma separating values.' + } + )} + </EuiText> + + <EuiSpacer size="s" /> + + {filters.map((filter, idx) => { + const [key, value] = filter; + const filterId = `filter-${idx}`; + const selectOptions = getSelectOptions(filters, idx); + return ( + <EuiFlexGroup key={filterId} gutterSize="s" alignItems="center"> + <EuiFlexItem> + <EuiSelect + aria-label={filterId} + id={filterId} + fullWidth + options={selectOptions} + value={key} + prepend={i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.filters.prepend', + { + defaultMessage: 'Field' + } + )} + onChange={e => + onChangeFilter( + [e.target.value as keyof FilterOptions, value], + idx + ) + } + isInvalid={ + !isEmpty(value) && + (isEmpty(key) || key === DEFAULT_OPTION.value) + } + /> + </EuiFlexItem> + <EuiFlexItem> + <EuiFieldText + fullWidth + placeholder={i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyOut.filters.defaultOption.value', + { defaultMessage: 'Value' } + )} + onChange={e => onChangeFilter([key, e.target.value], idx)} + value={value} + isInvalid={!isEmpty(key) && isEmpty(value)} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + iconType="trash" + onClick={() => onRemoveFilter(idx)} + disabled={!key && filters.length === 1} + /> + </EuiFlexItem> + </EuiFlexGroup> + ); + })} + + <EuiSpacer size="xs" /> + + <AddFilterButton + onClick={handleAddFilter} + // Disable button when user has already added all items available + isDisabled={filters.length === filterSelectOptions.length - 1} + /> + </> + ); +}; + +const AddFilterButton = ({ + onClick, + isDisabled +}: { + onClick: () => void; + isDisabled: boolean; +}) => ( + <EuiButtonEmpty + iconType="plusInCircle" + onClick={onClick} + disabled={isDisabled} + > + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.filters.addAnotherFilter', + { + defaultMessage: 'Add another filter' + } + )} + </EuiButtonEmpty> +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx new file mode 100644 index 0000000000000..cb27221309812 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DeleteButton } from './DeleteButton'; + +export const FlyoutFooter = ({ + onClose, + isSaving, + onDelete, + customLinkId, + isSaveButtonEnabled +}: { + onClose: () => void; + isSaving: boolean; + onDelete: () => void; + customLinkId?: string; + isSaveButtonEnabled: boolean; +}) => { + return ( + <EuiFlyoutFooter> + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty iconType="cross" onClick={onClose} flush="left"> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.close', + { + defaultMessage: 'Close' + } + )} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFlexGroup> + {customLinkId && ( + <EuiFlexItem> + <DeleteButton customLinkId={customLinkId} onDelete={onDelete} /> + </EuiFlexItem> + )} + <EuiFlexItem> + <EuiButton + fill + type="submit" + isLoading={isSaving} + isDisabled={!isSaveButtonEnabled} + > + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.save', + { + defaultMessage: 'Save' + } + )} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlyoutFooter> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx new file mode 100644 index 0000000000000..89f55a6c682ca --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + EuiFieldText, + EuiFormRow, + EuiSpacer, + EuiText, + EuiTitle +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; + +interface InputField { + name: keyof CustomLink; + label: string; + helpText: string; + placeholder: string; + onChange: (value: string) => void; + value?: string; +} + +interface Props { + label?: string; + onChangeLabel: (label: string) => void; + url?: string; + onChangeUrl: (url: string) => void; +} + +export const LinkSection = ({ + label, + onChangeLabel, + url, + onChangeUrl +}: Props) => { + const inputFields: InputField[] = [ + { + name: 'label', + label: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.label', + { + defaultMessage: 'Label' + } + ), + helpText: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.label.helpText', + { + defaultMessage: + 'This is the label shown in the actions context menu. Keep it as short as possible.' + } + ), + placeholder: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.label.placeholder', + { + defaultMessage: 'e.g. Support tickets' + } + ), + value: label, + onChange: onChangeLabel + }, + { + name: 'url', + label: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.url', + { + defaultMessage: 'URL' + } + ), + helpText: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.url.helpText', + { + defaultMessage: + 'Add fieldname variables to your URL to apply values e.g. {sample}. TODO: Learn more in the docs.', + values: { sample: '{{trace.id}}' } + } + ), + placeholder: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.url.placeholder', + { + defaultMessage: 'e.g. https://www.elastic.co/' + } + ), + value: url, + onChange: onChangeUrl + } + ]; + + return ( + <> + <EuiTitle size="xs"> + <h3> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.action.title', + { + defaultMessage: 'Link' + } + )} + </h3> + </EuiTitle> + <EuiSpacer size="l" /> + {inputFields.map(field => { + return ( + <EuiFormRow + fullWidth + key={field.name} + label={field.label} + helpText={field.helpText} + labelAppend={ + <EuiText size="xs"> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.required', + { + defaultMessage: 'Required' + } + )} + </EuiText> + } + > + <EuiFieldText + placeholder={field.placeholder} + name={field.name} + fullWidth + value={field.value} + onChange={e => field.onChange(e.target.value)} + aria-label={field.name} + /> + </EuiFormRow> + ); + })} + </> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts new file mode 100644 index 0000000000000..bb86a251594ab --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { isEmpty, pick } from 'lodash'; +import { + FilterOptions, + filterOptions + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../../../../../plugins/apm/server/routes/settings/custom_link'; +import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; + +export type Filters = Array<[keyof FilterOptions | '', string]>; + +interface FilterSelectOption { + value: 'DEFAULT' | keyof FilterOptions; + text: string; +} + +/** + * Converts available filters from the Custom Link to Array of filters. + * e.g. + * customLink = { + * id: '1', + * label: 'foo', + * url: 'http://www.elastic.co', + * service.name: 'opbeans-java', + * transaction.type: 'request' + * } + * + * results: [['service.name', 'opbeans-java'],['transaction.type', 'request']] + * @param customLink + */ +export const convertFiltersToArray = (customLink?: CustomLink): Filters => { + if (customLink) { + const filters = Object.entries(pick(customLink, filterOptions)) as Filters; + if (!isEmpty(filters)) { + return filters; + } + } + return [['', '']]; +}; + +/** + * Converts array of filters into object. + * e.g. + * filters: [['service.name', 'opbeans-java'],['transaction.type', 'request']] + * + * results: { + * 'service.name': 'opbeans-java', + * 'transaction.type': 'request' + * } + * @param filters + */ +export const convertFiltersToObject = (filters: Filters) => { + const convertedFilters = Object.fromEntries( + filters.filter(([key, value]) => !isEmpty(key) && !isEmpty(value)) + ); + if (!isEmpty(convertedFilters)) { + return convertedFilters; + } +}; + +export const DEFAULT_OPTION: FilterSelectOption = { + value: 'DEFAULT', + text: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyOut.filters.defaultOption', + { defaultMessage: 'Select field...' } + ) +}; + +export const filterSelectOptions: FilterSelectOption[] = [ + DEFAULT_OPTION, + ...filterOptions.map(filter => ({ + value: filter as keyof FilterOptions, + text: filter + })) +]; + +/** + * Returns the options available, removing filters already added, but keeping the selected filter. + * + * @param filters + * @param idx + */ +export const getSelectOptions = (filters: Filters, idx: number) => { + return filterSelectOptions.filter(option => { + const indexUsedFilter = filters.findIndex( + filter => filter[0] === option.value + ); + // Filter out all items already added, besides the one selected in the current filter. + return indexUsedFilter === -1 || idx === indexUsedFilter; + }); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx new file mode 100644 index 0000000000000..88358c888160b --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiPortal, + EuiSpacer, + EuiText, + EuiTitle +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; +import { FiltersSection } from './FiltersSection'; +import { FlyoutFooter } from './FlyoutFooter'; +import { LinkSection } from './LinkSection'; +import { saveCustomLink } from './saveCustomLink'; +import { convertFiltersToArray, convertFiltersToObject } from './helper'; + +interface Props { + onClose: () => void; + customLinkSelected?: CustomLink; + onSave: () => void; + onDelete: () => void; +} + +export const CustomLinkFlyout = ({ + onClose, + customLinkSelected, + onSave, + onDelete +}: Props) => { + const { toasts } = useApmPluginContext().core.notifications; + const [isSaving, setIsSaving] = useState(false); + + const [label, setLabel] = useState(customLinkSelected?.label || ''); + const [url, setUrl] = useState(customLinkSelected?.url || ''); + const [filters, setFilters] = useState( + convertFiltersToArray(customLinkSelected) + ); + + const isFormValid = !!label && !!url; + + const onSubmit = async ( + event: + | React.FormEvent<HTMLFormElement> + | React.MouseEvent<HTMLButtonElement> + ) => { + event.preventDefault(); + setIsSaving(true); + await saveCustomLink({ + id: customLinkSelected?.id, + label, + url, + filters: convertFiltersToObject(filters), + toasts + }); + setIsSaving(false); + onSave(); + }; + + return ( + <EuiPortal> + <form onSubmit={onSubmit}> + <EuiFlyout ownFocus onClose={onClose} size="m"> + <EuiFlyoutHeader hasBorder> + <EuiTitle size="s"> + <h2> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.title', + { + defaultMessage: 'Create link' + } + )} + </h2> + </EuiTitle> + </EuiFlyoutHeader> + <EuiFlyoutBody> + <EuiText> + <p> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.label', + { + defaultMessage: + 'Links will be available in the context of transaction details throughout the APM app. You can create an unlimited number of links and use the filter options to scope them to only appear for specific services. You can refer to dynamic variables by using any of the transaction metadata to fill in your URLs. TODO: Learn more about it in the docs.' + } + )} + </p> + </EuiText> + + <EuiSpacer size="l" /> + + <LinkSection + label={label} + onChangeLabel={setLabel} + url={url} + onChangeUrl={setUrl} + /> + + <EuiSpacer size="l" /> + + <FiltersSection filters={filters} onChangeFilters={setFilters} /> + </EuiFlyoutBody> + + <FlyoutFooter + isSaveButtonEnabled={isFormValid} + onClose={onClose} + isSaving={isSaving} + onDelete={onDelete} + customLinkId={customLinkSelected?.id} + /> + </EuiFlyout> + </form> + </EuiPortal> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts new file mode 100644 index 0000000000000..f255840e1d734 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'kibana/public'; +import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; + +export async function saveCustomLink({ + id, + label, + url, + filters, + toasts +}: { + id?: string; + label: string; + url: string; + filters?: { [key: string]: string }; + toasts: NotificationsStart['toasts']; +}) { + try { + const customLink = { + label, + url, + ...filters + }; + if (id) { + await callApmApi({ + pathname: '/api/apm/settings/custom_links/{id}', + method: 'PUT', + params: { + path: { id }, + body: customLink + } + }); + } else { + await callApmApi({ + pathname: '/api/apm/settings/custom_links', + method: 'POST', + params: { + body: customLink + } + }); + } + toasts.addSuccess({ + iconType: 'check', + title: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.create.successed', + { defaultMessage: 'Link saved!' } + ) + }); + } catch (error) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.create.failed', + { defaultMessage: 'Link could not be saved!' } + ), + text: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.create.failed.message', + { + defaultMessage: + 'Something went wrong when saving the link. Error: "{errorMessage}"', + values: { + errorMessage: error.message + } + } + ) + }); + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx new file mode 100644 index 0000000000000..f7d8c4baa71e9 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiSpacer +} from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import { units, px } from '../../../../../style/variables'; +import { CustomLink } from '../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { ManagedTable } from '../../../../shared/ManagedTable'; +import { TimestampTooltip } from '../../../../shared/TimestampTooltip'; +import { LoadingStatePrompt } from '../../../../shared/LoadingStatePrompt'; + +interface Props { + items: CustomLink[]; + onCustomLinkSelected: (customLink: CustomLink) => void; +} + +export const CustomLinkTable = ({ + items = [], + onCustomLinkSelected +}: Props) => { + const [searchTerm, setSearchTerm] = useState(''); + + const columns = [ + { + field: 'label', + name: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.name', + { defaultMessage: 'Name' } + ), + truncateText: true + }, + { + field: 'url', + name: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.url', + { defaultMessage: 'URL' } + ), + truncateText: true + }, + { + width: px(160), + align: 'right', + field: '@timestamp', + name: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.lastUpdated', + { defaultMessage: 'Last updated' } + ), + sortable: true, + render: (value: number) => ( + <TimestampTooltip time={value} timeUnit="minutes" /> + ) + }, + { + width: px(units.triple), + name: '', + actions: [ + { + name: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.editButtonLabel', + { defaultMessage: 'Edit' } + ), + description: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.editButtonDescription', + { defaultMessage: 'Edit this custom link' } + ), + icon: 'pencil', + color: 'primary', + type: 'icon', + onClick: (customLink: CustomLink) => { + onCustomLinkSelected(customLink); + } + } + ] + } + ]; + + const filteredItems = items.filter(({ label, url }) => { + return ( + label.toLowerCase().includes(searchTerm) || + url.toLowerCase().includes(searchTerm) + ); + }); + + return ( + <> + <EuiSpacer size="m" /> + <EuiFieldSearch + fullWidth + onChange={e => setSearchTerm(e.target.value)} + placeholder={i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.searchInput.filter', + { + defaultMessage: 'Filter links by Name and URL...' + } + )} + /> + <EuiSpacer size="s" /> + <ManagedTable + noItemsMessage={ + isEmpty(items) ? ( + <LoadingStatePrompt /> + ) : ( + <NoResultFound value={searchTerm} /> + ) + } + items={filteredItems} + columns={columns} + initialPageSize={10} + initialSortField="@timestamp" + initialSortDirection="desc" + /> + </> + ); +}; + +const NoResultFound = ({ value }: { value: string }) => ( + <EuiFlexGroup justifyContent="spaceAround"> + <EuiFlexItem grow={false}> + <EuiText size="s"> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.noResultFound', + { + defaultMessage: `No results for "{value}".`, + values: { value } + } + )} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx new file mode 100644 index 0000000000000..e75004918f430 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiEmptyPrompt } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { CreateCustomLinkButton } from './CreateCustomLinkButton'; + +export const EmptyPrompt = ({ + onCreateCustomLinkClick +}: { + onCreateCustomLinkClick: () => void; +}) => { + return ( + <EuiEmptyPrompt + iconType="link" + iconColor="" + title={ + <h2> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.emptyPromptTitle', + { + defaultMessage: 'No links found.' + } + )} + </h2> + } + body={ + <> + <p> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.emptyPromptText', + { + defaultMessage: + "Let's change that! You can add custom links to the Actions context menu by the transaction details for each service. Create a helpful link to your company's support portal or open a new bug report. Learn more about it in our docs." + } + )} + </p> + </> + } + actions={<CreateCustomLinkButton onClick={onCreateCustomLinkClick} />} + /> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/Title.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx similarity index 81% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/Title.tsx rename to x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx index d7f90e0919733..17ec42b3e2016 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/Title.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx @@ -14,8 +14,8 @@ export const Title = () => ( <EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}> <EuiFlexItem grow={false}> <h1> - {i18n.translate('xpack.apm.settings.customizeUI.customActions', { - defaultMessage: 'Custom actions' + {i18n.translate('xpack.apm.settings.customizeUI.customLink', { + defaultMessage: 'Custom Links' })} </h1> </EuiFlexItem> @@ -25,10 +25,10 @@ export const Title = () => ( type="iInCircle" position="top" content={i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.info', + 'xpack.apm.settings.customizeUI.customLink.info', { defaultMessage: - "These actions will be shown in the 'Actions' context menu for the trace and error detail components." + "These links will be shown in the 'Actions' context menu for the transaction detail." } )} /> diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/__test__/CustomLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/__test__/CustomLink.test.tsx new file mode 100644 index 0000000000000..f02cc2be8268d --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/__test__/CustomLink.test.tsx @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fireEvent, render, wait } from '@testing-library/react'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { CustomLinkOverview } from '../'; +import * as hooks from '../../../../../../hooks/useFetcher'; +import { + expectTextsInDocument, + MockApmPluginContextWrapper +} from '../../../../../../utils/testHelpers'; +import * as saveCustomLink from '../CustomLinkFlyout/saveCustomLink'; +import * as apmApi from '../../../../../../services/rest/createCallApmApi'; + +const data = [ + { + id: '1', + label: 'label 1', + url: 'url 1', + 'service.name': 'opbeans-java' + }, + { + id: '2', + label: 'label 2', + url: 'url 2', + 'transaction.type': 'request' + } +]; + +describe('CustomLink', () => { + describe('empty prompt', () => { + beforeAll(() => { + spyOn(hooks, 'useFetcher').and.returnValue({ + data: [], + status: 'success' + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + it('shows when no link is available', () => { + const component = render(<CustomLinkOverview />); + expectTextsInDocument(component, ['No links found.']); + }); + it('opens flyout when click to create new link', () => { + const { queryByText, getByText } = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expect(queryByText('Create link')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(getByText('Create custom link')); + }); + expect(queryByText('Create link')).toBeInTheDocument(); + }); + }); + + describe('overview', () => { + beforeAll(() => { + spyOn(hooks, 'useFetcher').and.returnValue({ + data, + status: 'success' + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('shows a table with all custom link', () => { + const component = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expectTextsInDocument(component, [ + 'label 1', + 'url 1', + 'label 2', + 'url 2' + ]); + }); + + it('checks if create custom link button is available and working', () => { + const { queryByText, getByText } = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expect(queryByText('Create link')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(getByText('Create custom link')); + }); + expect(queryByText('Create link')).toBeInTheDocument(); + }); + }); + + describe('Flyout', () => { + const refetch = jest.fn(); + let callApmApiSpy: Function; + let saveCustomLinkSpy: Function; + beforeAll(() => { + callApmApiSpy = spyOn(apmApi, 'callApmApi'); + saveCustomLinkSpy = spyOn(saveCustomLink, 'saveCustomLink'); + spyOn(hooks, 'useFetcher').and.returnValue({ + data, + status: 'success', + refetch + }); + }); + afterEach(() => { + jest.resetAllMocks(); + }); + + const openFlyout = () => { + const component = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expect(component.queryByText('Create link')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(component.getByText('Create custom link')); + }); + expect(component.queryByText('Create link')).toBeInTheDocument(); + return component; + }; + + it('creates a custom link', async () => { + const component = openFlyout(); + const labelInput = component.getByLabelText('label'); + act(() => { + fireEvent.change(labelInput, { + target: { value: 'foo' } + }); + }); + const urlInput = component.getByLabelText('url'); + act(() => { + fireEvent.change(urlInput, { + target: { value: 'bar' } + }); + }); + await act(async () => { + await wait(() => fireEvent.submit(component.getByText('Save'))); + }); + expect(saveCustomLinkSpy).toHaveBeenCalledTimes(1); + }); + + it('deletes a custom link', async () => { + const component = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expect(component.queryByText('Create link')).not.toBeInTheDocument(); + const editButtons = component.getAllByLabelText('Edit'); + expect(editButtons.length).toEqual(2); + act(() => { + fireEvent.click(editButtons[0]); + }); + expect(component.queryByText('Create link')).toBeInTheDocument(); + await act(async () => { + await wait(() => fireEvent.click(component.getByText('Delete'))); + }); + expect(callApmApiSpy).toHaveBeenCalled(); + expect(refetch).toHaveBeenCalled(); + }); + + describe('Filters', () => { + const addFilterField = ( + component: ReturnType<typeof openFlyout>, + amount: number + ) => { + for (let i = 1; i <= amount; i++) { + fireEvent.click(component.getByText('Add another filter')); + } + }; + it('checks if add filter button is disabled after all elements have been added', () => { + const component = openFlyout(); + expect(component.getAllByText('service.name').length).toEqual(1); + addFilterField(component, 1); + expect(component.getAllByText('service.name').length).toEqual(2); + addFilterField(component, 2); + expect(component.getAllByText('service.name').length).toEqual(4); + // After 4 items, the button is disabled + addFilterField(component, 2); + expect(component.getAllByText('service.name').length).toEqual(4); + }); + it('removes items already selected', () => { + const component = openFlyout(); + + const addFieldAndCheck = ( + fieldName: string, + selectValue: string, + addNewFilter: boolean, + optionsExpected: string[] + ) => { + if (addNewFilter) { + addFilterField(component, 1); + } + const field = component.getByLabelText( + fieldName + ) as HTMLSelectElement; + const optionsAvailable = Object.values(field) + .map(option => (option as HTMLOptionElement).text) + .filter(option => option); + + act(() => { + fireEvent.change(field, { + target: { value: selectValue } + }); + }); + expect(field.value).toEqual(selectValue); + expect(optionsAvailable).toEqual(optionsExpected); + }; + + addFieldAndCheck('filter-0', 'transaction.name', false, [ + 'Select field...', + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name' + ]); + + addFieldAndCheck('filter-1', 'service.name', true, [ + 'Select field...', + 'service.name', + 'service.environment', + 'transaction.type' + ]); + + addFieldAndCheck('filter-2', 'transaction.type', true, [ + 'Select field...', + 'service.environment', + 'transaction.type' + ]); + + addFieldAndCheck('filter-3', 'service.environment', true, [ + 'Select field...', + 'service.environment' + ]); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx new file mode 100644 index 0000000000000..bc1882c8c2785 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { CustomLink } from '../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { useFetcher, FETCH_STATUS } from '../../../../../hooks/useFetcher'; +import { CustomLinkFlyout } from './CustomLinkFlyout'; +import { CustomLinkTable } from './CustomLinkTable'; +import { EmptyPrompt } from './EmptyPrompt'; +import { Title } from './Title'; +import { CreateCustomLinkButton } from './CreateCustomLinkButton'; + +export const CustomLinkOverview = () => { + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); + const [customLinkSelected, setCustomLinkSelected] = useState< + CustomLink | undefined + >(); + + const { data: customLinks, status, refetch } = useFetcher( + callApmApi => callApmApi({ pathname: '/api/apm/settings/custom_links' }), + [] + ); + + useEffect(() => { + if (customLinkSelected) { + setIsFlyoutOpen(true); + } + }, [customLinkSelected]); + + const onCloseFlyout = () => { + setCustomLinkSelected(undefined); + setIsFlyoutOpen(false); + }; + + const onCreateCustomLinkClick = () => { + setIsFlyoutOpen(true); + }; + + const showEmptyPrompt = + status === FETCH_STATUS.SUCCESS && isEmpty(customLinks); + + return ( + <> + {isFlyoutOpen && ( + <CustomLinkFlyout + onClose={onCloseFlyout} + customLinkSelected={customLinkSelected} + onSave={() => { + onCloseFlyout(); + refetch(); + }} + onDelete={() => { + onCloseFlyout(); + refetch(); + }} + /> + )} + <EuiPanel> + <EuiFlexGroup alignItems="center"> + <EuiFlexItem grow={false}> + <Title /> + </EuiFlexItem> + {!showEmptyPrompt && ( + <EuiFlexItem> + <EuiFlexGroup alignItems="center" justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <CreateCustomLinkButton onClick={onCreateCustomLinkClick} /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + )} + </EuiFlexGroup> + + <EuiSpacer size="m" /> + + {showEmptyPrompt ? ( + <EmptyPrompt onCreateCustomLinkClick={onCreateCustomLinkClick} /> + ) : ( + <CustomLinkTable + items={customLinks} + onCustomLinkSelected={setCustomLinkSelected} + /> + )} + </EuiPanel> + </> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx index 17a4b2f847679..1cd1298fdd549 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiTitle, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { CustomActionsOverview } from './CustomActionsOverview'; +import { CustomLinkOverview } from './CustomLink'; export const CustomizeUI = () => { return ( @@ -20,7 +20,7 @@ export const CustomizeUI = () => { </h1> </EuiTitle> <EuiSpacer size="l" /> - <CustomActionsOverview /> + <CustomLinkOverview /> </> ); }; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useCallApmApi.ts b/x-pack/legacy/plugins/apm/public/hooks/useCallApmApi.ts deleted file mode 100644 index b28b295d8189e..0000000000000 --- a/x-pack/legacy/plugins/apm/public/hooks/useCallApmApi.ts +++ /dev/null @@ -1,17 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useMemo } from 'react'; -import { createCallApmApi } from '../services/rest/createCallApmApi'; -import { useApmPluginContext } from './useApmPluginContext'; - -export function useCallApmApi() { - const { http } = useApmPluginContext().core; - - return useMemo(() => { - return createCallApmApi(http); - }, [http]); -} diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx index d2202fff996b1..c2530d6982c3b 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx @@ -9,8 +9,7 @@ import { i18n } from '@kbn/i18n'; import { IHttpFetchError } from 'src/core/public'; import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; import { LoadingIndicatorContext } from '../context/LoadingIndicatorContext'; -import { APMClient } from '../services/rest/createCallApmApi'; -import { useCallApmApi } from './useCallApmApi'; +import { APMClient, callApmApi } from '../services/rest/createCallApmApi'; import { useApmPluginContext } from './useApmPluginContext'; import { useLoadingIndicator } from './useLoadingIndicator'; @@ -46,8 +45,6 @@ export function useFetcher<TReturn>( const { preservePreviousData = true } = options; const { setIsLoading } = useLoadingIndicator(); - const callApmApi = useCallApmApi(); - const { dispatchStatus } = useContext(LoadingIndicatorContext); const [result, setResult] = useState<Result<InferResponseType<TReturn>>>({ data: undefined, diff --git a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx index 0054f963ba8f2..0103dd72a3fea 100644 --- a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx +++ b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx @@ -39,6 +39,7 @@ import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { setReadonlyBadge } from './updateBadge'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { APMIndicesPermission } from '../components/app/APMIndicesPermission'; +import { createCallApmApi } from '../services/rest/createCallApmApi'; export const REACT_APP_ROOT_ID = 'react-apm-root'; @@ -104,6 +105,7 @@ export class ApmPlugin public start(core: CoreStart) { const i18nCore = core.i18n; const plugins = this.setupPlugins; + createCallApmApi(core.http); // Once we're actually an NP plugin we'll get the config from the // initializerContext like: @@ -157,7 +159,7 @@ export class ApmPlugin ); // create static index pattern and store as saved object. Not needed by APM UI but for legacy reasons in Discover, Dashboard etc. - createStaticIndexPattern(core.http).catch(e => { + createStaticIndexPattern().catch(e => { // eslint-disable-next-line no-console console.log('Error fetching static index pattern', e); }); diff --git a/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts b/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts index 9cca9469bba0e..2d4fd83003179 100644 --- a/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts +++ b/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts @@ -5,7 +5,7 @@ */ import * as callApiExports from '../rest/callApi'; -import { createCallApmApi, APMClient } from '../rest/createCallApmApi'; +import { createCallApmApi, callApmApi } from '../rest/createCallApmApi'; import { HttpSetup } from 'kibana/public'; const callApi = jest @@ -13,9 +13,8 @@ const callApi = jest .mockImplementation(() => Promise.resolve(null)); describe('callApmApi', () => { - let callApmApi: APMClient; beforeEach(() => { - callApmApi = createCallApmApi({} as HttpSetup); + createCallApmApi({} as HttpSetup); }); afterEach(() => { diff --git a/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts b/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts index 220320216788a..2fffb40d353fc 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts @@ -19,8 +19,14 @@ export type APMClientOptions = Omit<FetchOptions, 'query' | 'body'> & { }; }; -export const createCallApmApi = (http: HttpSetup) => - ((options: APMClientOptions) => { +export let callApmApi: APMClient = () => { + throw new Error( + 'callApmApi has to be initialized before used. Call createCallApmApi first.' + ); +}; + +export function createCallApmApi(http: HttpSetup) { + callApmApi = ((options: APMClientOptions) => { const { pathname, params = {}, ...opts } = options; const path = (params.path || {}) as Record<string, any>; @@ -36,3 +42,4 @@ export const createCallApmApi = (http: HttpSetup) => query: params.query }); }) as APMClient; +} diff --git a/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts b/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts index 8e1234dd55e69..1efcc98bbbd66 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HttpSetup } from 'kibana/public'; -import { createCallApmApi } from './createCallApmApi'; +import { callApmApi } from './createCallApmApi'; -export const createStaticIndexPattern = async (http: HttpSetup) => { - const callApmApi = createCallApmApi(http); +export const createStaticIndexPattern = async () => { return await callApmApi({ method: 'POST', pathname: '/api/apm/index_pattern/static' diff --git a/x-pack/legacy/plugins/apm/public/services/rest/ml.ts b/x-pack/legacy/plugins/apm/public/services/rest/ml.ts index 5e64d7e1ce716..1c618098b36e3 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/ml.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/ml.ts @@ -16,7 +16,7 @@ import { } from '../../../../../../plugins/apm/common/ml_job_constants'; import { callApi } from './callApi'; import { ESFilter } from '../../../../../../plugins/apm/typings/elasticsearch'; -import { createCallApmApi, APMClient } from './createCallApmApi'; +import { callApmApi } from './createCallApmApi'; interface MlResponseItem { id: string; @@ -36,7 +36,6 @@ interface StartedMLJobApiResponse { } async function getTransactionIndices(http: HttpSetup) { - const callApmApi: APMClient = createCallApmApi(http); const indices = await callApmApi({ method: 'GET', pathname: `/api/apm/settings/apm-indices` diff --git a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx index dec2257746e50..4ee45f7b3330b 100644 --- a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx @@ -29,6 +29,7 @@ import { ApmPluginContextValue } from '../context/ApmPluginContext'; import { ConfigSchema } from '../new-platform/plugin'; +import { createCallApmApi } from '../services/rest/createCallApmApi'; export function toJson(wrapper: ReactWrapper) { return enzymeToJson(wrapper, { @@ -118,6 +119,7 @@ interface MockSetup { 'apm_oss.transactionIndices': string; 'apm_oss.metricsIndices': string; apmAgentConfigurationIndex: string; + apmCustomLinkIndex: string; }; } @@ -162,7 +164,8 @@ export async function inspectSearchParams( 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }, dynamicIndexPattern: null as any }; @@ -195,7 +198,8 @@ const mockCore = { }, notifications: { toasts: { - addWarning: () => {} + addWarning: () => {}, + addDanger: () => {} } } }; @@ -222,6 +226,9 @@ export function MockApmPluginContextWrapper({ children?: ReactNode; value?: ApmPluginContextValue; }) { + if (value.core?.http) { + createCallApmApi(value.core?.http); + } return ( <ApmPluginContext.Provider value={{ diff --git a/x-pack/legacy/plugins/beats_management/public/components/inputs/code_editor.tsx b/x-pack/legacy/plugins/beats_management/public/components/inputs/code_editor.tsx index 6ec2a7f02f3a3..46ea90a9c1b30 100644 --- a/x-pack/legacy/plugins/beats_management/public/components/inputs/code_editor.tsx +++ b/x-pack/legacy/plugins/beats_management/public/components/inputs/code_editor.tsx @@ -93,13 +93,11 @@ class CodeEditor extends Component< error={error ? getErrorMessage() : []} > <EuiCodeEditor - id={id} name={name} mode={mode} theme="github" value={getValue() || ''} isReadOnly={isReadOnly || false} - isInvalid={error} onChange={this.handleChange} onBlur={this.handleBlur} width={fullWidth ? '100%' : undefined} diff --git a/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset_manager.tsx b/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset_manager.tsx index 44049b17bfaab..3785f81cc25b9 100644 --- a/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset_manager.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset_manager.tsx @@ -110,7 +110,8 @@ export class AssetManager extends PureComponent<Props, State> { this.props.onAssetDelete(this.state.deleteId); }; - private handleFileUpload = (files: FileList) => { + private handleFileUpload = (files: FileList | null) => { + if (files == null) return; this.setState({ isLoading: true }); Promise.all(Array.from(files).map(file => this.props.onAssetAdd(file))).finally(() => { this.setState({ isLoading: false }); diff --git a/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset_modal.tsx b/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset_modal.tsx index f8bce19a46968..3dfbb1b1fde3c 100644 --- a/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset_modal.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset_modal.tsx @@ -43,7 +43,7 @@ interface Props { /** Function to invoke when the modal is closed */ onClose: () => void; /** Function to invoke when a file is uploaded */ - onFileUpload: (assets: FileList) => void; + onFileUpload: (assets: FileList | null) => void; /** Function to invoke when an asset is copied */ onAssetCopy: (asset: AssetType) => void; /** Function to invoke when an asset is created */ diff --git a/x-pack/legacy/plugins/canvas/public/components/custom_element_modal/custom_element_modal.tsx b/x-pack/legacy/plugins/canvas/public/components/custom_element_modal/custom_element_modal.tsx index bd7fc775a34a0..56bd0bf5e9f2a 100644 --- a/x-pack/legacy/plugins/canvas/public/components/custom_element_modal/custom_element_modal.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/custom_element_modal/custom_element_modal.tsx @@ -100,8 +100,9 @@ export class CustomElementModal extends PureComponent<Props, State> { this.setState({ [type]: value }); }; - private _handleUpload = (files: File[]) => { - const [file] = files; + private _handleUpload = (files: FileList | null) => { + if (files == null) return; + const file = files[0]; const [type, subtype] = get(file, 'type', '').split('/'); if (type === 'image' && VALID_IMAGE_TYPES.indexOf(subtype) >= 0) { encode(file).then((dataurl: string) => this._handleChange('image', dataurl)); diff --git a/x-pack/legacy/plugins/canvas/public/components/keyboard_shortcuts_doc/__examples__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot b/x-pack/legacy/plugins/canvas/public/components/keyboard_shortcuts_doc/__examples__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot index 35cdd5ac378f4..9954ae0147a97 100644 --- a/x-pack/legacy/plugins/canvas/public/components/keyboard_shortcuts_doc/__examples__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot +++ b/x-pack/legacy/plugins/canvas/public/components/keyboard_shortcuts_doc/__examples__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot @@ -82,1060 +82,1064 @@ exports[`Storyshots components/KeyboardShortcutsDoc default 1`] = ` className="euiFlyoutBody__overflow" > <div - className="canvasKeyboardShortcut" + className="euiFlyoutBody__overflowContent" > - <h4 - className="euiTitle euiTitle--xsmall" - > - Element controls - </h4> - <hr - className="euiHorizontalRule euiHorizontalRule--full euiHorizontalRule--marginSmall" - /> - <dl - className="euiDescriptionList euiDescriptionList--column euiDescriptionList--reverse euiDescriptionList--compressed" - > - <dt - className="euiDescriptionList__title" - > - Cut - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - X - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Copy - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - C - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Paste - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - V - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Clone - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - D - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Delete - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - DEL - </code> - </span> - </span> - <span> - - or - - </span> - <span> - <span> - <code> - BACKSPACE - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Bring forward - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - ↑ - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Bring to front - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - SHIFT - </code> - </span> - - <span> - <code> - ↑ - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Send backward - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - ↓ - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Send to back - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - SHIFT - </code> - </span> - - <span> - <code> - ↓ - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Group - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - G - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Ungroup - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - U - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Shift up by 10px - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ↑ - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Shift down by 10px - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ↓ - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Shift left by 10px - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ← - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Shift right by 10px - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - → - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Shift up by 1px - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - SHIFT - </code> - </span> - - <span> - <code> - ↑ - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Shift down by 1px - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - SHIFT - </code> - </span> - - <span> - <code> - ↓ - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Shift left by 1px - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - SHIFT - </code> - </span> - - <span> - <code> - ← - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Shift right by 1px - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - SHIFT - </code> - </span> - - <span> - <code> - → - </code> - </span> - </span> - </dd> - </dl> <div - className="euiSpacer euiSpacer--l" - /> - </div> - <div - className="canvasKeyboardShortcut" - > - <h4 - className="euiTitle euiTitle--xsmall" - > - Expression controls - </h4> - <hr - className="euiHorizontalRule euiHorizontalRule--full euiHorizontalRule--marginSmall" - /> - <dl - className="euiDescriptionList euiDescriptionList--column euiDescriptionList--reverse euiDescriptionList--compressed" + className="canvasKeyboardShortcut" > - <dt - className="euiDescriptionList__title" - > - Run whole expression - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - ENTER - </code> - </span> - </span> - </dd> - </dl> + <h4 + className="euiTitle euiTitle--xsmall" + > + Element controls + </h4> + <hr + className="euiHorizontalRule euiHorizontalRule--full euiHorizontalRule--marginSmall" + /> + <dl + className="euiDescriptionList euiDescriptionList--column euiDescriptionList--reverse euiDescriptionList--compressed" + > + <dt + className="euiDescriptionList__title" + > + Cut + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + X + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Copy + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + C + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Paste + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + V + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Clone + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + D + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Delete + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + DEL + </code> + </span> + </span> + <span> + + or + + </span> + <span> + <span> + <code> + BACKSPACE + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Bring forward + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + ↑ + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Bring to front + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + SHIFT + </code> + </span> + + <span> + <code> + ↑ + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Send backward + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + ↓ + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Send to back + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + SHIFT + </code> + </span> + + <span> + <code> + ↓ + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Group + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + G + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Ungroup + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + U + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Shift up by 10px + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ↑ + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Shift down by 10px + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ↓ + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Shift left by 10px + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ← + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Shift right by 10px + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + → + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Shift up by 1px + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + SHIFT + </code> + </span> + + <span> + <code> + ↑ + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Shift down by 1px + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + SHIFT + </code> + </span> + + <span> + <code> + ↓ + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Shift left by 1px + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + SHIFT + </code> + </span> + + <span> + <code> + ← + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Shift right by 1px + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + SHIFT + </code> + </span> + + <span> + <code> + → + </code> + </span> + </span> + </dd> + </dl> + <div + className="euiSpacer euiSpacer--l" + /> + </div> <div - className="euiSpacer euiSpacer--l" - /> - </div> - <div - className="canvasKeyboardShortcut" - > - <h4 - className="euiTitle euiTitle--xsmall" - > - Editor controls - </h4> - <hr - className="euiHorizontalRule euiHorizontalRule--full euiHorizontalRule--marginSmall" - /> - <dl - className="euiDescriptionList euiDescriptionList--column euiDescriptionList--reverse euiDescriptionList--compressed" + className="canvasKeyboardShortcut" > - <dt - className="euiDescriptionList__title" - > - Select multiple elements - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - SHIFT - </code> - </span> - - <span> - <code> - CLICK - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Resize from center - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - DRAG - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Move, resize, and rotate without snapping - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - DRAG - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Select element below - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - CLICK - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Undo last action - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - Z - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Redo last action - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - SHIFT - </code> - </span> - - <span> - <code> - Z - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Go to previous page - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - [ - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Go to next page - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - ] - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Toggle edit mode - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - E - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Show grid - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - G - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Refresh workpad - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - R - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Zoom in - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - + - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Zoom out - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - - - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Reset zoom to 100% - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - CTRL - </code> - </span> - - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - [ - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Enter presentation mode - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - F - </code> - </span> - </span> - <span> - - or - - </span> - <span> - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - P - </code> - </span> - </span> - </dd> - </dl> + <h4 + className="euiTitle euiTitle--xsmall" + > + Expression controls + </h4> + <hr + className="euiHorizontalRule euiHorizontalRule--full euiHorizontalRule--marginSmall" + /> + <dl + className="euiDescriptionList euiDescriptionList--column euiDescriptionList--reverse euiDescriptionList--compressed" + > + <dt + className="euiDescriptionList__title" + > + Run whole expression + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + ENTER + </code> + </span> + </span> + </dd> + </dl> + <div + className="euiSpacer euiSpacer--l" + /> + </div> <div - className="euiSpacer euiSpacer--l" - /> - </div> - <div - className="canvasKeyboardShortcut" - > - <h4 - className="euiTitle euiTitle--xsmall" + className="canvasKeyboardShortcut" > - Presentation controls - </h4> - <hr - className="euiHorizontalRule euiHorizontalRule--full euiHorizontalRule--marginSmall" - /> - <dl - className="euiDescriptionList euiDescriptionList--column euiDescriptionList--reverse euiDescriptionList--compressed" - > - <dt - className="euiDescriptionList__title" - > - Enter presentation mode - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - F - </code> - </span> - </span> - <span> - - or - - </span> - <span> - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - P - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Exit presentation mode - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ESC - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Go to previous page - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - [ - </code> - </span> - </span> - <span> - - or - - </span> - <span> - <span> - <code> - BACKSPACE - </code> - </span> - </span> - <span> - - or - - </span> - <span> - <span> - <code> - ← - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Go to next page - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - ] - </code> - </span> - </span> - <span> - - or - - </span> - <span> - <span> - <code> - SPACE - </code> - </span> - </span> - <span> - - or - - </span> - <span> - <span> - <code> - → - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Refresh workpad - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - ALT - </code> - </span> - - <span> - <code> - R - </code> - </span> - </span> - </dd> - <dt - className="euiDescriptionList__title" - > - Toggle page cycling - </dt> - <dd - className="euiDescriptionList__description" - > - <span> - <span> - <code> - P - </code> - </span> - </span> - </dd> - </dl> + <h4 + className="euiTitle euiTitle--xsmall" + > + Editor controls + </h4> + <hr + className="euiHorizontalRule euiHorizontalRule--full euiHorizontalRule--marginSmall" + /> + <dl + className="euiDescriptionList euiDescriptionList--column euiDescriptionList--reverse euiDescriptionList--compressed" + > + <dt + className="euiDescriptionList__title" + > + Select multiple elements + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + SHIFT + </code> + </span> + + <span> + <code> + CLICK + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Resize from center + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + DRAG + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Move, resize, and rotate without snapping + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + DRAG + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Select element below + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + CLICK + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Undo last action + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + Z + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Redo last action + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + SHIFT + </code> + </span> + + <span> + <code> + Z + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Go to previous page + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + [ + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Go to next page + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + ] + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Toggle edit mode + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + E + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Show grid + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + G + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Refresh workpad + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + R + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Zoom in + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + + + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Zoom out + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + - + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Reset zoom to 100% + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + CTRL + </code> + </span> + + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + [ + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Enter presentation mode + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + F + </code> + </span> + </span> + <span> + + or + + </span> + <span> + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + P + </code> + </span> + </span> + </dd> + </dl> + <div + className="euiSpacer euiSpacer--l" + /> + </div> <div - className="euiSpacer euiSpacer--l" - /> + className="canvasKeyboardShortcut" + > + <h4 + className="euiTitle euiTitle--xsmall" + > + Presentation controls + </h4> + <hr + className="euiHorizontalRule euiHorizontalRule--full euiHorizontalRule--marginSmall" + /> + <dl + className="euiDescriptionList euiDescriptionList--column euiDescriptionList--reverse euiDescriptionList--compressed" + > + <dt + className="euiDescriptionList__title" + > + Enter presentation mode + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + F + </code> + </span> + </span> + <span> + + or + + </span> + <span> + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + P + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Exit presentation mode + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ESC + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Go to previous page + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + [ + </code> + </span> + </span> + <span> + + or + + </span> + <span> + <span> + <code> + BACKSPACE + </code> + </span> + </span> + <span> + + or + + </span> + <span> + <span> + <code> + ← + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Go to next page + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + ] + </code> + </span> + </span> + <span> + + or + + </span> + <span> + <span> + <code> + SPACE + </code> + </span> + </span> + <span> + + or + + </span> + <span> + <span> + <code> + → + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Refresh workpad + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + ALT + </code> + </span> + + <span> + <code> + R + </code> + </span> + </span> + </dd> + <dt + className="euiDescriptionList__title" + > + Toggle page cycling + </dt> + <dd + className="euiDescriptionList__description" + > + <span> + <span> + <code> + P + </code> + </span> + </span> + </dd> + </dl> + <div + className="euiSpacer euiSpacer--l" + /> + </div> </div> </div> </div> diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_editor.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_editor.tsx index f2a4c28afcdae..9c7cffa775781 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_editor.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_editor.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, ButtonHTMLAttributes } from 'react'; import { EuiPopover, EuiFormRow, @@ -23,7 +23,6 @@ import { EuiForm, EuiSpacer, EuiIconTip, - EuiComboBoxOptionProps, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import classNames from 'classnames'; @@ -224,14 +223,12 @@ export function FieldEditor({ }} singleSelection={{ asPlainText: true }} isClearable={false} - options={ - toOptions(allFields, initialField) as Array<EuiComboBoxOptionProps<string>> - } + options={toOptions(allFields, initialField)} selectedOptions={[ { value: currentField.name, label: currentField.name, - type: currentField.type, + type: currentField.type as ButtonHTMLAttributes<HTMLButtonElement>['type'], }, ]} renderOption={(option, searchValue, contentClassName) => { @@ -379,12 +376,12 @@ export function FieldEditor({ function toOptions( fields: WorkspaceField[], currentField: WorkspaceField -): Array<{ label: string; value: string; type: string }> { +): Array<{ label: string; value: string; type: ButtonHTMLAttributes<HTMLButtonElement>['type'] }> { return fields .filter(field => !field.selected || field === currentField) .map(({ name, type }) => ({ label: name, value: name, - type, + type: type as ButtonHTMLAttributes<HTMLButtonElement>['type'], })); } diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx index 77435fcdf3eed..8651751ea365b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx @@ -7,7 +7,7 @@ import _ from 'lodash'; import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiComboBoxOptionOption } from '@elastic/eui'; import classNames from 'classnames'; import { EuiHighlight } from '@elastic/eui'; import { OperationType } from '../indexpattern'; @@ -138,10 +138,10 @@ export function FieldSelect({ placeholder={i18n.translate('xpack.lens.indexPattern.fieldPlaceholder', { defaultMessage: 'Field', })} - options={(memoizedFieldOptions as unknown) as EuiComboBoxOptionProps[]} + options={(memoizedFieldOptions as unknown) as EuiComboBoxOptionOption[]} isInvalid={Boolean(incompatibleSelectedOperationType && selectedColumnOperationType)} selectedOptions={ - selectedColumnOperationType + ((selectedColumnOperationType ? selectedColumnSourceField ? [ { @@ -150,7 +150,7 @@ export function FieldSelect({ }, ] : [memoizedFieldOptions[0]] - : [] + : []) as unknown) as EuiComboBoxOptionOption[] } singleSelection={{ asPlainText: true }} onChange={choices => { diff --git a/x-pack/legacy/plugins/maps/common/constants.ts b/x-pack/legacy/plugins/maps/common/constants.ts index 4f1b3223967a5..53289fbbc9005 100644 --- a/x-pack/legacy/plugins/maps/common/constants.ts +++ b/x-pack/legacy/plugins/maps/common/constants.ts @@ -55,10 +55,10 @@ export const ES_SEARCH = 'ES_SEARCH'; export const ES_PEW_PEW = 'ES_PEW_PEW'; export const EMS_XYZ = 'EMS_XYZ'; // identifies a custom TMS source. Name is a little unfortunate. -export const FIELD_ORIGIN = { - SOURCE: 'source', - JOIN: 'join', -}; +export enum FIELD_ORIGIN { + SOURCE = 'source', + JOIN = 'join', +} export const SOURCE_DATA_ID_ORIGIN = 'source'; export const META_ID_ORIGIN_SUFFIX = 'meta'; @@ -139,6 +139,8 @@ export enum GRID_RESOLUTION { MOST_FINE = 'MOST_FINE', } +export const TOP_TERM_PERCENTAGE_SUFFIX = '__percentage'; + export const COUNT_PROP_LABEL = i18n.translate('xpack.maps.aggs.defaultCountLabel', { defaultMessage: 'count', }); diff --git a/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts b/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts index f1d172cf5ad16..f03f828200bbd 100644 --- a/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts +++ b/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts @@ -40,8 +40,8 @@ export type AbstractESAggDescriptor = AbstractESSourceDescriptor & { }; export type ESGeoGridSourceDescriptor = AbstractESAggDescriptor & { - requestType: RENDER_AS; - resolution: GRID_RESOLUTION; + requestType?: RENDER_AS; + resolution?: GRID_RESOLUTION; }; export type ESSearchSourceDescriptor = AbstractESSourceDescriptor & { @@ -119,3 +119,36 @@ export type VectorLayerDescriptor = LayerDescriptor & { joins?: JoinDescriptor[]; style?: unknown; }; + +export type RangeFieldMeta = { + min: number; + max: number; + delta: number; + isMinOutsideStdRange?: boolean; + isMaxOutsideStdRange?: boolean; +}; + +export type Category = { + key: string; + count: number; +}; + +export type CategoryFieldMeta = { + categories: Category[]; +}; + +export type GeometryTypes = { + isPointsOnly: boolean; + isLinesOnly: boolean; + isPolygonsOnly: boolean; +}; + +export type StyleMetaDescriptor = { + geometryTypes?: GeometryTypes; + fieldMeta: { + [key: string]: { + range: RangeFieldMeta; + categories: CategoryFieldMeta; + }; + }; +}; diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.js b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.js deleted file mode 100644 index 27ab8fc5bfb3a..0000000000000 --- a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.js +++ /dev/null @@ -1,96 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { AbstractField } from './field'; -import { AGG_TYPE } from '../../../common/constants'; -import { isMetricCountable } from '../util/is_metric_countable'; -import { ESAggMetricTooltipProperty } from '../tooltips/es_aggmetric_tooltip_property'; -import { getField, addFieldToDSL } from '../util/es_agg_utils'; - -export class ESAggMetricField extends AbstractField { - static type = 'ES_AGG'; - - constructor({ label, source, aggType, esDocField, origin }) { - super({ source, origin }); - this._label = label; - this._aggType = aggType; - this._esDocField = esDocField; - } - - getName() { - return this._source.getAggKey(this.getAggType(), this.getRootName()); - } - - getRootName() { - return this._getESDocFieldName(); - } - - async getLabel() { - return this._label - ? this._label - : this._source.getAggLabel(this.getAggType(), this.getRootName()); - } - - getAggType() { - return this._aggType; - } - - isValid() { - return this.getAggType() === AGG_TYPE.COUNT ? true : !!this._esDocField; - } - - async getDataType() { - return this.getAggType() === AGG_TYPE.TERMS ? 'string' : 'number'; - } - - _getESDocFieldName() { - return this._esDocField ? this._esDocField.getName() : ''; - } - - getRequestDescription() { - return this.getAggType() !== AGG_TYPE.COUNT - ? `${this.getAggType()} ${this.getRootName()}` - : AGG_TYPE.COUNT; - } - - async createTooltipProperty(value) { - const indexPattern = await this._source.getIndexPattern(); - return new ESAggMetricTooltipProperty( - this.getName(), - await this.getLabel(), - value, - indexPattern, - this - ); - } - - getValueAggDsl(indexPattern) { - const field = getField(indexPattern, this.getRootName()); - const aggType = this.getAggType(); - const aggBody = aggType === AGG_TYPE.TERMS ? { size: 1, shard_size: 1 } : {}; - return { - [aggType]: addFieldToDSL(aggBody, field), - }; - } - - supportsFieldMeta() { - // count and sum aggregations are not within field bounds so they do not support field meta. - return !isMetricCountable(this.getAggType()); - } - - canValueBeFormatted() { - // Do not use field formatters for counting metrics - return ![AGG_TYPE.COUNT, AGG_TYPE.UNIQUE_COUNT].includes(this.getAggType()); - } - - async getOrdinalFieldMetaRequest(config) { - return this._esDocField.getOrdinalFieldMetaRequest(config); - } - - async getCategoricalFieldMetaRequest() { - return this._esDocField.getCategoricalFieldMetaRequest(); - } -} diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.js b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.js deleted file mode 100644 index aeeffd63607ee..0000000000000 --- a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.js +++ /dev/null @@ -1,28 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ESAggMetricField } from './es_agg_field'; -import { AGG_TYPE } from '../../../common/constants'; - -describe('supportsFieldMeta', () => { - test('Non-counting aggregations should support field meta', () => { - const avgMetric = new ESAggMetricField({ aggType: AGG_TYPE.AVG }); - expect(avgMetric.supportsFieldMeta()).toBe(true); - const maxMetric = new ESAggMetricField({ aggType: AGG_TYPE.MAX }); - expect(maxMetric.supportsFieldMeta()).toBe(true); - const minMetric = new ESAggMetricField({ aggType: AGG_TYPE.MIN }); - expect(minMetric.supportsFieldMeta()).toBe(true); - }); - - test('Counting aggregations should not support field meta', () => { - const countMetric = new ESAggMetricField({ aggType: AGG_TYPE.COUNT }); - expect(countMetric.supportsFieldMeta()).toBe(false); - const sumMetric = new ESAggMetricField({ aggType: AGG_TYPE.SUM }); - expect(sumMetric.supportsFieldMeta()).toBe(false); - const uniqueCountMetric = new ESAggMetricField({ aggType: AGG_TYPE.UNIQUE_COUNT }); - expect(uniqueCountMetric.supportsFieldMeta()).toBe(false); - }); -}); diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.ts b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.ts new file mode 100644 index 0000000000000..7a65b5f9f6b46 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ESAggField, esAggFieldsFactory } from './es_agg_field'; +import { AGG_TYPE, FIELD_ORIGIN } from '../../../common/constants'; +import { IESAggSource } from '../sources/es_agg_source'; +import { IIndexPattern } from 'src/plugins/data/public'; + +const mockIndexPattern = { + title: 'wildIndex', + fields: [ + { + name: 'foo*', + }, + ], +} as IIndexPattern; + +const mockEsAggSource = { + getAggKey: (aggType: AGG_TYPE, fieldName: string) => { + return 'agg_key'; + }, + getAggLabel: (aggType: AGG_TYPE, fieldName: string) => { + return 'agg_label'; + }, + getIndexPattern: async () => { + return mockIndexPattern; + }, +} as IESAggSource; + +const defaultParams = { + label: 'my agg field', + source: mockEsAggSource, + aggType: AGG_TYPE.COUNT, + origin: FIELD_ORIGIN.SOURCE, +}; + +describe('supportsFieldMeta', () => { + test('Non-counting aggregations should support field meta', () => { + const avgMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.AVG }); + expect(avgMetric.supportsFieldMeta()).toBe(true); + const maxMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.MAX }); + expect(maxMetric.supportsFieldMeta()).toBe(true); + const minMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.MIN }); + expect(minMetric.supportsFieldMeta()).toBe(true); + const termsMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.TERMS }); + expect(termsMetric.supportsFieldMeta()).toBe(true); + }); + + test('Counting aggregations should not support field meta', () => { + const countMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.COUNT }); + expect(countMetric.supportsFieldMeta()).toBe(false); + const sumMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.SUM }); + expect(sumMetric.supportsFieldMeta()).toBe(false); + const uniqueCountMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.UNIQUE_COUNT }); + expect(uniqueCountMetric.supportsFieldMeta()).toBe(false); + }); +}); + +describe('esAggFieldsFactory', () => { + test('Should only create top terms field when term field is not provided', () => { + const fields = esAggFieldsFactory( + { type: AGG_TYPE.TERMS }, + mockEsAggSource, + FIELD_ORIGIN.SOURCE + ); + expect(fields.length).toBe(1); + }); + + test('Should create top terms and top terms percentage fields', () => { + const fields = esAggFieldsFactory( + { type: AGG_TYPE.TERMS, field: 'myField' }, + mockEsAggSource, + FIELD_ORIGIN.SOURCE + ); + expect(fields.length).toBe(2); + }); +}); diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.ts b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.ts new file mode 100644 index 0000000000000..9f08200442fea --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.ts @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IndexPattern } from 'src/plugins/data/public'; +import { IField } from './field'; +import { AggDescriptor } from '../../../common/descriptor_types'; +import { IESAggSource } from '../sources/es_agg_source'; +import { IVectorSource } from '../sources/vector_source'; +// @ts-ignore +import { ESDocField } from './es_doc_field'; +import { AGG_TYPE, FIELD_ORIGIN } from '../../../common/constants'; +import { isMetricCountable } from '../util/is_metric_countable'; +// @ts-ignore +import { ESAggMetricTooltipProperty } from '../tooltips/es_aggmetric_tooltip_property'; +import { getField, addFieldToDSL } from '../util/es_agg_utils'; +import { TopTermPercentageField } from './top_term_percentage_field'; + +export interface IESAggField extends IField { + getValueAggDsl(indexPattern: IndexPattern): unknown | null; + getBucketCount(): number; +} + +export class ESAggField implements IESAggField { + static type = 'ES_AGG'; + + private _source: IESAggSource; + private _origin: FIELD_ORIGIN; + private _label?: string; + private _aggType: AGG_TYPE; + private _esDocField?: unknown; + + constructor({ + label, + source, + aggType, + esDocField, + origin, + }: { + label?: string; + source: IESAggSource; + aggType: AGG_TYPE; + esDocField?: unknown; + origin: FIELD_ORIGIN; + }) { + this._source = source; + this._origin = origin; + this._label = label; + this._aggType = aggType; + this._esDocField = esDocField; + } + + getSource(): IVectorSource { + return this._source; + } + + getOrigin(): FIELD_ORIGIN { + return this._origin; + } + + getName(): string { + return this._source.getAggKey(this.getAggType(), this.getRootName()); + } + + getRootName(): string { + return this._getESDocFieldName(); + } + + async getLabel(): Promise<string> { + return this._label + ? this._label + : this._source.getAggLabel(this.getAggType(), this.getRootName()); + } + + getAggType(): AGG_TYPE { + return this._aggType; + } + + isValid(): boolean { + return this.getAggType() === AGG_TYPE.COUNT ? true : !!this._esDocField; + } + + async getDataType(): Promise<string> { + return this.getAggType() === AGG_TYPE.TERMS ? 'string' : 'number'; + } + + _getESDocFieldName(): string { + // TODO remove when esDocField is typed + // @ts-ignore + return this._esDocField ? this._esDocField.getName() : ''; + } + + async createTooltipProperty(value: number | string): Promise<unknown> { + const indexPattern = await this._source.getIndexPattern(); + return new ESAggMetricTooltipProperty( + this.getName(), + await this.getLabel(), + value, + indexPattern, + this + ); + } + + getValueAggDsl(indexPattern: IndexPattern): unknown | null { + if (this.getAggType() === AGG_TYPE.COUNT) { + return null; + } + + const field = getField(indexPattern, this.getRootName()); + const aggType = this.getAggType(); + const aggBody = aggType === AGG_TYPE.TERMS ? { size: 1, shard_size: 1 } : {}; + return { + [aggType]: addFieldToDSL(aggBody, field), + }; + } + + getBucketCount(): number { + // terms aggregation increases the overall number of buckets per split bucket + return this.getAggType() === AGG_TYPE.TERMS ? 1 : 0; + } + + supportsFieldMeta(): boolean { + // count and sum aggregations are not within field bounds so they do not support field meta. + return !isMetricCountable(this.getAggType()); + } + + canValueBeFormatted(): boolean { + // Do not use field formatters for counting metrics + return ![AGG_TYPE.COUNT, AGG_TYPE.UNIQUE_COUNT].includes(this.getAggType()); + } + + async getOrdinalFieldMetaRequest(): Promise<unknown> { + // TODO remove when esDocField is typed + // @ts-ignore + return this._esDocField.getOrdinalFieldMetaRequest(); + } + + async getCategoricalFieldMetaRequest(): Promise<unknown> { + // TODO remove when esDocField is typed + // @ts-ignore + return this._esDocField.getCategoricalFieldMetaRequest(); + } +} + +export function esAggFieldsFactory( + aggDescriptor: AggDescriptor, + source: IESAggSource, + origin: FIELD_ORIGIN +): IESAggField[] { + const aggField = new ESAggField({ + label: aggDescriptor.label, + esDocField: aggDescriptor.field + ? new ESDocField({ fieldName: aggDescriptor.field, source }) + : null, + aggType: aggDescriptor.type, + source, + origin, + }); + + const aggFields: IESAggField[] = [aggField]; + + if (aggDescriptor.field && aggDescriptor.type === AGG_TYPE.TERMS) { + aggFields.push(new TopTermPercentageField(aggField)); + } + + return aggFields; +} diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/field.ts b/x-pack/legacy/plugins/maps/public/layers/fields/field.ts index 57a916e93ffe0..f7c27fec1c6c7 100644 --- a/x-pack/legacy/plugins/maps/public/layers/fields/field.ts +++ b/x-pack/legacy/plugins/maps/public/layers/fields/field.ts @@ -13,12 +13,15 @@ export interface IField { canValueBeFormatted(): boolean; getLabel(): Promise<string>; getDataType(): Promise<string>; + getSource(): IVectorSource; + getOrigin(): FIELD_ORIGIN; + isValid(): boolean; } export class AbstractField implements IField { private _fieldName: string; private _source: IVectorSource; - private _origin: string; + private _origin: FIELD_ORIGIN; constructor({ fieldName, @@ -27,7 +30,7 @@ export class AbstractField implements IField { }: { fieldName: string; source: IVectorSource; - origin: string; + origin: FIELD_ORIGIN; }) { this._fieldName = fieldName; this._source = source; @@ -66,7 +69,7 @@ export class AbstractField implements IField { throw new Error('must implement Field#createTooltipProperty'); } - getOrigin(): string { + getOrigin(): FIELD_ORIGIN { return this._origin; } @@ -74,7 +77,7 @@ export class AbstractField implements IField { return false; } - async getOrdinalFieldMetaRequest(/* config */): Promise<unknown> { + async getOrdinalFieldMetaRequest(): Promise<unknown> { return null; } diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/top_term_percentage_field.ts b/x-pack/legacy/plugins/maps/public/layers/fields/top_term_percentage_field.ts new file mode 100644 index 0000000000000..cadf325652370 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/fields/top_term_percentage_field.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IESAggField } from './es_agg_field'; +import { IVectorSource } from '../sources/vector_source'; +// @ts-ignore +import { TooltipProperty } from '../tooltips/tooltip_property'; +import { TOP_TERM_PERCENTAGE_SUFFIX } from '../../../common/constants'; +import { FIELD_ORIGIN } from '../../../common/constants'; + +export class TopTermPercentageField implements IESAggField { + private _topTermAggField: IESAggField; + + constructor(topTermAggField: IESAggField) { + this._topTermAggField = topTermAggField; + } + + getSource(): IVectorSource { + return this._topTermAggField.getSource(); + } + + getOrigin(): FIELD_ORIGIN { + return this._topTermAggField.getOrigin(); + } + + getName(): string { + return `${this._topTermAggField.getName()}${TOP_TERM_PERCENTAGE_SUFFIX}`; + } + + getRootName(): string { + // top term percentage is a derived value so it has no root field + return ''; + } + + async getLabel(): Promise<string> { + const baseLabel = await this._topTermAggField.getLabel(); + return `${baseLabel}%`; + } + + isValid(): boolean { + return this._topTermAggField.isValid(); + } + + async getDataType(): Promise<string> { + return 'number'; + } + + async createTooltipProperty(value: unknown): Promise<unknown> { + return new TooltipProperty(this.getName(), await this.getLabel(), value); + } + + getValueAggDsl(): null { + return null; + } + + getBucketCount(): number { + return 0; + } + + supportsFieldMeta(): boolean { + return false; + } + + canValueBeFormatted(): boolean { + return false; + } +} diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.d.ts new file mode 100644 index 0000000000000..a91bb4a8bb1a7 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.d.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IESSource } from './es_source'; +import { AbstractESSource } from './es_source'; +import { AGG_TYPE } from '../../../common/constants'; + +export interface IESAggSource extends IESSource { + getAggKey(aggType: AGG_TYPE, fieldName: string): string; + getAggLabel(aggType: AGG_TYPE, fieldName: string): string; +} + +export class AbstractESAggSource extends AbstractESSource implements IESAggSource { + getAggKey(aggType: AGG_TYPE, fieldName: string): string; + getAggLabel(aggType: AGG_TYPE, fieldName: string): string; +} diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.js index 775535d9e2299..62f3369ceb3a3 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.js @@ -6,8 +6,8 @@ import { i18n } from '@kbn/i18n'; import { AbstractESSource } from './es_source'; -import { ESAggMetricField } from '../fields/es_agg_field'; -import { ESDocField } from '../fields/es_doc_field'; +import { esAggFieldsFactory } from '../fields/es_agg_field'; + import { AGG_TYPE, COUNT_PROP_LABEL, @@ -20,20 +20,14 @@ export const AGG_DELIMITER = '_of_'; export class AbstractESAggSource extends AbstractESSource { constructor(descriptor, inspectorAdapters) { super(descriptor, inspectorAdapters); - this._metricFields = this._descriptor.metrics - ? this._descriptor.metrics.map(metric => { - const esDocField = metric.field - ? new ESDocField({ fieldName: metric.field, source: this }) - : null; - return new ESAggMetricField({ - label: metric.label, - esDocField: esDocField, - aggType: metric.type, - source: this, - origin: this.getOriginForField(), - }); - }) - : []; + this._metricFields = []; + if (this._descriptor.metrics) { + this._descriptor.metrics.forEach(aggDescriptor => { + this._metricFields.push( + ...esAggFieldsFactory(aggDescriptor, this, this.getOriginForField()) + ); + }); + } } getFieldByName(name) { @@ -61,16 +55,9 @@ export class AbstractESAggSource extends AbstractESSource { getMetricFields() { const metrics = this._metricFields.filter(esAggField => esAggField.isValid()); - if (metrics.length === 0) { - metrics.push( - new ESAggMetricField({ - aggType: AGG_TYPE.COUNT, - source: this, - origin: this.getOriginForField(), - }) - ); - } - return metrics; + return metrics.length === 0 + ? esAggFieldsFactory({ type: AGG_TYPE.COUNT }, this, this.getOriginForField()) + : metrics; } getAggKey(aggType, fieldName) { @@ -93,13 +80,12 @@ export class AbstractESAggSource extends AbstractESSource { getValueAggsDsl(indexPattern) { const valueAggsDsl = {}; - this.getMetricFields() - .filter(esAggMetric => { - return esAggMetric.getAggType() !== AGG_TYPE.COUNT; - }) - .forEach(esAggMetric => { + this.getMetricFields().forEach(esAggMetric => { + const aggDsl = esAggMetric.getValueAggDsl(indexPattern); + if (aggDsl) { valueAggsDsl[esAggMetric.getName()] = esAggMetric.getValueAggDsl(indexPattern); - }); + } + }); return valueAggsDsl; } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.test.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.test.ts index a8223c36df349..e79d8e09fce9b 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.test.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.test.ts @@ -53,6 +53,7 @@ describe('convertCompositeRespToGeoJson', () => { avg_of_bytes: 5359.2307692307695, doc_count: 65, 'terms_of_machine.os.keyword': 'win xp', + 'terms_of_machine.os.keyword__percentage': 25, }, type: 'Feature', }); @@ -79,6 +80,7 @@ describe('convertCompositeRespToGeoJson', () => { avg_of_bytes: 5359.2307692307695, doc_count: 65, 'terms_of_machine.os.keyword': 'win xp', + 'terms_of_machine.os.keyword__percentage': 25, }, type: 'Feature', }); @@ -125,6 +127,7 @@ describe('convertRegularRespToGeoJson', () => { avg_of_bytes: 5359.2307692307695, doc_count: 65, 'terms_of_machine.os.keyword': 'win xp', + 'terms_of_machine.os.keyword__percentage': 25, }, type: 'Feature', }); @@ -151,6 +154,7 @@ describe('convertRegularRespToGeoJson', () => { avg_of_bytes: 5359.2307692307695, doc_count: 65, 'terms_of_machine.os.keyword': 'win xp', + 'terms_of_machine.os.keyword__percentage': 25, }, type: 'Feature', }); diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts new file mode 100644 index 0000000000000..652409b61fd72 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AbstractESAggSource } from '../es_agg_source'; +import { ESGeoGridSourceDescriptor } from '../../../../common/descriptor_types'; + +export class ESGeoGridSource extends AbstractESAggSource { + constructor(sourceDescriptor: ESGeoGridSourceDescriptor, inspectorAdapters: unknown); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js index b2463275dad0a..4987d052b8ab7 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js @@ -20,7 +20,6 @@ import { COLOR_GRADIENTS } from '../../styles/color_utils'; import { CreateSourceEditor } from './create_source_editor'; import { UpdateSourceEditor } from './update_source_editor'; import { - AGG_TYPE, DEFAULT_MAX_BUCKETS_LIMIT, SOURCE_DATA_ID_ORIGIN, ES_GEO_GRID, @@ -297,10 +296,7 @@ export class ESGeoGridSource extends AbstractESAggSource { let bucketsPerGrid = 1; this.getMetricFields().forEach(metricField => { - if (metricField.getAggType() === AGG_TYPE.TERMS) { - // each terms aggregation increases the overall number of buckets per grid - bucketsPerGrid++; - } + bucketsPerGrid += metricField.getBucketCount(); }); const features = diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.test.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.test.ts index 5fbd5a3ad20c0..14c62aa0207fe 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.test.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.test.ts @@ -62,6 +62,7 @@ it('Should convert elasticsearch aggregation response into feature collection of avg_of_FlightDelayMin: 3, doc_count: 1, terms_of_Carrier: 'ES-Air', + terms_of_Carrier__percentage: 100, }, type: 'Feature', }); diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts index 2aaaad15d6321..25c4fae89f024 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts @@ -5,5 +5,13 @@ */ import { AbstractVectorSource } from './vector_source'; +import { IVectorSource } from './vector_source'; +import { IndexPattern } from '../../../../../../../src/plugins/data/public'; -export class AbstractESSource extends AbstractVectorSource {} +export interface IESSource extends IVectorSource { + getIndexPattern(): Promise<IndexPattern>; +} + +export class AbstractESSource extends AbstractVectorSource implements IESSource { + getIndexPattern(): Promise<IndexPattern>; +} diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js index 30f60f543d38d..c12b4befc0684 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js @@ -105,7 +105,13 @@ export class ESTermSource extends AbstractESAggSource { requestName: `${this._descriptor.indexPatternTitle}.${this._termField.getName()}`, searchSource, registerCancelCallback, - requestDescription: this._getRequestDescription(leftSourceName, leftFieldName), + requestDescription: i18n.translate('xpack.maps.source.esJoin.joinDescription', { + defaultMessage: `Elasticsearch terms aggregation request, left source: {leftSource}, right source: {rightSource}`, + values: { + leftSource: `${leftSourceName}:${leftFieldName}`, + rightSource: `${this._descriptor.indexPatternTitle}:${this._termField.getName()}`, + }, + }), }); const countPropertyName = this.getAggKey(AGG_TYPE.COUNT); @@ -118,30 +124,6 @@ export class ESTermSource extends AbstractESAggSource { return false; } - _getRequestDescription(leftSourceName, leftFieldName) { - const metrics = this.getMetricFields().map(esAggMetric => esAggMetric.getRequestDescription()); - const joinStatement = []; - joinStatement.push( - i18n.translate('xpack.maps.source.esJoin.joinLeftDescription', { - defaultMessage: `Join {leftSourceName}:{leftFieldName} with`, - values: { leftSourceName, leftFieldName }, - }) - ); - joinStatement.push(`${this._descriptor.indexPatternTitle}:${this._termField.getName()}`); - joinStatement.push( - i18n.translate('xpack.maps.source.esJoin.joinMetricsDescription', { - defaultMessage: `for metrics {metrics}`, - values: { metrics: metrics.join(',') }, - }) - ); - return i18n.translate('xpack.maps.source.esJoin.joinDescription', { - defaultMessage: `Elasticsearch terms aggregation request for {description}`, - values: { - description: joinStatement.join(' '), - }, - }); - } - async getDisplayName() { //no need to localize. this is never rendered. return `es_table ${this._descriptor.indexPatternId}`; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js index 3952aacf03b33..8369ca562e14b 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js @@ -54,7 +54,7 @@ export class AbstractVectorSource extends AbstractSource { * factory function creating a new field-instance * @param fieldName * @param label - * @returns {ESAggMetricField} + * @returns {IField} */ createField() { throw new Error(`Should implemement ${this.constructor.type} ${this}`); @@ -64,7 +64,7 @@ export class AbstractVectorSource extends AbstractSource { * Retrieves a field. This may be an existing instance. * @param fieldName * @param label - * @returns {ESAggMetricField} + * @returns {IField} */ getFieldByName(name) { return this.createField({ fieldName: name }); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/components/ordinal_legend.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/components/ordinal_legend.js index 564bae3ef3f72..1ebd042118480 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/components/ordinal_legend.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/components/ordinal_legend.js @@ -46,7 +46,7 @@ export class OrdinalLegend extends React.Component { this._loadParams(); } render() { - const fieldMeta = this.props.style.getFieldMeta(); + const fieldMeta = this.props.style.getRangeFieldMeta(); let minLabel = EMPTY_VALUE; let maxLabel = EMPTY_VALUE; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js index 70e905907bc79..9404c2da3d274 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js @@ -169,7 +169,7 @@ export class DynamicColorProperty extends DynamicStyleProperty { }; } - const fieldMeta = this.getFieldMeta(); + const fieldMeta = this.getCategoryFieldMeta(); if (!fieldMeta || !fieldMeta.categories) { return EMPTY_STOPS; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js index 8648b073a7b79..c2f7a1313d02a 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js @@ -16,7 +16,8 @@ import { shallow } from 'enzyme'; import { VECTOR_STYLES } from '../vector_style_defaults'; import { DynamicColorProperty } from './dynamic_color_property'; -import { COLOR_MAP_TYPE } from '../../../../../common/constants'; +import { StyleMeta } from '../style_meta'; +import { COLOR_MAP_TYPE, FIELD_ORIGIN } from '../../../../../common/constants'; const mockField = { async getLabel() { @@ -28,35 +29,59 @@ const mockField = { getRootName() { return 'foobar'; }, + getOrigin() { + return FIELD_ORIGIN.SOURCE; + }, supportsFieldMeta() { return true; }, }; -const getOrdinalFieldMeta = () => { - return { min: 0, max: 100 }; -}; - -const getCategoricalFieldMeta = () => { - return { - categories: [ - { - key: 'US', - count: 10, +class MockStyle { + getStyleMeta() { + return new StyleMeta({ + geometryTypes: { + isPointsOnly: false, + isLinesOnly: false, + isPolygonsOnly: false, }, - { - key: 'CN', - count: 8, + fieldMeta: { + foobar: { + range: { min: 0, max: 100 }, + categories: { + categories: [ + { + key: 'US', + count: 10, + }, + { + key: 'CN', + count: 8, + }, + ], + }, + }, }, - ], - }; -}; -const makeProperty = (options, getFieldMeta) => { + }); + } +} + +class MockLayer { + getStyle() { + return new MockStyle(); + } + + findDataRequestById() { + return null; + } +} + +const makeProperty = options => { return new DynamicColorProperty( options, VECTOR_STYLES.LINE_COLOR, mockField, - getFieldMeta, + new MockLayer(), () => { return x => x + '_format'; } @@ -69,13 +94,10 @@ const defaultLegendParams = { }; test('Should render ordinal legend', async () => { - const colorStyle = makeProperty( - { - color: 'Blues', - type: undefined, - }, - getOrdinalFieldMeta - ); + const colorStyle = makeProperty({ + color: 'Blues', + type: undefined, + }); const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams); @@ -85,23 +107,20 @@ test('Should render ordinal legend', async () => { }); test('Should render ordinal legend with breaks', async () => { - const colorStyle = makeProperty( - { - type: COLOR_MAP_TYPE.ORDINAL, - useCustomColorRamp: true, - customColorRamp: [ - { - stop: 0, - color: '#FF0000', - }, - { - stop: 10, - color: '#00FF00', - }, - ], - }, - getOrdinalFieldMeta - ); + const colorStyle = makeProperty({ + type: COLOR_MAP_TYPE.ORDINAL, + useCustomColorRamp: true, + customColorRamp: [ + { + stop: 0, + color: '#FF0000', + }, + { + stop: 10, + color: '#00FF00', + }, + ], + }); const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams); @@ -116,14 +135,11 @@ test('Should render ordinal legend with breaks', async () => { }); test('Should render categorical legend with breaks from default', async () => { - const colorStyle = makeProperty( - { - type: COLOR_MAP_TYPE.CATEGORICAL, - useCustomColorPalette: false, - colorCategory: 'palette_0', - }, - getCategoricalFieldMeta - ); + const colorStyle = makeProperty({ + type: COLOR_MAP_TYPE.CATEGORICAL, + useCustomColorPalette: false, + colorCategory: 'palette_0', + }); const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams); @@ -138,27 +154,24 @@ test('Should render categorical legend with breaks from default', async () => { }); test('Should render categorical legend with breaks from custom', async () => { - const colorStyle = makeProperty( - { - type: COLOR_MAP_TYPE.CATEGORICAL, - useCustomColorPalette: true, - customColorPalette: [ - { - stop: null, //should include the default stop - color: '#FFFF00', - }, - { - stop: 'US_STOP', - color: '#FF0000', - }, - { - stop: 'CN_STOP', - color: '#00FF00', - }, - ], - }, - getCategoricalFieldMeta - ); + const colorStyle = makeProperty({ + type: COLOR_MAP_TYPE.CATEGORICAL, + useCustomColorPalette: true, + customColorPalette: [ + { + stop: null, //should include the default stop + color: '#FFFF00', + }, + { + stop: 'US_STOP', + color: '#FF0000', + }, + { + stop: 'CN_STOP', + color: '#00FF00', + }, + ], + }); const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams); @@ -182,11 +195,10 @@ test('Should pluck the categorical style-meta', async () => { const colorStyle = makeProperty({ type: COLOR_MAP_TYPE.CATEGORICAL, colorCategory: 'palette_0', - getCategoricalFieldMeta, }); const features = makeFeatures(['CN', 'CN', 'US', 'CN', 'US', 'IN']); - const meta = colorStyle.pluckStyleMetaFromFeatures(features); + const meta = colorStyle.pluckCategoricalStyleMetaFromFeatures(features); expect(meta).toEqual({ categories: [ @@ -201,10 +213,9 @@ test('Should pluck the categorical style-meta from fieldmeta', async () => { const colorStyle = makeProperty({ type: COLOR_MAP_TYPE.CATEGORICAL, colorCategory: 'palette_0', - getCategoricalFieldMeta, }); - const meta = colorStyle.pluckStyleMetaFromFieldMetaData({ + const meta = colorStyle.pluckCategoricalStyleMetaFromFieldMetaData({ foobar: { buckets: [ { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js index c0e56f962db74..c492efbdf4ba3 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js @@ -62,7 +62,7 @@ export class DynamicIconProperty extends DynamicStyleProperty { } return assignCategoriesToPalette({ - categories: _.get(this.getFieldMeta(), 'categories', []), + categories: _.get(this.getCategoryFieldMeta(), 'categories', []), paletteValues: getIconPalette(this._options.iconPaletteId), }); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js index dfc5c530cc90f..77f2d09982291 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js @@ -43,8 +43,8 @@ function getSymbolSizeIcons() { } export class DynamicSizeProperty extends DynamicStyleProperty { - constructor(options, styleName, field, getFieldMeta, getFieldFormatter, isSymbolizedAsIcon) { - super(options, styleName, field, getFieldMeta, getFieldFormatter); + constructor(options, styleName, field, vectorLayer, getFieldFormatter, isSymbolizedAsIcon) { + super(options, styleName, field, vectorLayer, getFieldFormatter); this._isSymbolizedAsIcon = isSymbolizedAsIcon; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js index e40c82e6276c7..7b94e58f0e7d4 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js @@ -7,7 +7,12 @@ import _ from 'lodash'; import { AbstractStyleProperty } from './style_property'; import { DEFAULT_SIGMA } from '../vector_style_defaults'; -import { COLOR_PALETTE_MAX_SIZE, STYLE_TYPE } from '../../../../../common/constants'; +import { + COLOR_PALETTE_MAX_SIZE, + STYLE_TYPE, + SOURCE_META_ID_ORIGIN, + FIELD_ORIGIN, +} from '../../../../../common/constants'; import { scaleValue, getComputedFieldName } from '../style_util'; import React from 'react'; import { OrdinalLegend } from './components/ordinal_legend'; @@ -17,10 +22,10 @@ import { OrdinalFieldMetaOptionsPopover } from '../components/ordinal_field_meta export class DynamicStyleProperty extends AbstractStyleProperty { static type = STYLE_TYPE.DYNAMIC; - constructor(options, styleName, field, getFieldMeta, getFieldFormatter) { + constructor(options, styleName, field, vectorLayer, getFieldFormatter) { super(options, styleName); this._field = field; - this._getFieldMeta = getFieldMeta; + this._layer = vectorLayer; this._getFieldFormatter = getFieldFormatter; } @@ -30,8 +35,57 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return fieldSource && field ? fieldSource.getValueSuggestions(field, query) : []; }; - getFieldMeta() { - return this._getFieldMeta && this._field ? this._getFieldMeta(this._field.getName()) : null; + _getStyleMetaDataRequestId(fieldName) { + if (this.getFieldOrigin() === FIELD_ORIGIN.SOURCE) { + return SOURCE_META_ID_ORIGIN; + } + + const join = this._layer.getValidJoins().find(join => { + return join.getRightJoinSource().hasMatchingMetricField(fieldName); + }); + return join ? join.getSourceMetaDataRequestId() : null; + } + + getRangeFieldMeta() { + const style = this._layer.getStyle(); + const styleMeta = style.getStyleMeta(); + const fieldName = this.getFieldName(); + const rangeFieldMetaFromLocalFeatures = styleMeta.getRangeFieldMetaDescriptor(fieldName); + + const dataRequestId = this._getStyleMetaDataRequestId(fieldName); + if (!dataRequestId) { + return rangeFieldMetaFromLocalFeatures; + } + + const styleMetaDataRequest = this._layer.findDataRequestById(dataRequestId); + if (!styleMetaDataRequest || !styleMetaDataRequest.hasData()) { + return rangeFieldMetaFromLocalFeatures; + } + + const data = styleMetaDataRequest.getData(); + const rangeFieldMeta = this.pluckOrdinalStyleMetaFromFieldMetaData(data); + return rangeFieldMeta ? rangeFieldMeta : rangeFieldMetaFromLocalFeatures; + } + + getCategoryFieldMeta() { + const style = this._layer.getStyle(); + const styleMeta = style.getStyleMeta(); + const fieldName = this.getFieldName(); + const rangeFieldMetaFromLocalFeatures = styleMeta.getCategoryFieldMetaDescriptor(fieldName); + + const dataRequestId = this._getStyleMetaDataRequestId(fieldName); + if (!dataRequestId) { + return rangeFieldMetaFromLocalFeatures; + } + + const styleMetaDataRequest = this._layer.findDataRequestById(dataRequestId); + if (!styleMetaDataRequest || !styleMetaDataRequest.hasData()) { + return rangeFieldMetaFromLocalFeatures; + } + + const data = styleMetaDataRequest.getData(); + const rangeFieldMeta = this.pluckCategoricalStyleMetaFromFieldMetaData(data); + return rangeFieldMeta ? rangeFieldMeta : rangeFieldMetaFromLocalFeatures; } getField() { @@ -98,10 +152,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { async getFieldMetaRequest() { if (this.isOrdinal()) { - const fieldMetaOptions = this.getFieldMetaOptions(); - return this._field.getOrdinalFieldMetaRequest({ - sigma: _.get(fieldMetaOptions, 'sigma', DEFAULT_SIGMA), - }); + return this._field.getOrdinalFieldMetaRequest(); } else if (this.isCategorical()) { return this._field.getCategoricalFieldMetaRequest(); } else { @@ -121,7 +172,11 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return _.get(this.getOptions(), 'fieldMetaOptions', {}); } - _pluckOrdinalStyleMetaFromFeatures(features) { + pluckOrdinalStyleMetaFromFeatures(features) { + if (!this.isOrdinal()) { + return null; + } + const name = this.getField().getName(); let min = Infinity; let max = -Infinity; @@ -143,7 +198,11 @@ export class DynamicStyleProperty extends AbstractStyleProperty { }; } - _pluckCategoricalStyleMetaFromFeatures(features) { + pluckCategoricalStyleMetaFromFeatures(features) { + if (!this.isCategorical()) { + return null; + } + const fieldName = this.getField().getName(); const counts = new Map(); for (let i = 0; i < features.length; i++) { @@ -173,17 +232,11 @@ export class DynamicStyleProperty extends AbstractStyleProperty { }; } - pluckStyleMetaFromFeatures(features) { - if (this.isOrdinal()) { - return this._pluckOrdinalStyleMetaFromFeatures(features); - } else if (this.isCategorical()) { - return this._pluckCategoricalStyleMetaFromFeatures(features); - } else { + pluckOrdinalStyleMetaFromFieldMetaData(fieldMetaData) { + if (!this.isOrdinal()) { return null; } - } - _pluckOrdinalStyleMetaFromFieldMetaData(fieldMetaData) { const stats = fieldMetaData[this._field.getRootName()]; if (!stats) { return null; @@ -203,7 +256,11 @@ export class DynamicStyleProperty extends AbstractStyleProperty { }; } - _pluckCategoricalStyleMetaFromFieldMetaData(fieldMetaData) { + pluckCategoricalStyleMetaFromFieldMetaData(fieldMetaData) { + if (!this.isCategorical()) { + return null; + } + const rootFieldName = this._field.getRootName(); if (!fieldMetaData[rootFieldName] || !fieldMetaData[rootFieldName].buckets) { return null; @@ -220,16 +277,6 @@ export class DynamicStyleProperty extends AbstractStyleProperty { }; } - pluckStyleMetaFromFieldMetaData(fieldMetaData) { - if (this.isOrdinal()) { - return this._pluckOrdinalStyleMetaFromFieldMetaData(fieldMetaData); - } else if (this.isCategorical()) { - return this._pluckCategoricalStyleMetaFromFieldMetaData(fieldMetaData); - } else { - return null; - } - } - formatField(value) { if (this.getField()) { const fieldName = this.getField().getName(); @@ -247,7 +294,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { const valueAsFloat = parseFloat(value); if (this.isOrdinalScaled()) { - return scaleValue(valueAsFloat, this.getFieldMeta()); + return scaleValue(valueAsFloat, this.getRangeFieldMeta()); } if (isNaN(valueAsFloat)) { return 0; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_meta.ts b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_meta.ts new file mode 100644 index 0000000000000..646b88d005af7 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_meta.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + StyleMetaDescriptor, + RangeFieldMeta, + CategoryFieldMeta, +} from '../../../../common/descriptor_types'; + +export class StyleMeta { + private readonly _descriptor: StyleMetaDescriptor; + constructor(styleMetaDescriptor: StyleMetaDescriptor | null | undefined) { + this._descriptor = styleMetaDescriptor ? styleMetaDescriptor : { fieldMeta: {} }; + } + + getRangeFieldMetaDescriptor(fieldName: string): RangeFieldMeta | null { + return this._descriptor && this._descriptor.fieldMeta[fieldName] + ? this._descriptor.fieldMeta[fieldName].range + : null; + } + + getCategoryFieldMetaDescriptor(fieldName: string): CategoryFieldMeta | null { + return this._descriptor && this._descriptor.fieldMeta[fieldName] + ? this._descriptor.fieldMeta[fieldName].categories + : null; + } + + isPointsOnly(): boolean { + return this._descriptor.geometryTypes ? !!this._descriptor.geometryTypes.isPointsOnly : false; + } + + isLinesOnly(): boolean { + return this._descriptor.geometryTypes ? !!this._descriptor.geometryTypes.isLinesOnly : false; + } + + isPolygonsOnly(): boolean { + return this._descriptor.geometryTypes ? !!this._descriptor.geometryTypes.isPolygonsOnly : false; + } +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js index 053aa114d94ae..528c5a9bfdc85 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js @@ -18,11 +18,11 @@ import { GEO_JSON_TYPE, FIELD_ORIGIN, STYLE_TYPE, - SOURCE_META_ID_ORIGIN, SOURCE_FORMATTERS_ID_ORIGIN, LAYER_STYLE_TYPE, DEFAULT_ICON, } from '../../../../common/constants'; +import { StyleMeta } from './style_meta'; import { VectorIcon } from './components/legend/vector_icon'; import { VectorStyleLegend } from './components/legend/vector_style_legend'; import { VECTOR_SHAPE_TYPES } from '../../sources/vector_feature_types'; @@ -71,6 +71,8 @@ export class VectorStyle extends AbstractStyle { ...VectorStyle.createDescriptor(descriptor.properties, descriptor.isTimeAware), }; + this._styleMeta = new StyleMeta(this._descriptor.__styleMeta); + this._symbolizeAsStyleProperty = new SymbolizeAsProperty( this._descriptor.properties[VECTOR_STYLES.SYMBOLIZE_AS].options, VECTOR_STYLES.SYMBOLIZE_AS @@ -272,7 +274,7 @@ export class VectorStyle extends AbstractStyle { } } - const featuresMeta = { + const styleMeta = { geometryTypes: { isPointsOnly: isOnlySingleFeatureType( VECTOR_SHAPE_TYPES.POINT, @@ -290,23 +292,32 @@ export class VectorStyle extends AbstractStyle { hasFeatureType ), }, + fieldMeta: {}, }; const dynamicProperties = this.getDynamicPropertiesArray(); if (dynamicProperties.length === 0 || features.length === 0) { // no additional meta data to pull from source data request. - return featuresMeta; + return styleMeta; } dynamicProperties.forEach(dynamicProperty => { - const styleMeta = dynamicProperty.pluckStyleMetaFromFeatures(features); - if (styleMeta) { - const name = dynamicProperty.getField().getName(); - featuresMeta[name] = styleMeta; + const categoricalStyleMeta = dynamicProperty.pluckCategoricalStyleMetaFromFeatures(features); + const ordinalStyleMeta = dynamicProperty.pluckOrdinalStyleMetaFromFeatures(features); + const name = dynamicProperty.getField().getName(); + if (!styleMeta.fieldMeta[name]) { + styleMeta.fieldMeta[name] = {}; + } + if (categoricalStyleMeta) { + styleMeta.fieldMeta[name].categories = categoricalStyleMeta; + } + + if (ordinalStyleMeta) { + styleMeta.fieldMeta[name].range = ordinalStyleMeta; } }); - return featuresMeta; + return styleMeta; } getSourceFieldNames() { @@ -335,15 +346,15 @@ export class VectorStyle extends AbstractStyle { } _getIsPointsOnly = () => { - return _.get(this._getStyleMeta(), 'geometryTypes.isPointsOnly', false); + return this._styleMeta.isPointsOnly(); }; _getIsLinesOnly = () => { - return _.get(this._getStyleMeta(), 'geometryTypes.isLinesOnly', false); + return this._styleMeta.isLinesOnly(); }; _getIsPolygonsOnly = () => { - return _.get(this._getStyleMeta(), 'geometryTypes.isPolygonsOnly', false); + return this._styleMeta.isPolygonsOnly(); }; _getDynamicPropertyByFieldName(fieldName) { @@ -353,39 +364,9 @@ export class VectorStyle extends AbstractStyle { }); } - _getFieldMeta = fieldName => { - const fieldMetaFromLocalFeatures = _.get(this._descriptor, ['__styleMeta', fieldName]); - - const dynamicProp = this._getDynamicPropertyByFieldName(fieldName); - if (!dynamicProp || !dynamicProp.isFieldMetaEnabled()) { - return fieldMetaFromLocalFeatures; - } - - let dataRequestId; - if (dynamicProp.getFieldOrigin() === FIELD_ORIGIN.SOURCE) { - dataRequestId = SOURCE_META_ID_ORIGIN; - } else { - const join = this._layer.getValidJoins().find(join => { - return join.getRightJoinSource().hasMatchingMetricField(fieldName); - }); - if (join) { - dataRequestId = join.getSourceMetaDataRequestId(); - } - } - - if (!dataRequestId) { - return fieldMetaFromLocalFeatures; - } - - const styleMetaDataRequest = this._layer._findDataRequestById(dataRequestId); - if (!styleMetaDataRequest || !styleMetaDataRequest.hasData()) { - return fieldMetaFromLocalFeatures; - } - - const data = styleMetaDataRequest.getData(); - const fieldMeta = dynamicProp.pluckStyleMetaFromFieldMetaData(data); - return fieldMeta ? fieldMeta : fieldMetaFromLocalFeatures; - }; + getStyleMeta() { + return this._styleMeta; + } _getFieldFormatter = fieldName => { const dynamicProp = this._getDynamicPropertyByFieldName(fieldName); @@ -409,7 +390,7 @@ export class VectorStyle extends AbstractStyle { return null; } - const formattersDataRequest = this._layer._findDataRequestById(dataRequestId); + const formattersDataRequest = this._layer.findDataRequestById(dataRequestId); if (!formattersDataRequest || !formattersDataRequest.hasData()) { return null; } @@ -418,10 +399,6 @@ export class VectorStyle extends AbstractStyle { return formatters[fieldName]; }; - _getStyleMeta = () => { - return _.get(this._descriptor, '__styleMeta', {}); - }; - _getSymbolId() { return this.arePointsSymbolizedAsCircles() ? undefined @@ -623,7 +600,7 @@ export class VectorStyle extends AbstractStyle { descriptor.options, styleName, field, - this._getFieldMeta, + this._layer, this._getFieldFormatter, isSymbolizedAsIcon ); @@ -643,7 +620,7 @@ export class VectorStyle extends AbstractStyle { descriptor.options, styleName, field, - this._getFieldMeta, + this._layer, this._getFieldFormatter ); } else { @@ -658,7 +635,13 @@ export class VectorStyle extends AbstractStyle { return new StaticOrientationProperty(descriptor.options, styleName); } else if (descriptor.type === DynamicStyleProperty.type) { const field = this._makeField(descriptor.options.field); - return new DynamicOrientationProperty(descriptor.options, styleName, field); + return new DynamicOrientationProperty( + descriptor.options, + styleName, + field, + this._layer, + this._getFieldFormatter + ); } else { throw new Error(`${descriptor} not implemented`); } @@ -675,7 +658,7 @@ export class VectorStyle extends AbstractStyle { descriptor.options, VECTOR_STYLES.LABEL_TEXT, field, - this._getFieldMeta, + this._layer, this._getFieldFormatter ); } else { @@ -694,7 +677,7 @@ export class VectorStyle extends AbstractStyle { descriptor.options, VECTOR_STYLES.ICON, field, - this._getFieldMeta, + this._layer, this._getFieldFormatter ); } else { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js index cc52d44aed8d3..66b7ae5e02c5f 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js @@ -279,8 +279,8 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { new MockSource() ); - const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); - expect(featuresMeta.myDynamicField).toEqual({ + const styleMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); + expect(styleMeta.fieldMeta.myDynamicField.range).toEqual({ delta: 9, max: 10, min: 1, diff --git a/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.test.ts b/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.test.ts index 201d6907981a2..445a7621194b7 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.test.ts +++ b/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.test.ts @@ -19,19 +19,34 @@ describe('extractPropertiesFromBucket', () => { }); }); - test('Should extract bucket aggregation values', () => { + test('Should extract top bucket aggregation value and percentage', () => { const properties = extractPropertiesFromBucket({ + doc_count: 3, 'terms_of_machine.os.keyword': { buckets: [ { key: 'win xp', - doc_count: 16, + doc_count: 1, }, ], }, }); expect(properties).toEqual({ + doc_count: 3, 'terms_of_machine.os.keyword': 'win xp', + 'terms_of_machine.os.keyword__percentage': 33, + }); + }); + + test('Should handle empty top bucket aggregation', () => { + const properties = extractPropertiesFromBucket({ + doc_count: 3, + 'terms_of_machine.os.keyword': { + buckets: [], + }, + }); + expect(properties).toEqual({ + doc_count: 3, }); }); }); diff --git a/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.ts b/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.ts index 7af176acfaf46..9d4f24f80d6cd 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.ts +++ b/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.ts @@ -6,6 +6,7 @@ import { i18n } from '@kbn/i18n'; import _ from 'lodash'; import { IndexPattern, IFieldType } from '../../../../../../../src/plugins/data/public'; +import { TOP_TERM_PERCENTAGE_SUFFIX } from '../../../common/constants'; export function getField(indexPattern: IndexPattern, fieldName: string) { const field = indexPattern.fields.getByName(fieldName); @@ -42,7 +43,19 @@ export function extractPropertiesFromBucket(bucket: any, ignoreKeys: string[] = if (_.has(bucket[key], 'value')) { properties[key] = bucket[key].value; } else if (_.has(bucket[key], 'buckets')) { + if (bucket[key].buckets.length === 0) { + // No top term + continue; + } + properties[key] = _.get(bucket[key], 'buckets[0].key'); + const topBucketCount = bucket[key].buckets[0].doc_count; + const totalCount = bucket.doc_count; + if (totalCount && topBucketCount) { + properties[`${key}${TOP_TERM_PERCENTAGE_SUFFIX}`] = Math.round( + (topBucketCount / totalCount) * 100 + ); + } } else { properties[key] = bucket[key]; } diff --git a/x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.js b/x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.ts similarity index 85% rename from x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.js rename to x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.ts index 69ccb8890d10c..37916e53d6c45 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.ts @@ -6,6 +6,6 @@ import { AGG_TYPE } from '../../../common/constants'; -export function isMetricCountable(aggType) { +export function isMetricCountable(aggType: AGG_TYPE): boolean { return [AGG_TYPE.COUNT, AGG_TYPE.SUM, AGG_TYPE.UNIQUE_COUNT].includes(aggType); } diff --git a/x-pack/legacy/plugins/maps/public/layers/vector_layer.js b/x-pack/legacy/plugins/maps/public/layers/vector_layer.js index b03dfc38f3841..32fdbcf965414 100644 --- a/x-pack/legacy/plugins/maps/public/layers/vector_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/vector_layer.js @@ -61,6 +61,10 @@ export class VectorLayer extends AbstractLayer { this._style = new VectorStyle(this._descriptor.style, this._source, this); } + getStyle() { + return this._style; + } + destroy() { if (this._source) { this._source.destroy(); @@ -227,7 +231,7 @@ export class VectorLayer extends AbstractLayer { return indexPatternIds; } - _findDataRequestById(sourceDataId) { + findDataRequestById(sourceDataId) { return this._dataRequests.find(dataRequest => dataRequest.getDataId() === sourceDataId); } @@ -248,7 +252,7 @@ export class VectorLayer extends AbstractLayer { sourceQuery: joinSource.getWhereQuery(), applyGlobalQuery: joinSource.getApplyGlobalQuery(), }; - const prevDataRequest = this._findDataRequestById(sourceDataId); + const prevDataRequest = this.findDataRequestById(sourceDataId); const canSkipFetch = await canSkipSourceUpdate({ source: joinSource, @@ -471,7 +475,7 @@ export class VectorLayer extends AbstractLayer { isTimeAware: this._style.isTimeAware() && (await source.isTimeAware()), timeFilters: dataFilters.timeFilters, }; - const prevDataRequest = this._findDataRequestById(dataRequestId); + const prevDataRequest = this.findDataRequestById(dataRequestId); const canSkipFetch = canSkipStyleMetaUpdate({ prevDataRequest, nextMeta }); if (canSkipFetch) { return; @@ -547,7 +551,7 @@ export class VectorLayer extends AbstractLayer { const nextMeta = { fieldNames: _.uniq(fieldNames).sort(), }; - const prevDataRequest = this._findDataRequestById(dataRequestId); + const prevDataRequest = this.findDataRequestById(dataRequestId); const canSkipUpdate = canSkipFormattersUpdate({ prevDataRequest, nextMeta }); if (canSkipUpdate) { return; diff --git a/x-pack/legacy/plugins/ml/public/application/app.tsx b/x-pack/legacy/plugins/ml/public/application/app.tsx index 4c956bfabecc9..18545f31f03c7 100644 --- a/x-pack/legacy/plugins/ml/public/application/app.tsx +++ b/x-pack/legacy/plugins/ml/public/application/app.tsx @@ -25,9 +25,6 @@ export interface MlDependencies extends AppMountParameters { data: DataPublicPluginStart; security: SecurityPluginSetup; licensing: LicensingPluginSetup; - __LEGACY: { - XSRF: string; - }; } interface AppProps { @@ -49,7 +46,6 @@ const App: FC<AppProps> = ({ coreStart, deps }) => { recentlyAccessed: coreStart.chrome!.recentlyAccessed, basePath: coreStart.http.basePath, savedObjectsClient: coreStart.savedObjects.client, - XSRF: deps.__LEGACY.XSRF, application: coreStart.application, http: coreStart.http, security: deps.security, diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx index 70722d9cb953a..c744c357c9550 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx @@ -8,7 +8,7 @@ import React, { Fragment, FC, useEffect, useMemo } from 'react'; import { EuiComboBox, - EuiComboBoxOptionProps, + EuiComboBoxOptionOption, EuiForm, EuiFieldText, EuiFormRow, @@ -118,7 +118,7 @@ export const CreateAnalyticsForm: FC<CreateAnalyticsFormProps> = ({ actions, sta } }; - const onCreateOption = (searchValue: string, flattenedOptions: EuiComboBoxOptionProps[]) => { + const onCreateOption = (searchValue: string, flattenedOptions: EuiComboBoxOptionOption[]) => { const normalizedSearchValue = searchValue.trim().toLowerCase(); if (!normalizedSearchValue) { @@ -132,7 +132,7 @@ export const CreateAnalyticsForm: FC<CreateAnalyticsFormProps> = ({ actions, sta // Create the option if it doesn't exist. if ( !flattenedOptions.some( - (option: EuiComboBoxOptionProps) => + (option: EuiComboBoxOptionOption) => option.label.trim().toLowerCase() === normalizedSearchValue ) ) { @@ -164,7 +164,7 @@ export const CreateAnalyticsForm: FC<CreateAnalyticsFormProps> = ({ actions, sta // If sourceIndex has changed load analysis field options again if (previousSourceIndex !== sourceIndex || previousJobType !== jobType) { - const analyzedFieldsOptions: EuiComboBoxOptionProps[] = []; + const analyzedFieldsOptions: EuiComboBoxOptionOption[] = []; if (resp.field_selection) { resp.field_selection.forEach((selectedField: FieldSelectionItem) => { @@ -229,7 +229,7 @@ export const CreateAnalyticsForm: FC<CreateAnalyticsFormProps> = ({ actions, sta // Get fields and filter for supported types for job type const { fields } = newJobCapsService; - const depVarOptions: EuiComboBoxOptionProps[] = []; + const depVarOptions: EuiComboBoxOptionOption[] = []; fields.forEach((field: Field) => { if (shouldAddAsDepVarOption(field, jobType)) { @@ -276,7 +276,7 @@ export const CreateAnalyticsForm: FC<CreateAnalyticsFormProps> = ({ actions, sta return errors; }; - const onSourceIndexChange = (selectedOptions: EuiComboBoxOptionProps[]) => { + const onSourceIndexChange = (selectedOptions: EuiComboBoxOptionOption[]) => { setFormState({ excludes: [], excludesOptions: [], diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 1f23048e09d1f..170700d35e651 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; import { DeepPartial } from '../../../../../../../common/types/common'; import { checkPermission } from '../../../../../privilege/check_privilege'; import { mlNodesAvailable } from '../../../../../ml_nodes_check/check_ml_nodes'; @@ -46,7 +46,7 @@ export interface State { createIndexPattern: boolean; dependentVariable: DependentVariable; dependentVariableFetchFail: boolean; - dependentVariableOptions: EuiComboBoxOptionProps[] | []; + dependentVariableOptions: EuiComboBoxOptionOption[]; description: string; destinationIndex: EsIndexName; destinationIndexNameExists: boolean; @@ -54,7 +54,7 @@ export interface State { destinationIndexNameValid: boolean; destinationIndexPatternTitleExists: boolean; excludes: string[]; - excludesOptions: EuiComboBoxOptionProps[]; + excludesOptions: EuiComboBoxOptionOption[]; fieldOptionsFetchFail: boolean; jobId: DataFrameAnalyticsId; jobIdExists: boolean; diff --git a/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/__snapshots__/overrides.test.js.snap b/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/__snapshots__/overrides.test.js.snap index 997b437508c34..46428ff9c351a 100644 --- a/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/__snapshots__/overrides.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/__snapshots__/overrides.test.js.snap @@ -40,6 +40,7 @@ exports[`Overrides render overrides 1`] = ` labelType="label" > <EuiComboBox + async={false} compressed={false} fullWidth={false} isClearable={false} @@ -99,6 +100,7 @@ exports[`Overrides render overrides 1`] = ` labelType="label" > <EuiComboBox + async={false} compressed={false} fullWidth={false} isClearable={false} @@ -338,6 +340,7 @@ exports[`Overrides render overrides 1`] = ` labelType="label" > <EuiComboBox + async={false} compressed={false} fullWidth={false} isClearable={false} diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/components/custom_url_editor/__snapshots__/editor.test.tsx.snap b/x-pack/legacy/plugins/ml/public/application/jobs/components/custom_url_editor/__snapshots__/editor.test.tsx.snap index 9a4504e3a113d..1b27d09965c4e 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/components/custom_url_editor/__snapshots__/editor.test.tsx.snap +++ b/x-pack/legacy/plugins/ml/public/application/jobs/components/custom_url_editor/__snapshots__/editor.test.tsx.snap @@ -135,6 +135,7 @@ exports[`CustomUrlEditor renders the editor for a dashboard type URL with a labe labelType="label" > <EuiComboBox + async={false} compressed={false} fullWidth={false} isClearable={true} @@ -334,6 +335,7 @@ exports[`CustomUrlEditor renders the editor for a discover type URL with an enti labelType="label" > <EuiComboBox + async={false} compressed={false} fullWidth={false} isClearable={true} @@ -571,6 +573,7 @@ exports[`CustomUrlEditor renders the editor for a discover type URL with valid t labelType="label" > <EuiComboBox + async={false} compressed={false} fullWidth={false} isClearable={true} @@ -808,6 +811,7 @@ exports[`CustomUrlEditor renders the editor for a new dashboard type URL with no labelType="label" > <EuiComboBox + async={false} compressed={false} fullWidth={false} isClearable={true} diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/components/custom_url_editor/editor.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/components/custom_url_editor/editor.tsx index 98acb1cfa8045..1c996858b7c94 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/components/custom_url_editor/editor.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/components/custom_url_editor/editor.tsx @@ -8,6 +8,7 @@ import React, { ChangeEvent, FC } from 'react'; import { EuiComboBox, + EuiComboBoxOptionOption, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -61,11 +62,6 @@ interface CustomUrlEditorProps { queryEntityFieldNames: string[]; } -interface EuiComboBoxOption { - label?: string; - value?: string; -} - /* * React component for the form for editing a custom URL. */ @@ -117,7 +113,7 @@ export const CustomUrlEditor: FC<CustomUrlEditorProps> = ({ }); }; - const onQueryEntitiesChange = (selectedOptions: EuiComboBoxOption[]) => { + const onQueryEntitiesChange = (selectedOptions: EuiComboBoxOptionOption[]) => { const selectedFieldNames = selectedOptions.map(option => option.label); const kibanaSettings = customUrl.kibanaSettings; @@ -172,7 +168,7 @@ export const CustomUrlEditor: FC<CustomUrlEditorProps> = ({ }); const entityOptions = queryEntityFieldNames.map(fieldName => ({ label: fieldName })); - let selectedEntityOptions: EuiComboBoxOption[] = []; + let selectedEntityOptions: EuiComboBoxOptionOption[] = []; if (kibanaSettings !== undefined && kibanaSettings.queryFieldNames !== undefined) { const queryFieldNames: string[] = kibanaSettings.queryFieldNames; selectedEntityOptions = queryFieldNames.map(fieldName => ({ label: fieldName })); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js b/x-pack/legacy/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js index 89589c98b52c2..32b5634b143db 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js +++ b/x-pack/legacy/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js @@ -39,8 +39,7 @@ function randomNumber(min, max) { } function saveWatch(watchModel) { - const basePath = getBasePath(); - const path = basePath.prepend('/api/watcher'); + const path = '/api/watcher'; const url = `${path}/watch/${watchModel.id}`; return http({ @@ -188,8 +187,7 @@ class CreateWatchService { loadWatch(jobId) { const id = `ml-${jobId}`; - const basePath = getBasePath(); - const path = basePath.prepend('/api/watcher'); + const path = '/api/watcher'; const url = `${path}/watch/${id}`; return http({ url, diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/jobs_list/components/ml_job_editor/ml_job_editor.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/jobs_list/components/ml_job_editor/ml_job_editor.tsx index ff6706edb0179..0633c62f754e0 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/jobs_list/components/ml_job_editor/ml_job_editor.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/jobs_list/components/ml_job_editor/ml_job_editor.tsx @@ -6,7 +6,7 @@ import React, { FC } from 'react'; -import { EuiCodeEditor } from '@elastic/eui'; +import { EuiCodeEditor, EuiCodeEditorProps } from '@elastic/eui'; import { expandLiteralStrings } from '../../../../../../shared_imports'; import { xJsonMode } from '../../../../components/custom_hooks'; @@ -20,7 +20,7 @@ interface MlJobEditorProps { readOnly?: boolean; syntaxChecking?: boolean; theme?: string; - onChange?: Function; + onChange?: EuiCodeEditorProps['onChange']; } export const MLJobEditor: FC<MlJobEditorProps> = ({ value, diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/components/job_groups_input.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/components/job_groups_input.tsx index 7211c034617f1..131e313e7c9e5 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/components/job_groups_input.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/components/job_groups_input.tsx @@ -6,7 +6,7 @@ import React, { FC, memo } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { Validation } from '../job_validator'; import { tabColor } from '../../../../../../common/util/group_color_utils'; import { Description } from '../../pages/components/job_details_step/components/groups/description'; @@ -20,28 +20,28 @@ export interface JobGroupsInputProps { export const JobGroupsInput: FC<JobGroupsInputProps> = memo( ({ existingGroups, selectedGroups, onChange, validation }) => { - const options = existingGroups.map<EuiComboBoxOptionProps>(g => ({ + const options = existingGroups.map<EuiComboBoxOptionOption>(g => ({ label: g, color: tabColor(g), })); - const selectedOptions = selectedGroups.map<EuiComboBoxOptionProps>(g => ({ + const selectedOptions = selectedGroups.map<EuiComboBoxOptionOption>(g => ({ label: g, color: tabColor(g), })); - function onChangeCallback(optionsIn: EuiComboBoxOptionProps[]) { + function onChangeCallback(optionsIn: EuiComboBoxOptionOption[]) { onChange(optionsIn.map(g => g.label)); } - function onCreateGroup(input: string, flattenedOptions: EuiComboBoxOptionProps[]) { + function onCreateGroup(input: string, flattenedOptions: EuiComboBoxOptionOption[]) { const normalizedSearchValue = input.trim().toLowerCase(); if (!normalizedSearchValue) { return; } - const newGroup: EuiComboBoxOptionProps = { + const newGroup: EuiComboBoxOptionOption = { label: input, color: tabColor(input), }; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/time_field/time_field_select.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/time_field/time_field_select.tsx index 9af1226d1fe6c..869dc046648b3 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/time_field/time_field_select.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/time_field/time_field_select.tsx @@ -5,7 +5,7 @@ */ import React, { FC, useContext } from 'react'; -import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { JobCreatorContext } from '../../../job_creator_context'; import { Field } from '../../../../../../../../../common/types/fields'; @@ -19,14 +19,17 @@ interface Props { export const TimeFieldSelect: FC<Props> = ({ fields, changeHandler, selectedField }) => { const { jobCreator } = useContext(JobCreatorContext); - const options: EuiComboBoxOptionProps[] = createFieldOptions(fields, jobCreator.additionalFields); + const options: EuiComboBoxOptionOption[] = createFieldOptions( + fields, + jobCreator.additionalFields + ); - const selection: EuiComboBoxOptionProps[] = []; + const selection: EuiComboBoxOptionOption[] = []; if (selectedField !== null) { selection.push({ label: selectedField }); } - function onChange(selectedOptions: EuiComboBoxOptionProps[]) { + function onChange(selectedOptions: EuiComboBoxOptionOption[]) { const option = selectedOptions[0]; if (typeof option !== 'undefined') { changeHandler(option.label); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/components/calendars/calendars_selection.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/components/calendars/calendars_selection.tsx index 1e7327552623e..597fe42543301 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/components/calendars/calendars_selection.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/components/calendars/calendars_selection.tsx @@ -9,7 +9,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonIcon, EuiComboBox, - EuiComboBoxOptionProps, + EuiComboBoxOptionOption, EuiComboBoxProps, EuiFlexGroup, EuiFlexItem, @@ -28,10 +28,10 @@ import { GLOBAL_CALENDAR } from '../../../../../../../../../../../common/constan export const CalendarsSelection: FC = () => { const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); const [selectedCalendars, setSelectedCalendars] = useState<Calendar[]>(jobCreator.calendars); - const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionProps<Calendar>>>( + const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<Calendar>>>( [] ); - const [options, setOptions] = useState<Array<EuiComboBoxOptionProps<Calendar>>>([]); + const [options, setOptions] = useState<Array<EuiComboBoxOptionOption<Calendar>>>([]); const [isLoading, setIsLoading] = useState(false); async function loadCalendars() { diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/groups/groups_input.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/groups/groups_input.tsx index cf0be9d3c0c4e..841ccfdce0958 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/groups/groups_input.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/groups/groups_input.tsx @@ -5,7 +5,7 @@ */ import React, { FC, useState, useContext, useEffect } from 'react'; -import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { JobCreatorContext } from '../../../job_creator_context'; import { tabColor } from '../../../../../../../../../common/util/group_color_utils'; @@ -24,28 +24,28 @@ export const GroupsInput: FC = () => { jobCreatorUpdate(); }, [selectedGroups.join()]); - const options: EuiComboBoxOptionProps[] = existingJobsAndGroups.groupIds.map((g: string) => ({ + const options: EuiComboBoxOptionOption[] = existingJobsAndGroups.groupIds.map((g: string) => ({ label: g, color: tabColor(g), })); - const selectedOptions: EuiComboBoxOptionProps[] = selectedGroups.map((g: string) => ({ + const selectedOptions: EuiComboBoxOptionOption[] = selectedGroups.map((g: string) => ({ label: g, color: tabColor(g), })); - function onChange(optionsIn: EuiComboBoxOptionProps[]) { + function onChange(optionsIn: EuiComboBoxOptionOption[]) { setSelectedGroups(optionsIn.map(g => g.label)); } - function onCreateGroup(input: string, flattenedOptions: EuiComboBoxOptionProps[]) { + function onCreateGroup(input: string, flattenedOptions: EuiComboBoxOptionOption[]) { const normalizedSearchValue = input.trim().toLowerCase(); if (!normalizedSearchValue) { return; } - const newGroup: EuiComboBoxOptionProps = { + const newGroup: EuiComboBoxOptionOption = { label: input, color: tabColor(input), }; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx index 753cea7adcb35..9e784a20c4f5f 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx @@ -10,7 +10,7 @@ import { EuiFlexItem, EuiFlexGroup, EuiFlexGrid, - EuiComboBoxOptionProps, + EuiComboBoxOptionOption, EuiHorizontalRule, EuiTextArea, } from '@elastic/eui'; @@ -54,11 +54,11 @@ export interface ModalPayload { index?: number; } -const emptyOption: EuiComboBoxOptionProps = { +const emptyOption: EuiComboBoxOptionOption = { label: '', }; -const excludeFrequentOptions: EuiComboBoxOptionProps[] = [{ label: 'all' }, { label: 'none' }]; +const excludeFrequentOptions: EuiComboBoxOptionOption[] = [{ label: 'all' }, { label: 'none' }]; export const AdvancedDetectorModal: FC<Props> = ({ payload, @@ -90,7 +90,7 @@ export const AdvancedDetectorModal: FC<Props> = ({ const usingScriptFields = jobCreator.additionalFields.length > 0; // list of aggregation combobox options. - const aggOptions: EuiComboBoxOptionProps[] = aggs + const aggOptions: EuiComboBoxOptionOption[] = aggs .filter(agg => filterAggs(agg, usingScriptFields)) .map(createAggOption); @@ -101,19 +101,19 @@ export const AdvancedDetectorModal: FC<Props> = ({ fields ); - const allFieldOptions: EuiComboBoxOptionProps[] = [ + const allFieldOptions: EuiComboBoxOptionOption[] = [ ...createFieldOptions(fields, jobCreator.additionalFields), ].sort(comboBoxOptionsSort); - const splitFieldOptions: EuiComboBoxOptionProps[] = [ + const splitFieldOptions: EuiComboBoxOptionOption[] = [ ...allFieldOptions, ...createMlcategoryFieldOption(jobCreator.categorizationFieldName), ].sort(comboBoxOptionsSort); const eventRateField = fields.find(f => f.id === EVENT_RATE_FIELD_ID); - const onOptionChange = (func: (p: EuiComboBoxOptionProps) => any) => ( - selectedOptions: EuiComboBoxOptionProps[] + const onOptionChange = (func: (p: EuiComboBoxOptionOption) => any) => ( + selectedOptions: EuiComboBoxOptionOption[] ) => { func(selectedOptions[0] || emptyOption); }; @@ -312,7 +312,7 @@ export const AdvancedDetectorModal: FC<Props> = ({ ); }; -function createAggOption(agg: Aggregation | null): EuiComboBoxOptionProps { +function createAggOption(agg: Aggregation | null): EuiComboBoxOptionOption { if (agg === null) { return emptyOption; } @@ -328,7 +328,7 @@ function filterAggs(agg: Aggregation, usingScriptFields: boolean) { return agg.fields !== undefined && (usingScriptFields || agg.fields.length); } -function createFieldOption(field: Field | null): EuiComboBoxOptionProps { +function createFieldOption(field: Field | null): EuiComboBoxOptionOption { if (field === null) { return emptyOption; } @@ -337,7 +337,7 @@ function createFieldOption(field: Field | null): EuiComboBoxOptionProps { }; } -function createExcludeFrequentOption(excludeFrequent: string | null): EuiComboBoxOptionProps { +function createExcludeFrequentOption(excludeFrequent: string | null): EuiComboBoxOptionOption { if (excludeFrequent === null) { return emptyOption; } @@ -406,15 +406,15 @@ function createDefaultDescription(dtr: RichDetector) { // if the options list only contains one option and nothing has been selected, set // selectedOptions list to be an empty array function createSelectedOptions( - selectedOption: EuiComboBoxOptionProps, - options: EuiComboBoxOptionProps[] -): EuiComboBoxOptionProps[] { + selectedOption: EuiComboBoxOptionOption, + options: EuiComboBoxOptionOption[] +): EuiComboBoxOptionOption[] { return (options.length === 1 && options[0].label !== selectedOption.label) || selectedOption.label === '' ? [] : [selectedOption]; } -function comboBoxOptionsSort(a: EuiComboBoxOptionProps, b: EuiComboBoxOptionProps) { +function comboBoxOptionsSort(a: EuiComboBoxOptionOption, b: EuiComboBoxOptionOption) { return a.label.localeCompare(b.label); } diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/agg_select/agg_select.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/agg_select/agg_select.tsx index a2434f3c33559..e4eccb5f01423 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/agg_select/agg_select.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/agg_select/agg_select.tsx @@ -5,7 +5,7 @@ */ import React, { FC, useContext, useState, useEffect } from 'react'; -import { EuiComboBox, EuiComboBoxOptionProps, EuiFormRow } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; import { JobCreatorContext } from '../../../job_creator_context'; import { Field, Aggregation, AggFieldPair } from '../../../../../../../../../common/types/fields'; @@ -26,12 +26,12 @@ export interface DropDownOption { options: DropDownLabel[]; } -export type DropDownProps = DropDownLabel[] | EuiComboBoxOptionProps[]; +export type DropDownProps = DropDownLabel[] | EuiComboBoxOptionOption[]; interface Props { fields: Field[]; - changeHandler(d: EuiComboBoxOptionProps[]): void; - selectedOptions: EuiComboBoxOptionProps[]; + changeHandler(d: EuiComboBoxOptionOption[]): void; + selectedOptions: EuiComboBoxOptionOption[]; removeOptions: AggFieldPair[]; } @@ -42,7 +42,7 @@ export const AggSelect: FC<Props> = ({ fields, changeHandler, selectedOptions, r // so they can be removed from the dropdown list const removeLabels = removeOptions.map(createLabel); - const options: EuiComboBoxOptionProps[] = fields.map(f => { + const options: EuiComboBoxOptionOption[] = fields.map(f => { const aggOption: DropDownOption = { label: f.name, options: [] }; if (typeof f.aggs !== 'undefined') { aggOption.options = f.aggs diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field_select.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field_select.tsx index 6451c2785eae0..2f3e8d43bc169 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field_select.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field_select.tsx @@ -5,7 +5,7 @@ */ import React, { FC, useContext } from 'react'; -import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { JobCreatorContext } from '../../../job_creator_context'; import { Field } from '../../../../../../../../../common/types/fields'; @@ -19,16 +19,16 @@ interface Props { export const CategorizationFieldSelect: FC<Props> = ({ fields, changeHandler, selectedField }) => { const { jobCreator } = useContext(JobCreatorContext); - const options: EuiComboBoxOptionProps[] = [ + const options: EuiComboBoxOptionOption[] = [ ...createFieldOptions(fields, jobCreator.additionalFields), ]; - const selection: EuiComboBoxOptionProps[] = []; + const selection: EuiComboBoxOptionOption[] = []; if (selectedField !== null) { selection.push({ label: selectedField }); } - function onChange(selectedOptions: EuiComboBoxOptionProps[]) { + function onChange(selectedOptions: EuiComboBoxOptionOption[]) { const option = selectedOptions[0]; if (typeof option !== 'undefined') { changeHandler(option.label); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers_select.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers_select.tsx index d4ac470f4ea4f..25c924ee0b42f 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers_select.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers_select.tsx @@ -5,7 +5,7 @@ */ import React, { FC, useContext } from 'react'; -import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { JobCreatorContext } from '../../../job_creator_context'; import { Field } from '../../../../../../../../../common/types/fields'; @@ -22,14 +22,14 @@ interface Props { export const InfluencersSelect: FC<Props> = ({ fields, changeHandler, selectedInfluencers }) => { const { jobCreator } = useContext(JobCreatorContext); - const options: EuiComboBoxOptionProps[] = [ + const options: EuiComboBoxOptionOption[] = [ ...createFieldOptions(fields, jobCreator.additionalFields), ...createMlcategoryFieldOption(jobCreator.categorizationFieldName), ]; - const selection: EuiComboBoxOptionProps[] = selectedInfluencers.map(i => ({ label: i })); + const selection: EuiComboBoxOptionOption[] = selectedInfluencers.map(i => ({ label: i })); - function onChange(selectedOptions: EuiComboBoxOptionProps[]) { + function onChange(selectedOptions: EuiComboBoxOptionOption[]) { changeHandler(selectedOptions.map(o => o.label)); } diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/split_field_select.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/split_field_select.tsx index 378c088332ed4..816614fb2a772 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/split_field_select.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/split_field_select.tsx @@ -5,7 +5,7 @@ */ import React, { FC } from 'react'; -import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { Field, SplitField } from '../../../../../../../../../common/types/fields'; @@ -31,7 +31,7 @@ export const SplitFieldSelect: FC<Props> = ({ testSubject, placeholder, }) => { - const options: EuiComboBoxOptionProps[] = fields.map( + const options: EuiComboBoxOptionOption[] = fields.map( f => ({ label: f.name, @@ -39,12 +39,12 @@ export const SplitFieldSelect: FC<Props> = ({ } as DropDownLabel) ); - const selection: EuiComboBoxOptionProps[] = []; + const selection: EuiComboBoxOptionOption[] = []; if (selectedField !== null) { selection.push({ label: selectedField.name, field: selectedField } as DropDownLabel); } - function onChange(selectedOptions: EuiComboBoxOptionProps[]) { + function onChange(selectedOptions: EuiComboBoxOptionOption[]) { const option = selectedOptions[0] as DropDownLabel; if (typeof option !== 'undefined') { changeHandler(option.field); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx index 6fe3aaf0a8652..8136008dce11b 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx @@ -5,7 +5,7 @@ */ import React, { FC, useContext } from 'react'; -import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { JobCreatorContext } from '../../../job_creator_context'; import { Field } from '../../../../../../../../../common/types/fields'; @@ -22,17 +22,17 @@ interface Props { export const SummaryCountFieldSelect: FC<Props> = ({ fields, changeHandler, selectedField }) => { const { jobCreator } = useContext(JobCreatorContext); - const options: EuiComboBoxOptionProps[] = [ + const options: EuiComboBoxOptionOption[] = [ ...createFieldOptions(fields, jobCreator.additionalFields), ...createDocCountFieldOption(jobCreator.aggregationFields.length > 0), ]; - const selection: EuiComboBoxOptionProps[] = []; + const selection: EuiComboBoxOptionOption[] = []; if (selectedField !== null) { selection.push({ label: selectedField }); } - function onChange(selectedOptions: EuiComboBoxOptionProps[]) { + function onChange(selectedOptions: EuiComboBoxOptionOption[]) { const option = selectedOptions[0]; if (typeof option !== 'undefined') { changeHandler(option.label); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/datafeed_details/datafeed_details.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/datafeed_details/datafeed_details.tsx index d1900413d84c9..2a86501d9e07f 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/datafeed_details/datafeed_details.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/datafeed_details/datafeed_details.tsx @@ -28,7 +28,7 @@ export const DatafeedDetails: FC = () => { <Italic>{`${DEFAULT_QUERY_DELAY} (${defaultLabel})`}</Italic> ); const frequency = jobCreator.frequency || ( - <Italic>{`${defaultFrequency} (${defaultLabel})`}</Italic> + <Italic>{`${defaultFrequency}s (${defaultLabel})`}</Italic> ); const scrollSize = jobCreator.scrollSize !== null ? ( @@ -69,7 +69,7 @@ export const DatafeedDetails: FC = () => { const queryTitle = i18n.translate( 'xpack.ml.newJob.wizard.summaryStep.datafeedDetails.query.title', { - defaultMessage: 'Scroll size', + defaultMessage: 'Elasticsearch query', } ); diff --git a/x-pack/legacy/plugins/ml/public/application/management/index.ts b/x-pack/legacy/plugins/ml/public/application/management/index.ts index a6d1bbfcee9f6..99a2e8353a874 100644 --- a/x-pack/legacy/plugins/ml/public/application/management/index.ts +++ b/x-pack/legacy/plugins/ml/public/application/management/index.ts @@ -54,7 +54,6 @@ function initManagementSection() { setDependencyCache({ docLinks: legacyDocLinks as any, basePath: legacyBasePath as any, - XSRF: chrome.getXsrfToken(), }); management.register('ml', { diff --git a/x-pack/legacy/plugins/ml/public/application/services/http_service.ts b/x-pack/legacy/plugins/ml/public/application/services/http_service.ts index 73a30dbcd71b2..75db2470d77cc 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/http_service.ts +++ b/x-pack/legacy/plugins/ml/public/application/services/http_service.ts @@ -4,68 +4,66 @@ * you may not use this file except in compliance with the Elastic License. */ -// service for interacting with the server +import { Observable } from 'rxjs'; -import { fromFetch } from 'rxjs/fetch'; -import { from, Observable } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; - -import { getXSRF } from '../util/dependency_cache'; - -export interface HttpOptions { - url?: string; -} +import { getHttp } from '../util/dependency_cache'; function getResultHeaders(headers: HeadersInit): HeadersInit { return { - asSystemRequest: false, + asSystemRequest: true, 'Content-Type': 'application/json', - 'kbn-version': getXSRF(), ...headers, } as HeadersInit; } -export function http(options: any) { - return new Promise((resolve, reject) => { - if (options && options.url) { - let url = ''; - url = url + (options.url || ''); - const headers = getResultHeaders(options.headers ?? {}); - - const allHeaders = - options.headers === undefined ? headers : { ...options.headers, ...headers }; - const body = options.data === undefined ? null : JSON.stringify(options.data); - - const payload: RequestInit = { - method: options.method || 'GET', - headers: allHeaders, - credentials: 'same-origin', - }; - - if (body !== null) { - payload.body = body; - } +interface HttpOptions { + url: string; + method: string; + headers?: any; + data?: any; +} - fetch(url, payload) - .then(resp => { - resp - .json() - .then(resp.ok === true ? resolve : reject) - .catch(resp.ok === true ? resolve : reject); - }) - .catch(resp => { - reject(resp); - }); - } else { - reject(); +/** + * Function for making HTTP requests to Kibana's backend. + * Wrapper for Kibana's HttpHandler. + */ +export async function http(options: HttpOptions) { + if (!options?.url) { + throw new Error('URL is missing'); + } + + try { + let url = ''; + url = url + (options.url || ''); + const headers = getResultHeaders(options.headers ?? {}); + + const allHeaders = options.headers === undefined ? headers : { ...options.headers, ...headers }; + const body = options.data === undefined ? null : JSON.stringify(options.data); + + const payload: RequestInit = { + method: options.method || 'GET', + headers: allHeaders, + credentials: 'same-origin', + }; + + if (body !== null) { + payload.body = body; } - }); + + return await getHttp().fetch(url, payload); + } catch (e) { + throw new Error(e); + } } interface RequestOptions extends RequestInit { body: BodyInit | any; } +/** + * Function for making HTTP requests to Kibana's backend which returns an Observable + * with request cancellation support. + */ export function http$<T>(url: string, options: RequestOptions): Observable<T> { const requestInit: RequestInit = { ...options, @@ -75,13 +73,56 @@ export function http$<T>(url: string, options: RequestOptions): Observable<T> { headers: getResultHeaders(options.headers ?? {}), }; - return fromFetch(url, requestInit).pipe( - switchMap(response => { - if (response.ok) { - return from(response.json() as Promise<T>); + return fromHttpHandler<T>(url, requestInit); +} + +/** + * Creates an Observable from Kibana's HttpHandler. + */ +export function fromHttpHandler<T>(input: string, init?: RequestInit): Observable<T> { + return new Observable<T>(subscriber => { + const controller = new AbortController(); + const signal = controller.signal; + + let abortable = true; + let unsubscribed = false; + + if (init?.signal) { + if (init.signal.aborted) { + controller.abort(); } else { - throw new Error(String(response.status)); + init.signal.addEventListener('abort', () => { + if (!signal.aborted) { + controller.abort(); + } + }); } - }) - ); + } + + const perSubscriberInit: RequestInit = { + ...(init ? init : {}), + signal, + }; + + getHttp() + .fetch<T>(input, perSubscriberInit) + .then(response => { + abortable = false; + subscriber.next(response); + subscriber.complete(); + }) + .catch(err => { + abortable = false; + if (!unsubscribed) { + subscriber.error(err); + } + }); + + return () => { + unsubscribed = true; + if (abortable) { + controller.abort(); + } + }; + }); } diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js index 6fdc76d7244d3..688abd1383ecb 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js +++ b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js @@ -13,10 +13,9 @@ import { filters } from './filters'; import { results } from './results'; import { jobs } from './jobs'; import { fileDatavisualizer } from './datavisualizer'; -import { getBasePath } from '../../util/dependency_cache'; export function basePath() { - return getBasePath().prepend('/api/ml'); + return '/api/ml'; } export const ml = { @@ -452,7 +451,7 @@ export const ml = { }, getIndices() { - const tempBasePath = getBasePath().prepend('/api'); + const tempBasePath = '/api'; return http({ url: `${tempBasePath}/index_management/indices`, method: 'GET', diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx index 6727102f55a52..8911ed53e74d0 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { EuiComboBox, - EuiComboBoxOptionProps, + EuiComboBoxOptionOption, EuiFlexItem, EuiFormRow, EuiToolTip, @@ -29,13 +29,13 @@ interface EntityControlProps { isLoading: boolean; onSearchChange: (entity: Entity, queryTerm: string) => void; forceSelection: boolean; - options: EuiComboBoxOptionProps[]; + options: EuiComboBoxOptionOption[]; } interface EntityControlState { - selectedOptions: EuiComboBoxOptionProps[] | undefined; + selectedOptions: EuiComboBoxOptionOption[] | undefined; isLoading: boolean; - options: EuiComboBoxOptionProps[] | undefined; + options: EuiComboBoxOptionOption[] | undefined; } export class EntityControl extends Component<EntityControlProps, EntityControlState> { @@ -53,7 +53,7 @@ export class EntityControl extends Component<EntityControlProps, EntityControlSt const { fieldValue } = entity; - let selectedOptionsUpdate: EuiComboBoxOptionProps[] | undefined = selectedOptions; + let selectedOptionsUpdate: EuiComboBoxOptionOption[] | undefined = selectedOptions; if ( (selectedOptions === undefined && fieldValue.length > 0) || (Array.isArray(selectedOptions) && @@ -84,7 +84,7 @@ export class EntityControl extends Component<EntityControlProps, EntityControlSt } } - onChange = (selectedOptions: EuiComboBoxOptionProps[]) => { + onChange = (selectedOptions: EuiComboBoxOptionOption[]) => { const options = selectedOptions.length > 0 ? selectedOptions : undefined; this.setState({ selectedOptions: options, diff --git a/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts b/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts index c167d7e7c3d42..2a1ffe79d033c 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts @@ -35,7 +35,6 @@ export interface DependencyCache { autocomplete: DataPublicPluginStart['autocomplete'] | null; basePath: IBasePath | null; savedObjectsClient: SavedObjectsClientContract | null; - XSRF: string | null; application: ApplicationStart | null; http: HttpStart | null; security: SecurityPluginSetup | null; @@ -54,7 +53,6 @@ const cache: DependencyCache = { autocomplete: null, basePath: null, savedObjectsClient: null, - XSRF: null, application: null, http: null, security: null, @@ -73,7 +71,6 @@ export function setDependencyCache(deps: Partial<DependencyCache>) { cache.autocomplete = deps.autocomplete || null; cache.basePath = deps.basePath || null; cache.savedObjectsClient = deps.savedObjectsClient || null; - cache.XSRF = deps.XSRF || null; cache.application = deps.application || null; cache.http = deps.http || null; cache.security = deps.security || null; @@ -162,13 +159,6 @@ export function getSavedObjectsClient() { return cache.savedObjectsClient; } -export function getXSRF() { - if (cache.XSRF === null) { - throw new Error("xsrf hasn't been initialized"); - } - return cache.XSRF; -} - export function getApplication() { if (cache.application === null) { throw new Error("application hasn't been initialized"); diff --git a/x-pack/legacy/plugins/ml/public/legacy.ts b/x-pack/legacy/plugins/ml/public/legacy.ts index 0c6c0bd8dd29e..9fb53e78d9454 100644 --- a/x-pack/legacy/plugins/ml/public/legacy.ts +++ b/x-pack/legacy/plugins/ml/public/legacy.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import chrome from 'ui/chrome'; import { npSetup, npStart } from 'ui/new_platform'; import { PluginInitializerContext } from 'src/core/public'; import { SecurityPluginSetup } from '../../../../plugins/security/public'; @@ -26,8 +25,5 @@ export const setup = pluginInstance.setup(npSetup.core, { data: npStart.plugins.data, security: setupDependencies.security, licensing: setupDependencies.licensing, - __LEGACY: { - XSRF: chrome.getXsrfToken(), - }, }); export const start = pluginInstance.start(npStart.core, npStart.plugins); diff --git a/x-pack/legacy/plugins/ml/public/plugin.ts b/x-pack/legacy/plugins/ml/public/plugin.ts index c0369a74c070a..7b3a5f6fadfac 100644 --- a/x-pack/legacy/plugins/ml/public/plugin.ts +++ b/x-pack/legacy/plugins/ml/public/plugin.ts @@ -8,7 +8,7 @@ import { Plugin, CoreStart, CoreSetup } from 'src/core/public'; import { MlDependencies } from './application/app'; export class MlPlugin implements Plugin<Setup, Start> { - setup(core: CoreSetup, { data, security, licensing, __LEGACY }: MlDependencies) { + setup(core: CoreSetup, { data, security, licensing }: MlDependencies) { core.application.register({ id: 'ml', title: 'Machine learning', @@ -21,7 +21,6 @@ export class MlPlugin implements Plugin<Setup, Start> { onAppLeave: params.onAppLeave, history: params.history, data, - __LEGACY, security, licensing, }); diff --git a/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap b/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap index 469f5e6e7b3c6..757677f1d4f82 100644 --- a/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap +++ b/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap @@ -47,6 +47,11 @@ Object { }, "settleTime": 1000, "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, "viewport": Object { "height": 1200, "width": 1950, @@ -138,6 +143,11 @@ Object { }, "settleTime": 1000, "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, "viewport": Object { "height": 1200, "width": 1950, @@ -228,6 +238,11 @@ Object { }, "settleTime": 1000, "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, "viewport": Object { "height": 1200, "width": 1950, @@ -319,6 +334,11 @@ Object { }, "settleTime": 1000, "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, "viewport": Object { "height": 1200, "width": 1950, diff --git a/x-pack/legacy/plugins/reporting/config.ts b/x-pack/legacy/plugins/reporting/config.ts index 34fc1f452fbc0..211fa70301bbf 100644 --- a/x-pack/legacy/plugins/reporting/config.ts +++ b/x-pack/legacy/plugins/reporting/config.ts @@ -31,6 +31,17 @@ export async function config(Joi: any) { .default(120000), }).default(), capture: Joi.object({ + timeouts: Joi.object({ + openUrl: Joi.number() + .integer() + .default(30000), + waitForElements: Joi.number() + .integer() + .default(30000), + renderComplete: Joi.number() + .integer() + .default(30000), + }).default(), networkPolicy: Joi.object({ enabled: Joi.boolean().default(true), rules: Joi.array() diff --git a/x-pack/legacy/plugins/reporting/export_types/common/constants.ts b/x-pack/legacy/plugins/reporting/export_types/common/constants.ts index 02a3e787da750..254cfbaa878bd 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/constants.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/constants.ts @@ -9,4 +9,4 @@ export const LayoutTypes = { PRINT: 'print', }; -export const WAITFOR_SELECTOR = '.application'; +export const PAGELOAD_SELECTOR = '.application'; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts index 54fae60a0773c..2c43517dbcaa9 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts @@ -27,7 +27,6 @@ export interface LayoutSelectorDictionary { renderComplete: string; itemsCountAttribute: string; timefilterDurationAttribute: string; - toastHeader: string; } export interface PdfImageSize { @@ -40,7 +39,6 @@ export const getDefaultLayoutSelectors = (): LayoutSelectorDictionary => ({ renderComplete: '[data-shared-item]', itemsCountAttribute: 'data-shared-items-count', timefilterDurationAttribute: 'data-shared-timefilter-duration', - toastHeader: '[data-test-subj="euiToastHeader"]', }); export abstract class Layout { @@ -75,9 +73,11 @@ export interface LayoutParams { dimensions: Size; } -export type LayoutInstance = Layout & { +interface LayoutSelectors { // Fields that are not part of Layout: the instances // independently implement these fields on their own selectors: LayoutSelectorDictionary; positionElements?: (browser: HeadlessChromiumDriver, logger: LevelLogger) => Promise<void>; -}; +} + +export type LayoutInstance = Layout & LayoutSelectors & Size; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/preserve_layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/preserve_layout.ts index cfa421b6f66ab..07dbba7d25883 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/preserve_layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/preserve_layout.ts @@ -19,8 +19,8 @@ const ZOOM: number = 2; export class PreserveLayout extends Layout { public readonly selectors: LayoutSelectorDictionary = getDefaultLayoutSelectors(); public readonly groupCount = 1; - private readonly height: number; - private readonly width: number; + public readonly height: number; + public readonly width: number; private readonly scaledHeight: number; private readonly scaledWidth: number; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/check_for_toast.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/check_for_toast.ts deleted file mode 100644 index c888870bd2bc3..0000000000000 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/check_for_toast.ts +++ /dev/null @@ -1,58 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { ElementHandle } from 'puppeteer'; -import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; -import { LevelLogger } from '../../../../server/lib'; -import { LayoutInstance } from '../../layouts/layout'; -import { CONTEXT_CHECKFORTOASTMESSAGE } from './constants'; - -export const checkForToastMessage = async ( - browser: HeadlessBrowser, - layout: LayoutInstance, - logger: LevelLogger -): Promise<ElementHandle<Element>> => { - return await browser - .waitForSelector(layout.selectors.toastHeader, { silent: true }, logger) - .then(async () => { - // Check for a toast message on the page. If there is one, capture the - // message and throw an error, to fail the screenshot. - const toastHeaderText: string = await browser.evaluate( - { - fn: selector => { - const nodeList = document.querySelectorAll(selector); - return nodeList.item(0).innerText; - }, - args: [layout.selectors.toastHeader], - }, - { context: CONTEXT_CHECKFORTOASTMESSAGE }, - logger - ); - - // Log an error to track the event in kibana server logs - logger.error( - i18n.translate( - 'xpack.reporting.exportTypes.printablePdf.screenshots.unexpectedErrorMessage', - { - defaultMessage: 'Encountered an unexpected message on the page: {toastHeaderText}', - values: { toastHeaderText }, - } - ) - ); - - // Throw an error to fail the screenshot job with a message - throw new Error( - i18n.translate( - 'xpack.reporting.exportTypes.printablePdf.screenshots.unexpectedErrorMessage', - { - defaultMessage: 'Encountered an unexpected message on the page: {toastHeaderText}', - values: { toastHeaderText }, - } - ) - ); - }); -}; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/constants.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/constants.ts index bbc97ca57940c..a3faf9337524e 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/constants.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/constants.ts @@ -9,6 +9,6 @@ export const CONTEXT_INJECTCSS = 'InjectCss'; export const CONTEXT_WAITFORRENDER = 'WaitForRender'; export const CONTEXT_GETTIMERANGE = 'GetTimeRange'; export const CONTEXT_ELEMENTATTRIBUTES = 'ElementPositionAndAttributes'; -export const CONTEXT_CHECKFORTOASTMESSAGE = 'CheckForToastMessage'; export const CONTEXT_WAITFORELEMENTSTOBEINDOM = 'WaitForElementsToBeInDOM'; export const CONTEXT_SKIPTELEMETRY = 'SkipTelemetry'; +export const CONTEXT_READMETADATA = 'ReadVisualizationsMetadata'; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts index 4302f4c631e3c..2f93765165e50 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LayoutInstance } from '../../layouts/layout'; import { AttributesMap, ElementsPositionAndAttribute } from './types'; @@ -14,50 +15,58 @@ export const getElementPositionAndAttributes = async ( browser: HeadlessBrowser, layout: LayoutInstance, logger: Logger -): Promise<ElementsPositionAndAttribute[]> => { - const elementsPositionAndAttributes: ElementsPositionAndAttribute[] = await browser.evaluate( - { - fn: (selector: string, attributes: any) => { - const elements: NodeListOf<Element> = document.querySelectorAll(selector); +): Promise<ElementsPositionAndAttribute[] | null> => { + const { screenshot: screenshotSelector } = layout.selectors; // data-shared-items-container + let elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null; + try { + elementsPositionAndAttributes = await browser.evaluate( + { + fn: (selector, attributes) => { + const elements: NodeListOf<Element> = document.querySelectorAll(selector); - // NodeList isn't an array, just an iterator, unable to use .map/.forEach - const results: ElementsPositionAndAttribute[] = []; - for (let i = 0; i < elements.length; i++) { - const element = elements[i]; - const boundingClientRect = element.getBoundingClientRect() as DOMRect; - results.push({ - position: { - boundingClientRect: { - // modern browsers support x/y, but older ones don't - top: boundingClientRect.y || boundingClientRect.top, - left: boundingClientRect.x || boundingClientRect.left, - width: boundingClientRect.width, - height: boundingClientRect.height, + // NodeList isn't an array, just an iterator, unable to use .map/.forEach + const results: ElementsPositionAndAttribute[] = []; + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + const boundingClientRect = element.getBoundingClientRect() as DOMRect; + results.push({ + position: { + boundingClientRect: { + // modern browsers support x/y, but older ones don't + top: boundingClientRect.y || boundingClientRect.top, + left: boundingClientRect.x || boundingClientRect.left, + width: boundingClientRect.width, + height: boundingClientRect.height, + }, + scroll: { + x: window.scrollX, + y: window.scrollY, + }, }, - scroll: { - x: window.scrollX, - y: window.scrollY, - }, - }, - attributes: Object.keys(attributes).reduce((result: AttributesMap, key) => { - const attribute = attributes[key]; - (result as any)[key] = element.getAttribute(attribute); - return result; - }, {} as AttributesMap), - }); - } - return results; + attributes: Object.keys(attributes).reduce((result: AttributesMap, key) => { + const attribute = attributes[key]; + (result as any)[key] = element.getAttribute(attribute); + return result; + }, {} as AttributesMap), + }); + } + return results; + }, + args: [screenshotSelector, { title: 'data-title', description: 'data-description' }], }, - args: [layout.selectors.screenshot, { title: 'data-title', description: 'data-description' }], - }, - { context: CONTEXT_ELEMENTATTRIBUTES }, - logger - ); - - if (elementsPositionAndAttributes.length === 0) { - throw new Error( - `No shared items containers were found on the page! Reporting requires a container element with the '${layout.selectors.screenshot}' attribute on the page.` + { context: CONTEXT_ELEMENTATTRIBUTES }, + logger ); + + if (!elementsPositionAndAttributes || elementsPositionAndAttributes.length === 0) { + throw new Error( + i18n.translate('xpack.reporting.screencapture.noElements', { + defaultMessage: `An error occurred while reading the page for visualization panels: no panels were found.`, + }) + ); + } + } catch (err) { + elementsPositionAndAttributes = null; } return elementsPositionAndAttributes; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts index 1beae719cd6b0..16eb433e8a75e 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts @@ -4,38 +4,72 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; +import { ServerFacade } from '../../../../types'; import { LayoutInstance } from '../../layouts/layout'; -import { CONTEXT_GETNUMBEROFITEMS } from './constants'; +import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants'; export const getNumberOfItems = async ( + server: ServerFacade, browser: HeadlessBrowser, layout: LayoutInstance, logger: LevelLogger ): Promise<number> => { - logger.debug('determining how many rendered items to wait for'); + const config = server.config(); + const { renderComplete: renderCompleteSelector, itemsCountAttribute } = layout.selectors; + let itemsCount: number; - // returns the value of the `itemsCountAttribute` if it's there, otherwise - // we just count the number of `itemSelector` - const itemsCount: number = await browser.evaluate( - { - fn: (selector, countAttribute) => { - const elementWithCount = document.querySelector(`[${countAttribute}]`); - if (elementWithCount && elementWithCount != null) { - const count = elementWithCount.getAttribute(countAttribute); - if (count && count != null) { - return parseInt(count, 10); + logger.debug( + i18n.translate('xpack.reporting.screencapture.logWaitingForElements', { + defaultMessage: 'waiting for elements or items count attribute; or not found to interrupt', + }) + ); + + try { + // the dashboard is using the `itemsCountAttribute` attribute to let us + // know how many items to expect since gridster incrementally adds panels + // we have to use this hint to wait for all of them + await browser.waitForSelector( + `${renderCompleteSelector},[${itemsCountAttribute}]`, + { timeout: config.get('xpack.reporting.capture.timeouts.waitForElements') }, + { context: CONTEXT_READMETADATA }, + logger + ); + + // returns the value of the `itemsCountAttribute` if it's there, otherwise + // we just count the number of `itemSelector`: the number of items already rendered + itemsCount = await browser.evaluate( + { + fn: (selector, countAttribute) => { + const elementWithCount = document.querySelector(`[${countAttribute}]`); + if (elementWithCount && elementWithCount != null) { + const count = elementWithCount.getAttribute(countAttribute); + if (count && count != null) { + return parseInt(count, 10); + } } - } - return document.querySelectorAll(selector).length; + return document.querySelectorAll(selector).length; + }, + args: [renderCompleteSelector, itemsCountAttribute], }, - args: [layout.selectors.renderComplete, layout.selectors.itemsCountAttribute], - }, - { context: CONTEXT_GETNUMBEROFITEMS }, - logger - ); + { context: CONTEXT_GETNUMBEROFITEMS }, + logger + ); + } catch (err) { + throw new Error( + i18n.translate('xpack.reporting.screencapture.readVisualizationsError', { + defaultMessage: `An error occurred when trying to read the page for visualization panel info. You may need to increase '{configKey}'. {error}`, + values: { + error: err, + configKey: 'xpack.reporting.capture.timeouts.waitForElements', + }, + }) + ); + itemsCount = 1; + } return itemsCount; }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts index b21d1e752ba3f..d50ac64743f07 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; import { Screenshot, ElementsPositionAndAttribute } from './types'; @@ -12,21 +13,29 @@ const getAsyncDurationLogger = (logger: LevelLogger) => { return async (description: string, promise: Promise<any>) => { const start = Date.now(); const result = await promise; - logger.debug(`${description} took ${Date.now() - start}ms`); + logger.debug( + i18n.translate('xpack.reporting.screencapture.asyncTook', { + defaultMessage: '{description} took {took}ms', + values: { + description, + took: Date.now() - start, + }, + }) + ); return result; }; }; -export const getScreenshots = async ({ - browser, - elementsPositionAndAttributes, - logger, -}: { - logger: LevelLogger; - browser: HeadlessBrowser; - elementsPositionAndAttributes: ElementsPositionAndAttribute[]; -}): Promise<Screenshot[]> => { - logger.info(`taking screenshots`); +export const getScreenshots = async ( + browser: HeadlessBrowser, + elementsPositionAndAttributes: ElementsPositionAndAttribute[], + logger: LevelLogger +): Promise<Screenshot[]> => { + logger.info( + i18n.translate('xpack.reporting.screencapture.takingScreenshots', { + defaultMessage: `taking screenshots`, + }) + ); const asyncDurationLogger = getAsyncDurationLogger(logger); const screenshots: Screenshot[] = []; @@ -45,7 +54,14 @@ export const getScreenshots = async ({ }); } - logger.info(`screenshots taken: ${screenshots.length}`); + logger.info( + i18n.translate('xpack.reporting.screencapture.screenshotsTaken', { + defaultMessage: `screenshots taken: {numScreenhots}`, + values: { + numScreenhots: screenshots.length, + }, + }) + ); return screenshots; }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts index 40204804a276f..cb2673e85186b 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import fs from 'fs'; import { promisify } from 'util'; import { LevelLogger } from '../../../../server/lib'; @@ -18,21 +19,34 @@ export const injectCustomCss = async ( layout: Layout, logger: LevelLogger ): Promise<void> => { - logger.debug('injecting custom css'); + logger.debug( + i18n.translate('xpack.reporting.screencapture.injectingCss', { + defaultMessage: 'injecting custom css', + }) + ); const filePath = layout.getCssOverridesPath(); const buffer = await fsp.readFile(filePath); - await browser.evaluate( - { - fn: css => { - const node = document.createElement('style'); - node.type = 'text/css'; - node.innerHTML = css; // eslint-disable-line no-unsanitized/property - document.getElementsByTagName('head')[0].appendChild(node); + try { + await browser.evaluate( + { + fn: css => { + const node = document.createElement('style'); + node.type = 'text/css'; + node.innerHTML = css; // eslint-disable-line no-unsanitized/property + document.getElementsByTagName('head')[0].appendChild(node); + }, + args: [buffer.toString()], }, - args: [buffer.toString()], - }, - { context: CONTEXT_INJECTCSS }, - logger - ); + { context: CONTEXT_INJECTCSS }, + logger + ); + } catch (err) { + throw new Error( + i18n.translate('xpack.reporting.screencapture.injectCss', { + defaultMessage: `An error occurred when trying to update Kibana CSS for reporting. {error}`, + values: { error: err }, + }) + ); + } }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts index 9f8e218f4f614..13d07bcdd6baf 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts @@ -23,7 +23,6 @@ import { createMockBrowserDriverFactory, createMockLayoutInstance, createMockServer, - mockSelectors, } from '../../../../test_helpers'; import { ConditionalHeaders, HeadlessChromiumDriver } from '../../../../types'; import { screenshotsObservableFactory } from './observable'; @@ -61,6 +60,7 @@ describe('Screenshot Observable Pipeline', () => { expect(result).toMatchInlineSnapshot(` Array [ Object { + "error": undefined, "screenshots": Array [ Object { "base64EncodedData": "allyourBase64 of boundingClientRect,scroll", @@ -98,6 +98,7 @@ describe('Screenshot Observable Pipeline', () => { expect(result).toMatchInlineSnapshot(` Array [ Object { + "error": undefined, "screenshots": Array [ Object { "base64EncodedData": "allyourBase64 screenshots", @@ -108,6 +109,7 @@ describe('Screenshot Observable Pipeline', () => { "timeRange": "Default GetTimeRange Result", }, Object { + "error": undefined, "screenshots": Array [ Object { "base64EncodedData": "allyourBase64 screenshots", @@ -122,15 +124,10 @@ describe('Screenshot Observable Pipeline', () => { }); describe('error handling', () => { - it('fails if error toast message is found', async () => { + it('recovers if waitForSelector fails', async () => { // mock implementations const mockWaitForSelector = jest.fn().mockImplementation((selectorArg: string) => { - const { toastHeader } = mockSelectors; - if (selectorArg === toastHeader) { - return Promise.resolve(true); - } - // make the error toast message get found before anything else - return Rx.interval(100).toPromise(); + throw new Error('Mock error!'); }); // mocks @@ -153,12 +150,35 @@ describe('Screenshot Observable Pipeline', () => { }).toPromise(); }; - await expect(getScreenshot()).rejects.toMatchInlineSnapshot( - `[Error: Encountered an unexpected message on the page: Toast Message]` - ); + await expect(getScreenshot()).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!], + "screenshots": Array [ + Object { + "base64EncodedData": "allyourBase64 of boundingClientRect,scroll", + "description": undefined, + "title": undefined, + }, + ], + "timeRange": null, + }, + Object { + "error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!], + "screenshots": Array [ + Object { + "base64EncodedData": "allyourBase64 of boundingClientRect,scroll", + "description": undefined, + "title": undefined, + }, + ], + "timeRange": null, + }, + ] + `); }); - it('fails if exit$ fires a timeout or error signal', async () => { + it('recovers if exit$ fires a timeout signal', async () => { // mocks const mockGetCreatePage = (driver: HeadlessChromiumDriver) => jest @@ -188,7 +208,21 @@ describe('Screenshot Observable Pipeline', () => { }).toPromise(); }; - await expect(getScreenshot()).rejects.toMatchInlineSnapshot(`"Instant timeout has fired!"`); + await expect(getScreenshot()).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "error": "Instant timeout has fired!", + "screenshots": Array [ + Object { + "base64EncodedData": "allyourBase64 of boundingClientRect,scroll", + "description": undefined, + "title": undefined, + }, + ], + "timeRange": null, + }, + ] + `); }); }); }); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts index d429931602951..878a9d3b87393 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts @@ -5,19 +5,18 @@ */ import * as Rx from 'rxjs'; -import { concatMap, first, mergeMap, take, toArray } from 'rxjs/operators'; +import { catchError, concatMap, first, mergeMap, take, takeUntil, toArray } from 'rxjs/operators'; import { CaptureConfig, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; import { getElementPositionAndAttributes } from './get_element_position_data'; import { getNumberOfItems } from './get_number_of_items'; import { getScreenshots } from './get_screenshots'; import { getTimeRange } from './get_time_range'; -import { injectCustomCss } from './inject_css'; import { openUrl } from './open_url'; -import { scanPage } from './scan_page'; -import { ScreenshotObservableOpts, ScreenshotResults } from './types'; -import { waitForElementsToBeInDOM } from './wait_for_dom_elements'; -import { waitForRenderComplete } from './wait_for_render'; import { skipTelemetry } from './skip_telemetry'; +import { ScreenSetupData, ScreenshotObservableOpts, ScreenshotResults } from './types'; +import { waitForRenderComplete } from './wait_for_render'; +import { waitForVisualizations } from './wait_for_visualizations'; +import { injectCustomCss } from './inject_css'; export function screenshotsObservableFactory( server: ServerFacade, @@ -41,16 +40,16 @@ export function screenshotsObservableFactory( concatMap(url => { return create$.pipe( mergeMap(({ driver, exit$ }) => { - const screenshot$ = Rx.of(1).pipe( - mergeMap(() => openUrl(driver, url, conditionalHeaders, logger)), + const setup$: Rx.Observable<ScreenSetupData> = Rx.of(1).pipe( + takeUntil(exit$), + mergeMap(() => openUrl(server, driver, url, conditionalHeaders, logger)), mergeMap(() => skipTelemetry(driver, logger)), - mergeMap(() => scanPage(driver, layout, logger)), - mergeMap(() => getNumberOfItems(driver, layout, logger)), + mergeMap(() => getNumberOfItems(server, driver, layout, logger)), mergeMap(async itemsCount => { const viewport = layout.getViewport(itemsCount); await Promise.all([ driver.setViewport(viewport, logger), - waitForElementsToBeInDOM(driver, itemsCount, layout, logger), + waitForVisualizations(server, driver, itemsCount, layout, logger), ]); }), mergeMap(async () => { @@ -63,28 +62,35 @@ export function screenshotsObservableFactory( await layout.positionElements(driver, logger); } - await waitForRenderComplete(captureConfig, driver, layout, logger); + await waitForRenderComplete(driver, layout, captureConfig, logger); }), - mergeMap(() => getTimeRange(driver, layout, logger)), - mergeMap( - async (timeRange): Promise<ScreenshotResults> => { - const elementsPositionAndAttributes = await getElementPositionAndAttributes( - driver, - layout, - logger - ); - const screenshots = await getScreenshots({ - browser: driver, - elementsPositionAndAttributes, - logger, - }); + mergeMap(async () => { + return await Promise.all([ + getTimeRange(driver, layout, logger), + getElementPositionAndAttributes(driver, layout, logger), + ]).then(([timeRange, elementsPositionAndAttributes]) => ({ + elementsPositionAndAttributes, + timeRange, + })); + }), + catchError(err => { + logger.error(err); + return Rx.of({ elementsPositionAndAttributes: null, timeRange: null, error: err }); + }) + ); - return { timeRange, screenshots }; + return setup$.pipe( + mergeMap( + async (data: ScreenSetupData): Promise<ScreenshotResults> => { + const elements = data.elementsPositionAndAttributes + ? data.elementsPositionAndAttributes + : getDefaultElementPosition(layout.getViewport(1)); + const screenshots = await getScreenshots(driver, elements, logger); + const { timeRange, error: setupError } = data; + return { timeRange, screenshots, error: setupError }; } ) ); - - return Rx.race(screenshot$, exit$); }), first() ); @@ -94,3 +100,18 @@ export function screenshotsObservableFactory( ); }; } + +/* + * If an error happens setting up the page, we don't know if there actually + * are any visualizations showing. These defaults should help capture the page + * enough for the user to see the error themselves + */ +const getDefaultElementPosition = ({ height, width }: { height: number; width: number }) => [ + { + position: { + boundingClientRect: { top: 0, left: 0, height, width }, + scroll: { x: 0, y: 0 }, + }, + attributes: {}, + }, +]; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts index e465499f839f9..fbae1f91a7a6a 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts @@ -4,23 +4,40 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ConditionalHeaders } from '../../../../types'; +import { i18n } from '@kbn/i18n'; +import { ConditionalHeaders, ServerFacade } from '../../../../types'; import { LevelLogger } from '../../../../server/lib'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; -import { WAITFOR_SELECTOR } from '../../constants'; +import { PAGELOAD_SELECTOR } from '../../constants'; export const openUrl = async ( + server: ServerFacade, browser: HeadlessBrowser, url: string, conditionalHeaders: ConditionalHeaders, logger: LevelLogger ): Promise<void> => { - await browser.open( - url, - { - conditionalHeaders, - waitForSelector: WAITFOR_SELECTOR, - }, - logger - ); + const config = server.config(); + + try { + await browser.open( + url, + { + conditionalHeaders, + waitForSelector: PAGELOAD_SELECTOR, + timeout: config.get('xpack.reporting.capture.timeouts.openUrl'), + }, + logger + ); + } catch (err) { + throw new Error( + i18n.translate('xpack.reporting.screencapture.couldntLoadKibana', { + defaultMessage: `An error occurred when trying to open the Kibana URL. You may need to increase '{configKey}'. {error}`, + values: { + configKey: 'xpack.reporting.capture.timeouts.openUrl', + error: err, + }, + }) + ); + } }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/scan_page.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/scan_page.ts deleted file mode 100644 index 010ffe8f23afc..0000000000000 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/scan_page.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as Rx from 'rxjs'; -import { HeadlessChromiumDriver } from '../../../../server/browsers'; -import { LevelLogger } from '../../../../server/lib'; -import { LayoutInstance } from '../../layouts/layout'; -import { checkForToastMessage } from './check_for_toast'; - -export function scanPage( - browser: HeadlessChromiumDriver, - layout: LayoutInstance, - logger: LevelLogger -) { - logger.debug('waiting for elements or items count attribute; or not found to interrupt'); - - // the dashboard is using the `itemsCountAttribute` attribute to let us - // know how many items to expect since gridster incrementally adds panels - // we have to use this hint to wait for all of them - const renderSuccess = browser.waitForSelector( - `${layout.selectors.renderComplete},[${layout.selectors.itemsCountAttribute}]`, - {}, - logger - ); - const renderError = checkForToastMessage(browser, layout, logger); - return Rx.race(Rx.from(renderSuccess), Rx.from(renderError)); -} diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts index 78cd42f0cae2f..ab81a952f345c 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts @@ -35,7 +35,14 @@ export interface Screenshot { description: string; } +export interface ScreenSetupData { + elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null; + timeRange: TimeRange | null; + error?: Error; +} + export interface ScreenshotResults { timeRange: TimeRange | null; screenshots: Screenshot[]; + error?: Error; } diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_dom_elements.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_dom_elements.ts deleted file mode 100644 index c958585f78e0d..0000000000000 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_dom_elements.ts +++ /dev/null @@ -1,34 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; -import { LevelLogger } from '../../../../server/lib'; -import { LayoutInstance } from '../../layouts/layout'; -import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; - -export const waitForElementsToBeInDOM = async ( - browser: HeadlessBrowser, - itemsCount: number, - layout: LayoutInstance, - logger: LevelLogger -): Promise<number> => { - logger.debug(`waiting for ${itemsCount} rendered elements to be in the DOM`); - - await browser.waitFor( - { - fn: selector => { - return document.querySelectorAll(selector).length; - }, - args: [layout.selectors.renderComplete], - toEqual: itemsCount, - }, - { context: CONTEXT_WAITFORELEMENTSTOBEINDOM }, - logger - ); - - logger.info(`found ${itemsCount} rendered elements in the DOM`); - return itemsCount; -}; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts index 632f008ca63bc..2f6dc2829dfd8 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { CaptureConfig } from '../../../../types'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; @@ -11,12 +12,16 @@ import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_WAITFORRENDER } from './constants'; export const waitForRenderComplete = async ( - captureConfig: CaptureConfig, browser: HeadlessBrowser, layout: LayoutInstance, + captureConfig: CaptureConfig, logger: LevelLogger ) => { - logger.debug('waiting for rendering to complete'); + logger.debug( + i18n.translate('xpack.reporting.screencapture.waitingForRenderComplete', { + defaultMessage: 'waiting for rendering to complete', + }) + ); return await browser .evaluate( @@ -66,6 +71,10 @@ export const waitForRenderComplete = async ( logger ) .then(() => { - logger.debug('rendering is complete'); + logger.debug( + i18n.translate('xpack.reporting.screencapture.renderIsComplete', { + defaultMessage: 'rendering is complete', + }) + ); }); }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts new file mode 100644 index 0000000000000..93ad40026dff8 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ServerFacade } from '../../../../types'; +import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; +import { LevelLogger } from '../../../../server/lib'; +import { LayoutInstance } from '../../layouts/layout'; +import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; + +type SelectorArgs = Record<string, string>; + +const getCompletedItemsCount = ({ renderCompleteSelector }: SelectorArgs) => { + return document.querySelectorAll(renderCompleteSelector).length; +}; + +/* + * 1. Wait for the visualization metadata to be found in the DOM + * 2. Read the metadata for the number of visualization items + * 3. Wait for the render complete event to be fired once for each item + */ +export const waitForVisualizations = async ( + server: ServerFacade, + browser: HeadlessBrowser, + itemsCount: number, + layout: LayoutInstance, + logger: LevelLogger +): Promise<void> => { + const config = server.config(); + const { renderComplete: renderCompleteSelector } = layout.selectors; + + logger.debug( + i18n.translate('xpack.reporting.screencapture.waitingForRenderedElements', { + defaultMessage: `waiting for {itemsCount} rendered elements to be in the DOM`, + values: { itemsCount }, + }) + ); + + try { + await browser.waitFor( + { + fn: getCompletedItemsCount, + args: [{ renderCompleteSelector }], + toEqual: itemsCount, + timeout: config.get('xpack.reporting.capture.timeouts.renderComplete'), + }, + { context: CONTEXT_WAITFORELEMENTSTOBEINDOM }, + logger + ); + + logger.debug(`found ${itemsCount} rendered elements in the DOM`); + } catch (err) { + throw new Error( + i18n.translate('xpack.reporting.screencapture.couldntFinishRendering', { + defaultMessage: `An error occurred when trying to wait for {count} visualizations to finish rendering. You may need to increase '{configKey}'. {error}`, + values: { + count: itemsCount, + configKey: 'xpack.reporting.capture.timeouts.renderComplete', + error: err, + }, + }) + ); + } +}; diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js index c0c21119e1d53..e2e6ba1b89096 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js @@ -114,7 +114,7 @@ test(`returns content of generatePng getBuffer base64 encoded`, async () => { const testContent = 'test content'; const generatePngObservable = generatePngObservableFactory(); - generatePngObservable.mockReturnValue(Rx.of(Buffer.from(testContent))); + generatePngObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); const executeJob = await executeJobFactory( mockReporting, diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts index 5cde245080914..8670f0027af89 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts @@ -4,17 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as Rx from 'rxjs'; import { ElasticsearchServiceSetup } from 'kibana/server'; +import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; -import { ReportingCore } from '../../../../server'; import { PNG_JOB_TYPE } from '../../../../common/constants'; -import { ServerFacade, ExecuteJobFactory, ESQueueWorkerExecuteFn, Logger } from '../../../../types'; +import { ReportingCore } from '../../../../server'; +import { + ESQueueWorkerExecuteFn, + ExecuteJobFactory, + JobDocOutput, + Logger, + ServerFacade, +} from '../../../../types'; import { decryptJobHeaders, - omitBlacklistedHeaders, getConditionalHeaders, getFullUrls, + omitBlacklistedHeaders, } from '../../../common/execute_job/'; import { JobDocPayloadPNG } from '../../types'; import { generatePngObservableFactory } from '../lib/generate_png'; @@ -33,7 +39,7 @@ export const executeJobFactory: QueuedPngExecutorFactory = async function execut return function executeJob(jobId: string, job: JobDocPayloadPNG, cancellationToken: any) { const jobLogger = logger.clone([jobId]); - const process$ = Rx.of(1).pipe( + const process$: Rx.Observable<JobDocOutput> = Rx.of(1).pipe( mergeMap(() => decryptJobHeaders({ server, job, logger })), map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })), map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })), @@ -48,11 +54,12 @@ export const executeJobFactory: QueuedPngExecutorFactory = async function execut job.layout ); }), - map((buffer: Buffer) => { + map(({ buffer, warnings }) => { return { content_type: 'image/png', content: buffer.toString('base64'), size: buffer.byteLength, + warnings, }; }), catchError(err => { diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts index 600762c451a79..88e91982adc63 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts @@ -7,10 +7,11 @@ import * as Rx from 'rxjs'; import { map } from 'rxjs/operators'; import { LevelLogger } from '../../../../server/lib'; -import { ServerFacade, HeadlessChromiumDriverFactory, ConditionalHeaders } from '../../../../types'; -import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; -import { PreserveLayout } from '../../../common/layouts/preserve_layout'; +import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; import { LayoutParams } from '../../../common/layouts/layout'; +import { PreserveLayout } from '../../../common/layouts/preserve_layout'; +import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; +import { ScreenshotResults } from '../../../common/lib/screenshots/types'; export function generatePngObservableFactory( server: ServerFacade, @@ -24,7 +25,7 @@ export function generatePngObservableFactory( browserTimezone: string, conditionalHeaders: ConditionalHeaders, layoutParams: LayoutParams - ): Rx.Observable<Buffer> { + ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { if (!layoutParams || !layoutParams.dimensions) { throw new Error(`LayoutParams.Dimensions is undefined.`); } @@ -37,12 +38,16 @@ export function generatePngObservableFactory( layout, browserTimezone, }).pipe( - map(([{ screenshots }]) => { - if (screenshots.length !== 1) { - throw new Error(`Expected there to be 1 screenshot, but there are ${screenshots.length}`); - } - - return screenshots[0].base64EncodedData; + map((results: ScreenshotResults[]) => { + return { + buffer: results[0].screenshots[0].base64EncodedData, + warnings: results.reduce((found, current) => { + if (current.error) { + found.push(current.error.message); + } + return found; + }, [] as string[]), + }; }) ); diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js index cc6b298bebdc5..484842ba18f2a 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js @@ -82,7 +82,7 @@ test(`returns content of generatePdf getBuffer base64 encoded`, async () => { const testContent = 'test content'; const generatePdfObservable = generatePdfObservableFactory(); - generatePdfObservable.mockReturnValue(Rx.of(Buffer.from(testContent))); + generatePdfObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); const executeJob = await executeJobFactory( mockReporting, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts index e8461862bee82..535c2dcd439a7 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts @@ -4,21 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as Rx from 'rxjs'; import { ElasticsearchServiceSetup } from 'kibana/server'; +import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; -import { ReportingCore } from '../../../../server'; -import { ServerFacade, ExecuteJobFactory, ESQueueWorkerExecuteFn, Logger } from '../../../../types'; -import { JobDocPayloadPDF } from '../../types'; import { PDF_JOB_TYPE } from '../../../../common/constants'; -import { generatePdfObservableFactory } from '../lib/generate_pdf'; +import { ReportingCore } from '../../../../server'; +import { + ESQueueWorkerExecuteFn, + ExecuteJobFactory, + JobDocOutput, + Logger, + ServerFacade, +} from '../../../../types'; import { decryptJobHeaders, - omitBlacklistedHeaders, getConditionalHeaders, - getFullUrls, getCustomLogo, + getFullUrls, + omitBlacklistedHeaders, } from '../../../common/execute_job/'; +import { JobDocPayloadPDF } from '../../types'; +import { generatePdfObservableFactory } from '../lib/generate_pdf'; type QueuedPdfExecutorFactory = ExecuteJobFactory<ESQueueWorkerExecuteFn<JobDocPayloadPDF>>; @@ -34,8 +40,7 @@ export const executeJobFactory: QueuedPdfExecutorFactory = async function execut return function executeJob(jobId: string, job: JobDocPayloadPDF, cancellationToken: any) { const jobLogger = logger.clone([jobId]); - - const process$ = Rx.of(1).pipe( + const process$: Rx.Observable<JobDocOutput> = Rx.of(1).pipe( mergeMap(() => decryptJobHeaders({ server, job, logger })), map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })), map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })), @@ -54,10 +59,11 @@ export const executeJobFactory: QueuedPdfExecutorFactory = async function execut logo ); }), - map((buffer: Buffer) => ({ + map(({ buffer, warnings }) => ({ content_type: 'application/pdf', content: buffer.toString('base64'), size: buffer.byteLength, + warnings, })), catchError(err => { jobLogger.error(err); diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts index 9a8db308bea79..d78effaa1fc2f 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts @@ -4,17 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ +import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; import { mergeMap } from 'rxjs/operators'; -import { groupBy } from 'lodash'; import { LevelLogger } from '../../../../server/lib'; -import { ServerFacade, HeadlessChromiumDriverFactory, ConditionalHeaders } from '../../../../types'; -// @ts-ignore untyped module -import { pdf } from './pdf'; -import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; +import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; import { createLayout } from '../../../common/layouts'; -import { ScreenshotResults } from '../../../common/lib/screenshots/types'; import { LayoutInstance, LayoutParams } from '../../../common/layouts/layout'; +import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; +import { ScreenshotResults } from '../../../common/lib/screenshots/types'; +// @ts-ignore untyped module +import { pdf } from './pdf'; const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { const grouped = groupBy(urlScreenshots.map(u => u.timeRange)); @@ -40,7 +40,7 @@ export function generatePdfObservableFactory( conditionalHeaders: ConditionalHeaders, layoutParams: LayoutParams, logo?: string - ): Rx.Observable<Buffer> { + ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { const layout = createLayout(server, layoutParams) as LayoutInstance; const screenshots$ = screenshotsObservable({ logger, @@ -49,17 +49,17 @@ export function generatePdfObservableFactory( layout, browserTimezone, }).pipe( - mergeMap(async urlScreenshots => { + mergeMap(async (results: ScreenshotResults[]) => { const pdfOutput = pdf.create(layout, logo); if (title) { - const timeRange = getTimeRange(urlScreenshots); + const timeRange = getTimeRange(results); title += timeRange ? ` - ${timeRange.duration}` : ''; pdfOutput.setTitle(title); } - urlScreenshots.forEach(({ screenshots }) => { - screenshots.forEach(screenshot => { + results.forEach(r => { + r.screenshots.forEach(screenshot => { pdfOutput.addImage(screenshot.base64EncodedData, { title: screenshot.title, description: screenshot.description, @@ -68,7 +68,16 @@ export function generatePdfObservableFactory( }); pdfOutput.generate(); - return await pdfOutput.getBuffer(); + + return { + buffer: await pdfOutput.getBuffer(), + warnings: results.reduce((found, current) => { + if (current.error) { + found.push(current.error.message); + } + return found; + }, [] as string[]), + }; }) ); diff --git a/x-pack/legacy/plugins/reporting/public/components/__snapshots__/report_info_button.test.tsx.snap b/x-pack/legacy/plugins/reporting/public/components/__snapshots__/report_info_button.test.tsx.snap index 2055afdcf2bfe..f89e90cc4860c 100644 --- a/x-pack/legacy/plugins/reporting/public/components/__snapshots__/report_info_button.test.tsx.snap +++ b/x-pack/legacy/plugins/reporting/public/components/__snapshots__/report_info_button.test.tsx.snap @@ -182,9 +182,13 @@ Array [ class="euiFlyoutBody__overflow" > <div - class="euiText euiText--medium" + class="euiFlyoutBody__overflowContent" > - Could not fetch the job info + <div + class="euiText euiText--medium" + > + Could not fetch the job info + </div> </div> </div> </div> @@ -243,9 +247,13 @@ Array [ class="euiFlyoutBody__overflow" > <div - class="euiText euiText--medium" + class="euiFlyoutBody__overflowContent" > - Could not fetch the job info + <div + class="euiText euiText--medium" + > + Could not fetch the job info + </div> </div> </div> </div> @@ -332,13 +340,17 @@ Array [ <div className="euiFlyoutBody__overflow" > - <EuiText> - <div - className="euiText euiText--medium" - > - Could not fetch the job info - </div> - </EuiText> + <div + className="euiFlyoutBody__overflowContent" + > + <EuiText> + <div + className="euiText euiText--medium" + > + Could not fetch the job info + </div> + </EuiText> + </div> </div> </div> </EuiFlyoutBody> @@ -440,13 +452,17 @@ Array [ <div className="euiFlyoutBody__overflow" > - <EuiText> - <div - className="euiText euiText--medium" - > - Could not fetch the job info - </div> - </EuiText> + <div + className="euiFlyoutBody__overflowContent" + > + <EuiText> + <div + className="euiText euiText--medium" + > + Could not fetch the job info + </div> + </EuiText> + </div> </div> </div> </EuiFlyoutBody> @@ -599,8 +615,12 @@ Array [ class="euiFlyoutBody__overflow" > <div - class="euiText euiText--medium" - /> + class="euiFlyoutBody__overflowContent" + > + <div + class="euiText euiText--medium" + /> + </div> </div> </div> </div> @@ -658,8 +678,12 @@ Array [ class="euiFlyoutBody__overflow" > <div - class="euiText euiText--medium" - /> + class="euiFlyoutBody__overflowContent" + > + <div + class="euiText euiText--medium" + /> + </div> </div> </div> </div> @@ -745,11 +769,15 @@ Array [ <div className="euiFlyoutBody__overflow" > - <EuiText> - <div - className="euiText euiText--medium" - /> - </EuiText> + <div + className="euiFlyoutBody__overflowContent" + > + <EuiText> + <div + className="euiText euiText--medium" + /> + </EuiText> + </div> </div> </div> </EuiFlyoutBody> @@ -851,11 +879,15 @@ Array [ <div className="euiFlyoutBody__overflow" > - <EuiText> - <div - className="euiText euiText--medium" - /> - </EuiText> + <div + className="euiFlyoutBody__overflowContent" + > + <EuiText> + <div + className="euiText euiText--medium" + /> + </EuiText> + </div> </div> </div> </EuiFlyoutBody> diff --git a/x-pack/legacy/plugins/reporting/public/components/report_info_button.tsx b/x-pack/legacy/plugins/reporting/public/components/report_info_button.tsx index 77869c40d3577..7f5d070948e50 100644 --- a/x-pack/legacy/plugins/reporting/public/components/report_info_button.tsx +++ b/x-pack/legacy/plugins/reporting/public/components/report_info_button.tsx @@ -86,95 +86,102 @@ export class ReportInfoButton extends Component<Props, State> { const maxAttempts = info.max_attempts ? info.max_attempts.toString() : NA; const priority = info.priority ? info.priority.toString() : NA; const timeout = info.timeout ? info.timeout.toString() : NA; + const warnings = info.output && info.output.warnings ? info.output.warnings.join(',') : null; + + const jobInfoDateTimes: JobInfo[] = [ + { + title: 'Created By', + description: info.created_by || NA, + }, + { + title: 'Created At', + description: info.created_at || NA, + }, + { + title: 'Started At', + description: info.started_at || NA, + }, + { + title: 'Completed At', + description: info.completed_at || NA, + }, + { + title: 'Processed By', + description: + info.kibana_name && info.kibana_id ? `${info.kibana_name} (${info.kibana_id})` : UNKNOWN, + }, + { + title: 'Browser Timezone', + description: get(info, 'payload.browserTimezone') || NA, + }, + ]; + const jobInfoPayload: JobInfo[] = [ + { + title: 'Title', + description: get(info, 'payload.title') || NA, + }, + { + title: 'Type', + description: get(info, 'payload.type') || NA, + }, + { + title: 'Layout', + description: get(info, 'meta.layout') || NA, + }, + { + title: 'Dimensions', + description: getDimensions(info), + }, + { + title: 'Job Type', + description: jobType, + }, + { + title: 'Content Type', + description: get(info, 'output.content_type') || NA, + }, + { + title: 'Size in Bytes', + description: get(info, 'output.size') || NA, + }, + ]; + const jobInfoStatus: JobInfo[] = [ + { + title: 'Attempts', + description: attempts, + }, + { + title: 'Max Attempts', + description: maxAttempts, + }, + { + title: 'Priority', + description: priority, + }, + { + title: 'Timeout', + description: timeout, + }, + { + title: 'Status', + description: info.status || NA, + }, + { + title: 'Browser Type', + description: USES_HEADLESS_JOB_TYPES.includes(jobType) ? info.browser_type || UNKNOWN : NA, + }, + ]; + if (warnings) { + jobInfoStatus.push({ + title: 'Errors', + description: warnings, + }); + } const jobInfoParts: JobInfoMap = { - datetimes: [ - { - title: 'Created By', - description: info.created_by || NA, - }, - { - title: 'Created At', - description: info.created_at || NA, - }, - { - title: 'Started At', - description: info.started_at || NA, - }, - { - title: 'Completed At', - description: info.completed_at || NA, - }, - { - title: 'Processed By', - description: - info.kibana_name && info.kibana_id - ? `${info.kibana_name} (${info.kibana_id})` - : UNKNOWN, - }, - { - title: 'Browser Timezone', - description: get(info, 'payload.browserTimezone') || NA, - }, - ], - payload: [ - { - title: 'Title', - description: get(info, 'payload.title') || NA, - }, - { - title: 'Type', - description: get(info, 'payload.type') || NA, - }, - { - title: 'Layout', - description: get(info, 'meta.layout') || NA, - }, - { - title: 'Dimensions', - description: getDimensions(info), - }, - { - title: 'Job Type', - description: jobType, - }, - { - title: 'Content Type', - description: get(info, 'output.content_type') || NA, - }, - { - title: 'Size in Bytes', - description: get(info, 'output.size') || NA, - }, - ], - status: [ - { - title: 'Attempts', - description: attempts, - }, - { - title: 'Max Attempts', - description: maxAttempts, - }, - { - title: 'Priority', - description: priority, - }, - { - title: 'Timeout', - description: timeout, - }, - { - title: 'Status', - description: info.status || NA, - }, - { - title: 'Browser Type', - description: USES_HEADLESS_JOB_TYPES.includes(jobType) - ? info.browser_type || UNKNOWN - : NA, - }, - ], + datetimes: jobInfoDateTimes, + payload: jobInfoPayload, + status: jobInfoStatus, }; return ( diff --git a/x-pack/legacy/plugins/reporting/public/components/report_listing.tsx b/x-pack/legacy/plugins/reporting/public/components/report_listing.tsx index 320f6220aa996..54061eda94dce 100644 --- a/x-pack/legacy/plugins/reporting/public/components/report_listing.tsx +++ b/x-pack/legacy/plugins/reporting/public/components/report_listing.tsx @@ -43,6 +43,7 @@ interface Job { attempts: number; max_attempts: number; csv_contains_formulas: boolean; + warnings: string[]; } interface Props { @@ -203,7 +204,7 @@ class ReportListingUi extends Component<Props, State> { return ( <div> <FormattedMessage - id="xpack.reporting.listing.tableValue.createdAtDetail.pendingStatusReachedText" + id="xpack.reporting.listing.tableValue.statusDetail.pendingStatusReachedText" defaultMessage="Pending - waiting for job to be processed" /> </div> @@ -215,13 +216,27 @@ class ReportListingUi extends Component<Props, State> { maxSizeReached = ( <span> <FormattedMessage - id="xpack.reporting.listing.tableValue.createdAtDetail.maxSizeReachedText" + id="xpack.reporting.listing.tableValue.statusDetail.maxSizeReachedText" defaultMessage=" - Max size reached" /> </span> ); } + let warnings; + if (record.warnings) { + warnings = ( + <EuiText size="s"> + <EuiTextColor color="subdued"> + <FormattedMessage + id="xpack.reporting.listing.tableValue.statusDetail.warningsText" + defaultMessage="Errors occurred: see job info for details." + /> + </EuiTextColor> + </EuiText> + ); + } + let statusTimestamp; if (status === JobStatuses.PROCESSING && record.started_at) { statusTimestamp = this.formatDate(record.started_at); @@ -242,7 +257,7 @@ class ReportListingUi extends Component<Props, State> { return ( <div> <FormattedMessage - id="xpack.reporting.listing.tableValue.createdAtDetail.statusTimestampText" + id="xpack.reporting.listing.tableValue.statusDetail.statusTimestampText" defaultMessage="{statusLabel} at {statusTimestamp}" values={{ statusLabel, @@ -250,6 +265,7 @@ class ReportListingUi extends Component<Props, State> { }} /> {maxSizeReached} + {warnings} </div> ); } @@ -259,6 +275,7 @@ class ReportListingUi extends Component<Props, State> { <div> {statusLabel} {maxSizeReached} + {warnings} </div> ); }, @@ -437,6 +454,7 @@ class ReportListingUi extends Component<Props, State> { attempts: source.attempts, max_attempts: source.max_attempts, csv_contains_formulas: get(source, 'output.csv_contains_formulas'), + warnings: source.output ? source.output.warnings : undefined, }; } ), diff --git a/x-pack/legacy/plugins/reporting/public/lib/job_queue_client.ts b/x-pack/legacy/plugins/reporting/public/lib/job_queue_client.ts index 281a2e1cdf9a5..87d4174168b7f 100644 --- a/x-pack/legacy/plugins/reporting/public/lib/job_queue_client.ts +++ b/x-pack/legacy/plugins/reporting/public/lib/job_queue_client.ts @@ -31,6 +31,7 @@ export interface JobInfo { output: { content_type: string; size: number; + warnings: string[]; }; process_expiration: string; completed_at: string; diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts index 0592124b9897b..60799e3e918b8 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { trunc, map } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { map, trunc } from 'lodash'; import open from 'opn'; +import { ElementHandle, EvaluateFn, Page, SerializableOrJSHandle } from 'puppeteer'; import { parse as parseUrl } from 'url'; -import { Page, SerializableOrJSHandle, EvaluateFn } from 'puppeteer'; import { ViewZoomWidthHeight } from '../../../../export_types/common/layouts/layout'; import { LevelLogger } from '../../../../server/lib'; -import { allowRequest } from '../../network_policy'; import { ConditionalHeaders, ConditionalHeadersConditions, @@ -18,6 +18,7 @@ import { InterceptedRequest, NetworkPolicy, } from '../../../../types'; +import { allowRequest } from '../../network_policy'; export interface ChromiumDriverOptions { inspect: boolean; @@ -25,7 +26,7 @@ export interface ChromiumDriverOptions { } interface WaitForSelectorOpts { - silent?: boolean; + timeout: number; } interface EvaluateOpts { @@ -65,10 +66,15 @@ export class HeadlessChromiumDriver { url: string, { conditionalHeaders, - waitForSelector, - }: { conditionalHeaders: ConditionalHeaders; waitForSelector: string }, + waitForSelector: pageLoadSelector, + timeout, + }: { + conditionalHeaders: ConditionalHeaders; + waitForSelector: string; + timeout: number; + }, logger: LevelLogger - ) { + ): Promise<void> { logger.info(`opening url ${url}`); // @ts-ignore const client = this.page._client; @@ -81,7 +87,7 @@ export class HeadlessChromiumDriver { // https://github.com/puppeteer/puppeteer/issues/5003 // Docs on this client/protocol can be found here: // https://chromedevtools.github.io/devtools-protocol/tot/Fetch - client.on('Fetch.requestPaused', (interceptedRequest: InterceptedRequest) => { + client.on('Fetch.requestPaused', async (interceptedRequest: InterceptedRequest) => { const { requestId, request: { url: interceptedUrl }, @@ -92,12 +98,17 @@ export class HeadlessChromiumDriver { // We should never ever let file protocol requests go through if (!allowed || !this.allowRequest(interceptedUrl)) { logger.error(`Got bad URL: "${interceptedUrl}", closing browser.`); - client.send('Fetch.failRequest', { + await client.send('Fetch.failRequest', { errorReason: 'Aborted', requestId, }); this.page.browser().close(); - throw new Error(`Received disallowed outgoing URL: "${interceptedUrl}", exiting`); + throw new Error( + i18n.translate('xpack.reporting.chromiumDriver.disallowedOutgoingUrl', { + defaultMessage: `Received disallowed outgoing URL: "{interceptedUrl}", exiting`, + values: { interceptedUrl }, + }) + ); } if (this._shouldUseCustomHeaders(conditionalHeaders.conditions, interceptedUrl)) { @@ -112,14 +123,33 @@ export class HeadlessChromiumDriver { value, }) ); - client.send('Fetch.continueRequest', { - requestId, - headers, - }); + + try { + await client.send('Fetch.continueRequest', { + requestId, + headers, + }); + } catch (err) { + logger.error( + i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequestUsingHeaders', { + defaultMessage: 'Failed to complete a request using headers: {error}', + values: { error: err }, + }) + ); + } } else { const loggedUrl = isData ? this.truncateUrl(interceptedUrl) : interceptedUrl; logger.debug(`No custom headers for ${loggedUrl}`); - client.send('Fetch.continueRequest', { requestId }); + try { + await client.send('Fetch.continueRequest', { requestId }); + } catch (err) { + logger.error( + i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequest', { + defaultMessage: 'Failed to complete a request: {error}', + values: { error: err }, + }) + ); + } } interceptedCount = interceptedCount + (isData ? 0 : 1); }); @@ -144,11 +174,16 @@ export class HeadlessChromiumDriver { await this.launchDebugger(); } - await this.waitForSelector(waitForSelector, {}, logger); + await this.waitForSelector( + pageLoadSelector, + { timeout }, + { context: 'waiting for page load selector' }, + logger + ); logger.info(`handled ${interceptedCount} page requests`); } - public async screenshot(elementPosition: ElementPosition) { + public async screenshot(elementPosition: ElementPosition): Promise<string> { let clip; if (elementPosition) { const { boundingClientRect, scroll = { x: 0, y: 0 } } = elementPosition; @@ -176,63 +211,56 @@ export class HeadlessChromiumDriver { const result = await this.page.evaluate(fn, ...args); return result; } + public async waitForSelector( selector: string, - opts: WaitForSelectorOpts = {}, + opts: WaitForSelectorOpts, + context: EvaluateMetaOpts, logger: LevelLogger - ) { - const { silent = false } = opts; + ): Promise<ElementHandle<Element>> { + const { timeout } = opts; logger.debug(`waitForSelector ${selector}`); - - let resp; - try { - resp = await this.page.waitFor(selector); - } catch (err) { - if (!silent) { - // Provide some troubleshooting info to see if we're on the login page, - // "Kibana could not load correctly", etc - logger.error(`waitForSelector ${selector} failed on ${this.page.url()}`); - const pageText = await this.evaluate( - { - fn: () => document.querySelector('body')!.innerText, - args: [], - }, - { context: `waitForSelector${selector}` }, - logger - ); - logger.debug(`Page plain text: ${pageText.replace(/\n/g, '\\n')}`); // replace newline with escaped for single log line - } - throw err; - } - + const resp = await this.page.waitFor(selector, { timeout }); // override default 30000ms logger.debug(`waitForSelector ${selector} resolved`); return resp; } - public async waitFor<T>( + public async waitFor( { fn, args, toEqual, + timeout, }: { fn: EvaluateFn; args: SerializableOrJSHandle[]; - toEqual: T; + toEqual: number; + timeout: number; }, context: EvaluateMetaOpts, logger: LevelLogger - ) { + ): Promise<void> { + const startTime = Date.now(); + while (true) { const result = await this.evaluate({ fn, args }, context, logger); if (result === toEqual) { return; } + if (Date.now() - startTime > timeout) { + throw new Error( + `Timed out waiting for the items selected to equal ${toEqual}. Found: ${result}. Context: ${context.context}` + ); + } await new Promise(r => setTimeout(r, WAIT_FOR_DELAY_MS)); } } - public async setViewport({ width, height, zoom }: ViewZoomWidthHeight, logger: LevelLogger) { + public async setViewport( + { width, height, zoom }: ViewZoomWidthHeight, + logger: LevelLogger + ): Promise<void> { logger.debug(`Setting viewport to width: ${width}, height: ${height}, zoom: ${zoom}`); await this.page.setViewport({ diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index 6fa46b893de8c..1a57408f41dd6 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -18,7 +18,7 @@ import * as Rx from 'rxjs'; import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators'; import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber'; -import { BrowserConfig, NetworkPolicy } from '../../../../types'; +import { BrowserConfig, CaptureConfig } from '../../../../types'; import { LevelLogger as Logger } from '../../../lib/level_logger'; import { HeadlessChromiumDriver } from '../driver'; import { safeChildProcess } from '../../safe_child_process'; @@ -27,30 +27,27 @@ import { getChromeLogLocation } from '../paths'; import { args } from './args'; type binaryPath = string; -type queueTimeout = number; +type ViewportConfig = BrowserConfig['viewport']; export class HeadlessChromiumDriverFactory { private binaryPath: binaryPath; + private captureConfig: CaptureConfig; private browserConfig: BrowserConfig; - private queueTimeout: queueTimeout; - private networkPolicy: NetworkPolicy; private userDataDir: string; - private getChromiumArgs: (viewport: BrowserConfig['viewport']) => string[]; + private getChromiumArgs: (viewport: ViewportConfig) => string[]; constructor( binaryPath: binaryPath, logger: Logger, browserConfig: BrowserConfig, - queueTimeout: queueTimeout, - networkPolicy: NetworkPolicy + captureConfig: CaptureConfig ) { this.binaryPath = binaryPath; this.browserConfig = browserConfig; - this.queueTimeout = queueTimeout; - this.networkPolicy = networkPolicy; + this.captureConfig = captureConfig; this.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chromium-')); - this.getChromiumArgs = (viewport: BrowserConfig['viewport']) => + this.getChromiumArgs = (viewport: ViewportConfig) => args({ userDataDir: this.userDataDir, viewport, @@ -88,7 +85,7 @@ export class HeadlessChromiumDriverFactory { * Return an observable to objects which will drive screenshot capture for a page */ createPage( - { viewport, browserTimezone }: { viewport: BrowserConfig['viewport']; browserTimezone: string }, + { viewport, browserTimezone }: { viewport: ViewportConfig; browserTimezone: string }, pLogger: Logger ): Rx.Observable<{ driver: HeadlessChromiumDriver; exit$: Rx.Observable<never> }> { return Rx.Observable.create(async (observer: InnerSubscriber<any, any>) => { @@ -113,11 +110,9 @@ export class HeadlessChromiumDriverFactory { page = await browser.newPage(); - // All navigation/waitFor methods default to 30 seconds, - // which can cause the job to fail even if we bump timeouts in - // the config. Help alleviate errors like - // "TimeoutError: waiting for selector ".application" failed: timeout 30000ms exceeded" - page.setDefaultTimeout(this.queueTimeout); + // Set the default timeout for all navigation methods to the openUrl timeout (30 seconds) + // All waitFor methods have their own timeout config passed in to them + page.setDefaultTimeout(this.captureConfig.timeouts.openUrl); logger.debug(`Browser page driver created`); } catch (err) { @@ -158,7 +153,7 @@ export class HeadlessChromiumDriverFactory { // HeadlessChromiumDriver: object to "drive" a browser page const driver = new HeadlessChromiumDriver(page, { inspect: this.browserConfig.inspect, - networkPolicy: this.networkPolicy, + networkPolicy: this.captureConfig.networkPolicy, }); // Rx.Observable<never>: stream to interrupt page capture diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts index d5f7027e025d4..d32338ae3e311 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BrowserConfig, NetworkPolicy } from '../../../types'; +import { BrowserConfig, CaptureConfig } from '../../../types'; import { LevelLogger } from '../../lib'; import { HeadlessChromiumDriverFactory } from './driver_factory'; @@ -14,14 +14,7 @@ export async function createDriverFactory( binaryPath: string, logger: LevelLogger, browserConfig: BrowserConfig, - queueTimeout: number, - networkPolicy: NetworkPolicy + captureConfig: CaptureConfig ): Promise<HeadlessChromiumDriverFactory> { - return new HeadlessChromiumDriverFactory( - binaryPath, - logger, - browserConfig, - queueTimeout, - networkPolicy - ); + return new HeadlessChromiumDriverFactory(binaryPath, logger, browserConfig, captureConfig); } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts index 128df4d318c76..49c6222c9f276 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts @@ -22,8 +22,6 @@ export async function createBrowserDriverFactory( const browserType = captureConfig.browser.type; const browserAutoDownload = captureConfig.browser.autoDownload; const browserConfig = captureConfig.browser[BROWSER_TYPE]; - const networkPolicy = captureConfig.networkPolicy; - const reportingTimeout: number = config.get('xpack.reporting.queue.timeout'); if (browserConfig.disableSandbox) { logger.warning(`Enabling the Chromium sandbox provides an additional layer of protection.`); @@ -34,13 +32,7 @@ export async function createBrowserDriverFactory( try { const { binaryPath } = await installBrowser(logger, chromium, dataDir); - return chromium.createDriverFactory( - binaryPath, - logger, - browserConfig, - reportingTimeout, - networkPolicy - ); + return chromium.createDriverFactory(binaryPath, logger, browserConfig, captureConfig); } catch (error) { if (error.cause && ['EACCES', 'EEXIST'].includes(error.cause.code)) { logger.error( diff --git a/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js b/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js index 4373597942278..113059fa2fa47 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js +++ b/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js @@ -226,8 +226,10 @@ export class Worker extends events.EventEmitter { docOutput.content = output.content; docOutput.content_type = output.content_type || unknownMime; docOutput.max_size_reached = output.max_size_reached; - docOutput.size = output.size; docOutput.csv_contains_formulas = output.csv_contains_formulas; + docOutput.size = output.size; + docOutput.warnings = + output.warnings && output.warnings.length > 0 ? output.warnings : undefined; } else { docOutput.content = output || defaultOutput; docOutput.content_type = unknownMime; @@ -248,7 +250,11 @@ export class Worker extends events.EventEmitter { Promise.resolve(this.workerFn.call(null, job, jobSource.payload, cancellationToken)) .then(res => { // job execution was successful - this.info(`Job execution completed successfully`); + if (res && res.warnings && res.warnings.length > 0) { + this.warn(`Job execution completed with warnings`); + } else { + this.info(`Job execution completed successfully`); + } isResolved = true; resolve(res); diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts index 6d9ae2153255f..883276d43e27e 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts @@ -10,7 +10,7 @@ import * as contexts from '../export_types/common/lib/screenshots/constants'; import { ElementsPositionAndAttribute } from '../export_types/common/lib/screenshots/types'; import { HeadlessChromiumDriver, HeadlessChromiumDriverFactory } from '../server/browsers'; import { createDriverFactory } from '../server/browsers/chromium'; -import { BrowserConfig, Logger, NetworkPolicy } from '../types'; +import { BrowserConfig, CaptureConfig, Logger } from '../types'; interface CreateMockBrowserDriverFactoryOpts { evaluate: jest.Mock<Promise<any>, any[]>; @@ -19,7 +19,7 @@ interface CreateMockBrowserDriverFactoryOpts { getCreatePage: (driver: HeadlessChromiumDriver) => jest.Mock<any, any>; } -export const mockSelectors = { +const mockSelectors = { renderComplete: 'renderedSelector', itemsCountAttribute: 'itemsSelector', screenshot: 'screenshotSelector', @@ -73,9 +73,6 @@ mockBrowserEvaluate.mockImplementation(() => { if (mockCall === contexts.CONTEXT_ELEMENTATTRIBUTES) { return Promise.resolve(getMockElementsPositionAndAttributes('Default Mock Title', 'Default ')); } - if (mockCall === contexts.CONTEXT_CHECKFORTOASTMESSAGE) { - return Promise.resolve('Toast Message'); - } throw new Error(mockCall); }); const mockScreenshot = jest.fn(); @@ -105,19 +102,20 @@ export const createMockBrowserDriverFactory = async ( } as BrowserConfig; const binaryPath = '/usr/local/share/common/secure/'; - const queueTimeout = 55; - const networkPolicy = {} as NetworkPolicy; + const captureConfig = { networkPolicy: {}, timeouts: {} } as CaptureConfig; const mockBrowserDriverFactory = await createDriverFactory( binaryPath, logger, browserConfig, - queueTimeout, - networkPolicy + captureConfig ); const mockPage = {} as Page; - const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, { inspect: true, networkPolicy }); + const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, { + inspect: true, + networkPolicy: captureConfig.networkPolicy, + }); // mock the driver methods as either default mocks or passed-in mockBrowserDriver.waitForSelector = opts.waitForSelector ? opts.waitForSelector : defaultOpts.waitForSelector; // prettier-ignore diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts index a2eb03c3fe300..0250e6c0a9afd 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts @@ -19,7 +19,6 @@ export const createMockLayoutInstance = (__LEGACY: ServerFacade) => { itemsCountAttribute: 'itemsSelector', screenshot: 'screenshotSelector', timefilterDurationAttribute: 'timefilterDurationSelector', - toastHeader: 'toastHeaderSelector', }; return mockLayout; }; diff --git a/x-pack/legacy/plugins/reporting/test_helpers/index.ts b/x-pack/legacy/plugins/reporting/test_helpers/index.ts index 91c348ba1db3d..491d390c370b9 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/index.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/index.ts @@ -6,5 +6,5 @@ export { createMockServer } from './create_mock_server'; export { createMockReportingCore } from './create_mock_reportingplugin'; -export { createMockBrowserDriverFactory, mockSelectors } from './create_mock_browserdriverfactory'; +export { createMockBrowserDriverFactory } from './create_mock_browserdriverfactory'; export { createMockLayoutInstance } from './create_mock_layoutinstance'; diff --git a/x-pack/legacy/plugins/reporting/types.d.ts b/x-pack/legacy/plugins/reporting/types.d.ts index 38406186c8173..b4d49fd21f230 100644 --- a/x-pack/legacy/plugins/reporting/types.d.ts +++ b/x-pack/legacy/plugins/reporting/types.d.ts @@ -122,6 +122,11 @@ export interface CaptureConfig { maxAttempts: number; networkPolicy: NetworkPolicy; loadDelay: number; + timeouts: { + openUrl: number; + waitForElements: number; + renderComplet: number; + }; } export interface BrowserConfig { @@ -219,8 +224,9 @@ export interface JobSource<JobParamsType> { export interface JobDocOutput { content_type: string; content: string | null; - max_size_reached: boolean; size: number; + max_size_reached?: boolean; + warnings?: string[]; } export interface ESQueueWorker { diff --git a/x-pack/legacy/plugins/siem/cypress/integration/navigation.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/navigation.spec.ts index 2c5a0e5eeea8a..bebd5f7d679cf 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/navigation.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/navigation.spec.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { HOSTS, NETWORK, OVERVIEW, TIMELINES } from '../screens/siem_header'; +import { DETECTIONS, HOSTS, NETWORK, OVERVIEW, TIMELINES } from '../screens/siem_header'; import { loginAndWaitForPage } from '../tasks/login'; import { navigateFromHeaderTo } from '../tasks/siem_header'; @@ -29,6 +29,11 @@ describe('top-level navigation common to all pages in the SIEM app', () => { cy.url().should('include', '/siem#/network'); }); + it('navigates to the Detections page', () => { + navigateFromHeaderTo(DETECTIONS); + cy.url().should('include', '/siem#/detections'); + }); + it('navigates to the Timelines page', () => { navigateFromHeaderTo(TIMELINES); cy.url().should('include', '/siem#/timelines'); diff --git a/x-pack/legacy/plugins/siem/cypress/screens/siem_header.ts b/x-pack/legacy/plugins/siem/cypress/screens/siem_header.ts index cf1059269393a..c2dab051793c1 100644 --- a/x-pack/legacy/plugins/siem/cypress/screens/siem_header.ts +++ b/x-pack/legacy/plugins/siem/cypress/screens/siem_header.ts @@ -6,6 +6,8 @@ export const BREADCRUMBS = '[data-test-subj="breadcrumbs"] a'; +export const DETECTIONS = '[data-test-subj="navigation-detections"]'; + export const HOSTS = '[data-test-subj="navigation-hosts"]'; export const KQL_INPUT = '[data-test-subj="queryInput"]'; diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx index 4b80b9fff2740..b7d368639ed92 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx @@ -30,6 +30,24 @@ DragEffects.displayName = 'DragEffects'; export const DraggablePortalContext = createContext<boolean>(false); export const useDraggablePortalContext = () => useContext(DraggablePortalContext); +/** + * Wraps the `react-beautiful-dnd` error boundary. See also: + * https://github.com/atlassian/react-beautiful-dnd/blob/v12.0.0/docs/guides/setup-problem-detection-and-error-recovery.md + * + * NOTE: This extends from `PureComponent` because, at the time of this + * writing, there's no hook equivalent for `componentDidCatch`, per + * https://reactjs.org/docs/hooks-faq.html#do-hooks-cover-all-use-cases-for-classes + */ +class DragDropErrorBoundary extends React.PureComponent { + componentDidCatch() { + this.forceUpdate(); // required for recovery + } + + render() { + return this.props.children; + } +} + const Wrapper = styled.div` display: inline-block; max-width: 100%; @@ -94,50 +112,52 @@ export const DraggableWrapper = React.memo<Props>( return ( <Wrapper data-test-subj="draggableWrapperDiv"> - <Droppable isDropDisabled={true} droppableId={getDroppableId(dataProvider.id)}> - {droppableProvided => ( - <div ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}> - <Draggable - draggableId={getDraggableId(dataProvider.id)} - index={0} - key={getDraggableId(dataProvider.id)} - > - {(provided, snapshot) => ( - <ConditionalPortal - isDragging={snapshot.isDragging} - registerProvider={registerProvider} - usePortal={snapshot.isDragging && usePortal} - > - <ProviderContainer - {...provided.draggableProps} - {...provided.dragHandleProps} - ref={provided.innerRef} - data-test-subj="providerContainer" + <DragDropErrorBoundary> + <Droppable isDropDisabled={true} droppableId={getDroppableId(dataProvider.id)}> + {droppableProvided => ( + <div ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}> + <Draggable + draggableId={getDraggableId(dataProvider.id)} + index={0} + key={getDraggableId(dataProvider.id)} + > + {(provided, snapshot) => ( + <ConditionalPortal isDragging={snapshot.isDragging} registerProvider={registerProvider} - style={{ - ...provided.draggableProps.style, - }} + usePortal={snapshot.isDragging && usePortal} > - {truncate && !snapshot.isDragging ? ( - <TruncatableText data-test-subj="draggable-truncatable-content"> - {render(dataProvider, provided, snapshot)} - </TruncatableText> - ) : ( - <ProviderContentWrapper - data-test-subj={`draggable-content-${dataProvider.queryMatch.field}`} - > - {render(dataProvider, provided, snapshot)} - </ProviderContentWrapper> - )} - </ProviderContainer> - </ConditionalPortal> - )} - </Draggable> - {droppableProvided.placeholder} - </div> - )} - </Droppable> + <ProviderContainer + {...provided.draggableProps} + {...provided.dragHandleProps} + ref={provided.innerRef} + data-test-subj="providerContainer" + isDragging={snapshot.isDragging} + registerProvider={registerProvider} + style={{ + ...provided.draggableProps.style, + }} + > + {truncate && !snapshot.isDragging ? ( + <TruncatableText data-test-subj="draggable-truncatable-content"> + {render(dataProvider, provided, snapshot)} + </TruncatableText> + ) : ( + <ProviderContentWrapper + data-test-subj={`draggable-content-${dataProvider.queryMatch.field}`} + > + {render(dataProvider, provided, snapshot)} + </ProviderContentWrapper> + )} + </ProviderContainer> + </ConditionalPortal> + )} + </Draggable> + {droppableProvided.placeholder} + </div> + )} + </Droppable> + </DragDropErrorBoundary> </Wrapper> ); }, diff --git a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/helpers.tsx b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/helpers.tsx index 1b003f1336406..e6afc86a7ee67 100644 --- a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/helpers.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { findIndex } from 'lodash/fp'; -import { EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; import { BrowserField, BrowserFields, getAllFieldsByName } from '../../containers/source'; import { @@ -16,7 +16,7 @@ import { import * as i18n from './translations'; /** The list of operators to display in the `Operator` select */ -export const operatorLabels: EuiComboBoxOptionProps[] = [ +export const operatorLabels: EuiComboBoxOptionOption[] = [ { label: i18n.IS, }, @@ -38,7 +38,7 @@ export const getFieldNames = (category: Partial<BrowserField>): string[] => : []; /** Returns all field names by category, for display in an `EuiComboBox` */ -export const getCategorizedFieldNames = (browserFields: BrowserFields): EuiComboBoxOptionProps[] => +export const getCategorizedFieldNames = (browserFields: BrowserFields): EuiComboBoxOptionOption[] => Object.keys(browserFields) .sort() .map(categoryId => ({ @@ -55,8 +55,8 @@ export const selectionsAreValid = ({ selectedOperator, }: { browserFields: BrowserFields; - selectedField: EuiComboBoxOptionProps[]; - selectedOperator: EuiComboBoxOptionProps[]; + selectedField: EuiComboBoxOptionOption[]; + selectedOperator: EuiComboBoxOptionOption[]; }): boolean => { const fieldId = selectedField.length > 0 ? selectedField[0].label : ''; const operator = selectedOperator.length > 0 ? selectedOperator[0].label : ''; @@ -69,7 +69,7 @@ export const selectionsAreValid = ({ /** Returns a `QueryOperator` based on the user's Operator selection */ export const getQueryOperatorFromSelection = ( - selectedOperator: EuiComboBoxOptionProps[] + selectedOperator: EuiComboBoxOptionOption[] ): QueryOperator => { const selection = selectedOperator.length > 0 ? selectedOperator[0].label : ''; @@ -88,7 +88,7 @@ export const getQueryOperatorFromSelection = ( /** * Returns `true` when the search excludes results that match the specified data provider */ -export const getExcludedFromSelection = (selectedOperator: EuiComboBoxOptionProps[]): boolean => { +export const getExcludedFromSelection = (selectedOperator: EuiComboBoxOptionOption[]): boolean => { const selection = selectedOperator.length > 0 ? selectedOperator[0].label : ''; switch (selection) { diff --git a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx index 87e83e0c47b6d..5ecc96187532d 100644 --- a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx @@ -8,7 +8,7 @@ import { noop } from 'lodash/fp'; import { EuiButton, EuiComboBox, - EuiComboBoxOptionProps, + EuiComboBoxOptionOption, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -64,7 +64,7 @@ const sanatizeValue = (value: string | number): string => export const getInitialOperatorLabel = ( isExcluded: boolean, operator: QueryOperator -): EuiComboBoxOptionProps[] => { +): EuiComboBoxOptionOption[] => { if (operator === ':') { return isExcluded ? [{ label: i18n.IS_NOT }] : [{ label: i18n.IS }]; } else { @@ -84,8 +84,8 @@ export const StatefulEditDataProvider = React.memo<Props>( timelineId, value, }) => { - const [updatedField, setUpdatedField] = useState<EuiComboBoxOptionProps[]>([{ label: field }]); - const [updatedOperator, setUpdatedOperator] = useState<EuiComboBoxOptionProps[]>( + const [updatedField, setUpdatedField] = useState<EuiComboBoxOptionOption[]>([{ label: field }]); + const [updatedOperator, setUpdatedOperator] = useState<EuiComboBoxOptionOption[]>( getInitialOperatorLabel(isExcluded, operator) ); const [updatedValue, setUpdatedValue] = useState<string | number>(value); @@ -105,13 +105,13 @@ export const StatefulEditDataProvider = React.memo<Props>( } }; - const onFieldSelected = useCallback((selectedField: EuiComboBoxOptionProps[]) => { + const onFieldSelected = useCallback((selectedField: EuiComboBoxOptionOption[]) => { setUpdatedField(selectedField); focusInput(); }, []); - const onOperatorSelected = useCallback((operatorSelected: EuiComboBoxOptionProps[]) => { + const onOperatorSelected = useCallback((operatorSelected: EuiComboBoxOptionOption[]) => { setUpdatedOperator(operatorSelected); focusInput(); diff --git a/x-pack/legacy/plugins/siem/public/components/filter_popover/index.tsx b/x-pack/legacy/plugins/siem/public/components/filter_popover/index.tsx new file mode 100644 index 0000000000000..1d269dffeccf5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/filter_popover/index.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Dispatch, SetStateAction, useCallback, useState } from 'react'; +import { + EuiFilterButton, + EuiFilterSelectItem, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiPopover, + EuiText, +} from '@elastic/eui'; +import styled from 'styled-components'; + +interface FilterPopoverProps { + buttonLabel: string; + onSelectedOptionsChanged: Dispatch<SetStateAction<string[]>>; + options: string[]; + optionsEmptyLabel: string; + selectedOptions: string[]; +} + +const ScrollableDiv = styled.div` + max-height: 250px; + overflow: auto; +`; + +export const toggleSelectedGroup = ( + group: string, + selectedGroups: string[], + setSelectedGroups: Dispatch<SetStateAction<string[]>> +): void => { + const selectedGroupIndex = selectedGroups.indexOf(group); + const updatedSelectedGroups = [...selectedGroups]; + if (selectedGroupIndex >= 0) { + updatedSelectedGroups.splice(selectedGroupIndex, 1); + } else { + updatedSelectedGroups.push(group); + } + return setSelectedGroups(updatedSelectedGroups); +}; + +/** + * Popover for selecting a field to filter on + * + * @param buttonLabel label on dropdwon button + * @param onSelectedOptionsChanged change listener to be notified when option selection changes + * @param options to display for filtering + * @param optionsEmptyLabel shows when options empty + * @param selectedOptions manage state of selectedOptions + */ +export const FilterPopoverComponent = ({ + buttonLabel, + onSelectedOptionsChanged, + options, + optionsEmptyLabel, + selectedOptions, +}: FilterPopoverProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const setIsPopoverOpenCb = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); + const toggleSelectedGroupCb = useCallback( + option => toggleSelectedGroup(option, selectedOptions, onSelectedOptionsChanged), + [selectedOptions, onSelectedOptionsChanged] + ); + + return ( + <EuiPopover + ownFocus + button={ + <EuiFilterButton + data-test-subj={`options-filter-popover-button-${buttonLabel}`} + iconType="arrowDown" + onClick={setIsPopoverOpenCb} + isSelected={isPopoverOpen} + numFilters={options.length} + hasActiveFilters={selectedOptions.length > 0} + numActiveFilters={selectedOptions.length} + > + {buttonLabel} + </EuiFilterButton> + } + isOpen={isPopoverOpen} + closePopover={setIsPopoverOpenCb} + panelPaddingSize="none" + > + <ScrollableDiv> + {options.map((option, index) => ( + <EuiFilterSelectItem + checked={selectedOptions.includes(option) ? 'on' : undefined} + key={`${index}-${option}`} + onClick={toggleSelectedGroupCb.bind(null, option)} + > + {option} + </EuiFilterSelectItem> + ))} + </ScrollableDiv> + {options.length === 0 && ( + <EuiFlexGroup gutterSize="m" justifyContent="spaceAround"> + <EuiFlexItem grow={true}> + <EuiPanel> + <EuiText>{optionsEmptyLabel}</EuiText> + </EuiPanel> + </EuiFlexItem> + </EuiFlexGroup> + )} + </EuiPopover> + ); +}; + +FilterPopoverComponent.displayName = 'FilterPopoverComponent'; + +export const FilterPopover = React.memo(FilterPopoverComponent); + +FilterPopover.displayName = 'FilterPopover'; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts index 507d6cf98ed08..d4f38d817bd6b 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts @@ -5,7 +5,7 @@ */ import { isAnError, isToasterError, errorToToaster } from './error_to_toaster'; -import { ToasterErrors } from './throw_if_not_ok'; +import { ToasterErrors } from '../../../hooks/api/throw_if_not_ok'; describe('error_to_toaster', () => { let dispatchToaster = jest.fn(); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts index 779befaa0cd8e..b341016fff6ef 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts @@ -7,7 +7,7 @@ import { isError } from 'lodash/fp'; import uuid from 'uuid'; import { ActionToaster, AppToast } from '../../toasters'; -import { ToasterErrorsType, ToasterErrors } from './throw_if_not_ok'; +import { ToasterErrorsType, ToasterErrors } from '../../../hooks/api/throw_if_not_ok'; export type ErrorToToasterArgs = Partial<AppToast> & { error: unknown; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx index 120fd8c404ffd..1ab996f88515b 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx @@ -17,7 +17,7 @@ import { StartDatafeedResponse, StopDatafeedResponse, } from './types'; -import { throwIfErrorAttached, throwIfErrorAttachedToSetup } from '../ml/api/throw_if_not_ok'; +import { throwIfErrorAttached, throwIfErrorAttachedToSetup } from '../../hooks/api/throw_if_not_ok'; import { throwIfNotOk } from '../../hooks/api/api'; import { KibanaServices } from '../../lib/kibana'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx index b8280aedd12fa..be83a4f7b33a7 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx @@ -16,8 +16,8 @@ import { EuiFilterButton, EuiFilterGroup, EuiPortal, + EuiSelectableOption, } from '@elastic/eui'; -import { Option } from '@elastic/eui/src/components/selectable/types'; import { isEmpty } from 'lodash/fp'; import React, { memo, useCallback, useMemo, useState } from 'react'; import { ListProps } from 'react-virtualized'; @@ -91,10 +91,10 @@ const getBasicSelectableOptions = (timelineId: string) => [ description: i18n.DEFAULT_TIMELINE_DESCRIPTION, favorite: [], label: i18n.DEFAULT_TIMELINE_TITLE, - id: null, + id: undefined, title: i18n.DEFAULT_TIMELINE_TITLE, checked: timelineId === '-1' ? 'on' : undefined, - } as Option, + } as EuiSelectableOption, ]; const ORIGINAL_PAGE_SIZE = 50; @@ -326,7 +326,7 @@ const SearchTimelineSuperSelectComponent: React.FC<SearchTimelineSuperSelectProp key: `${t.title}-${index}`, title: t.title, checked: t.savedObjectId === timelineId ? 'on' : undefined, - } as Option) + } as EuiSelectableOption) ), ]} > diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/__snapshots__/utility_bar.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/utility_bar/__snapshots__/utility_bar.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/__snapshots__/utility_bar.test.tsx.snap rename to x-pack/legacy/plugins/siem/public/components/utility_bar/__snapshots__/utility_bar.test.tsx.snap diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/__snapshots__/utility_bar_action.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/utility_bar/__snapshots__/utility_bar_action.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/__snapshots__/utility_bar_action.test.tsx.snap rename to x-pack/legacy/plugins/siem/public/components/utility_bar/__snapshots__/utility_bar_action.test.tsx.snap diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/__snapshots__/utility_bar_group.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/utility_bar/__snapshots__/utility_bar_group.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/__snapshots__/utility_bar_group.test.tsx.snap rename to x-pack/legacy/plugins/siem/public/components/utility_bar/__snapshots__/utility_bar_group.test.tsx.snap diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/__snapshots__/utility_bar_section.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/utility_bar/__snapshots__/utility_bar_section.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/__snapshots__/utility_bar_section.test.tsx.snap rename to x-pack/legacy/plugins/siem/public/components/utility_bar/__snapshots__/utility_bar_section.test.tsx.snap diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/__snapshots__/utility_bar_text.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/utility_bar/__snapshots__/utility_bar_text.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/__snapshots__/utility_bar_text.test.tsx.snap rename to x-pack/legacy/plugins/siem/public/components/utility_bar/__snapshots__/utility_bar_text.test.tsx.snap diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/index.ts b/x-pack/legacy/plugins/siem/public/components/utility_bar/index.ts similarity index 100% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/index.ts rename to x-pack/legacy/plugins/siem/public/components/utility_bar/index.ts diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/styles.tsx b/x-pack/legacy/plugins/siem/public/components/utility_bar/styles.tsx similarity index 100% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/styles.tsx rename to x-pack/legacy/plugins/siem/public/components/utility_bar/styles.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar.test.tsx b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar.test.tsx similarity index 98% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar.test.tsx rename to x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar.test.tsx index eae0fc4ff422b..5fd010362be10 100644 --- a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar.test.tsx @@ -8,7 +8,7 @@ import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount, shallow } from 'enzyme'; import React from 'react'; -import { TestProviders } from '../../../mock'; +import { TestProviders } from '../../mock'; import { UtilityBar, UtilityBarAction, diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar.tsx b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar.tsx similarity index 100% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar.tsx rename to x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_action.test.tsx b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_action.test.tsx similarity index 95% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_action.test.tsx rename to x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_action.test.tsx index 2a8a71955a986..09c62773fddd1 100644 --- a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_action.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_action.test.tsx @@ -7,7 +7,7 @@ import { mount, shallow } from 'enzyme'; import React from 'react'; -import { TestProviders } from '../../../mock'; +import { TestProviders } from '../../mock'; import { UtilityBarAction } from './index'; describe('UtilityBarAction', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_action.tsx b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_action.tsx similarity index 97% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_action.tsx rename to x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_action.tsx index 4e850a0a11957..d3e2be0e8f816 100644 --- a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_action.tsx +++ b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_action.tsx @@ -7,7 +7,7 @@ import { EuiPopover } from '@elastic/eui'; import React, { useCallback, useState } from 'react'; -import { LinkIcon, LinkIconProps } from '../../link_icon'; +import { LinkIcon, LinkIconProps } from '../link_icon'; import { BarAction } from './styles'; const Popover = React.memo<UtilityBarActionProps>( diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_group.test.tsx b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_group.test.tsx similarity index 93% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_group.test.tsx rename to x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_group.test.tsx index e18e7d5e0b524..8e184e5aaec30 100644 --- a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_group.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_group.test.tsx @@ -7,7 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { TestProviders } from '../../../mock'; +import { TestProviders } from '../../mock'; import { UtilityBarGroup, UtilityBarText } from './index'; describe('UtilityBarGroup', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_group.tsx b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_group.tsx similarity index 100% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_group.tsx rename to x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_group.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_section.test.tsx b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_section.test.tsx similarity index 94% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_section.test.tsx rename to x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_section.test.tsx index f849fa4b4ee46..c6037c75670eb 100644 --- a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_section.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_section.test.tsx @@ -7,7 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { TestProviders } from '../../../mock'; +import { TestProviders } from '../../mock'; import { UtilityBarGroup, UtilityBarSection, UtilityBarText } from './index'; describe('UtilityBarSection', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_section.tsx b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_section.tsx similarity index 100% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_section.tsx rename to x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_section.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_text.test.tsx b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_text.test.tsx similarity index 92% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_text.test.tsx rename to x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_text.test.tsx index 230dd80b1a86b..fcfc2b6b0cefa 100644 --- a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_text.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_text.test.tsx @@ -7,7 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { TestProviders } from '../../../mock'; +import { TestProviders } from '../../mock'; import { UtilityBarText } from './index'; describe('UtilityBarText', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_text.tsx b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_text.tsx similarity index 100% rename from x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_text.tsx rename to x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_text.tsx diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index f1d87ca58b44b..81f8f83217e11 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -4,24 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaServices } from '../../lib/kibana'; import { - AllCases, - Case, - CaseSnake, - Comment, - CommentSnake, - FetchCasesProps, - NewCase, - NewComment, - SortFieldCase, -} from './types'; + CaseResponse, + CasesResponse, + CaseRequest, + CommentRequest, + CommentResponse, +} from '../../../../../../plugins/case/common/api'; +import { KibanaServices } from '../../lib/kibana'; +import { AllCases, Case, Comment, FetchCasesProps, SortFieldCase } from './types'; import { throwIfNotOk } from '../../hooks/api/api'; import { CASES_URL } from './constants'; -import { convertToCamelCase, convertAllCasesToCamel } from './utils'; +import { + convertToCamelCase, + convertAllCasesToCamel, + decodeCaseResponse, + decodeCasesResponse, + decodeCommentResponse, +} from './utils'; + +const CaseSavedObjectType = 'cases'; export const getCase = async (caseId: string, includeComments: boolean = true): Promise<Case> => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { + const response = await KibanaServices.get().http.fetch<CaseResponse>(`${CASES_URL}/${caseId}`, { method: 'GET', asResponse: true, query: { @@ -29,12 +34,22 @@ export const getCase = async (caseId: string, includeComments: boolean = true): }, }); await throwIfNotOk(response.response); - return convertToCamelCase<CaseSnake, Case>(response.body!); + return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response.body)); +}; + +export const getTags = async (): Promise<string[]> => { + const response = await KibanaServices.get().http.fetch<string[]>(`${CASES_URL}/tags`, { + method: 'GET', + asResponse: true, + }); + await throwIfNotOk(response.response); + return response.body ?? []; }; export const getCases = async ({ filterOptions = { search: '', + state: 'open', tags: [], }, queryParams = { @@ -44,65 +59,74 @@ export const getCases = async ({ sortOrder: 'desc', }, }: FetchCasesProps): Promise<AllCases> => { - const tags = [...(filterOptions.tags?.map(t => `case-workflow.attributes.tags: ${t}`) ?? [])]; + const stateFilter = `${CaseSavedObjectType}.attributes.state: ${filterOptions.state}`; + const tags = [ + ...(filterOptions.tags?.reduce( + (acc, t) => [...acc, `${CaseSavedObjectType}.attributes.tags: ${t}`], + [stateFilter] + ) ?? [stateFilter]), + ]; const query = { ...queryParams, - filter: tags.join(' AND '), - search: filterOptions.search, + ...(tags.length > 0 ? { filter: tags.join(' AND ') } : {}), + ...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}), }; - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + const response = await KibanaServices.get().http.fetch<CasesResponse>(`${CASES_URL}/_find`, { method: 'GET', query, asResponse: true, }); await throwIfNotOk(response.response); - return convertAllCasesToCamel(response.body!); + return convertAllCasesToCamel(decodeCasesResponse(response.body)); }; -export const createCase = async (newCase: NewCase): Promise<Case> => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { +export const postCase = async (newCase: CaseRequest): Promise<Case> => { + const response = await KibanaServices.get().http.fetch<CaseResponse>(`${CASES_URL}`, { method: 'POST', asResponse: true, body: JSON.stringify(newCase), }); await throwIfNotOk(response.response); - return convertToCamelCase<CaseSnake, Case>(response.body!); + return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response.body)); }; -export const updateCaseProperty = async ( +export const patchCase = async ( caseId: string, - updatedCase: Partial<Case>, + updatedCase: Partial<CaseRequest>, version: string -): Promise<Partial<Case>> => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { +): Promise<Case> => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { method: 'PATCH', asResponse: true, - body: JSON.stringify({ case: updatedCase, version }), + body: JSON.stringify({ ...updatedCase, id: caseId, version }), }); await throwIfNotOk(response.response); - return convertToCamelCase<Partial<CaseSnake>, Partial<Case>>(response.body!); + return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response.body)); }; -export const createComment = async (newComment: NewComment, caseId: string): Promise<Comment> => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}/comment`, { - method: 'POST', - asResponse: true, - body: JSON.stringify(newComment), - }); +export const postComment = async (newComment: CommentRequest, caseId: string): Promise<Comment> => { + const response = await KibanaServices.get().http.fetch<CommentResponse>( + `${CASES_URL}/${caseId}/comments`, + { + method: 'POST', + asResponse: true, + body: JSON.stringify(newComment), + } + ); await throwIfNotOk(response.response); - return convertToCamelCase<CommentSnake, Comment>(response.body!); + return convertToCamelCase<CommentResponse, Comment>(decodeCommentResponse(response.body)); }; -export const updateComment = async ( +export const patchComment = async ( commentId: string, commentUpdate: string, version: string ): Promise<Partial<Comment>> => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}/comment/${commentId}`, { + const response = await KibanaServices.get().http.fetch<CommentResponse>(`${CASES_URL}/comments`, { method: 'PATCH', asResponse: true, - body: JSON.stringify({ comment: commentUpdate, version }), + body: JSON.stringify({ comment: commentUpdate, id: commentId, version }), }); await throwIfNotOk(response.response); - return convertToCamelCase<Partial<CommentSnake>, Partial<Comment>>(response.body!); + return convertToCamelCase<CommentResponse, Comment>(decodeCommentResponse(response.body)); }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/constants.ts b/x-pack/legacy/plugins/siem/public/containers/case/constants.ts index 031ba1c128a24..ac62ba7b6f997 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/constants.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/constants.ts @@ -13,4 +13,5 @@ export const FETCH_SUCCESS = 'FETCH_SUCCESS'; export const POST_NEW_CASE = 'POST_NEW_CASE'; export const POST_NEW_COMMENT = 'POST_NEW_COMMENT'; export const UPDATE_FILTER_OPTIONS = 'UPDATE_FILTER_OPTIONS'; +export const UPDATE_TABLE_SELECTIONS = 'UPDATE_TABLE_SELECTIONS'; export const UPDATE_QUERY_PARAMS = 'UPDATE_QUERY_PARAMS'; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 75ed6f7c2366d..d479abdbd4489 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -4,31 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -interface FormData { - isNew?: boolean; -} - -export interface NewCase extends FormData { - description: string; - tags: string[]; - title: string; -} - -export interface NewComment extends FormData { - comment: string; -} - -export interface CommentSnake { - comment_id: string; - created_at: string; - created_by: ElasticUserSnake; - comment: string; - updated_at: string; - version: string; -} - export interface Comment { - commentId: string; + id: string; createdAt: string; createdBy: ElasticUser; comment: string; @@ -36,21 +13,8 @@ export interface Comment { version: string; } -export interface CaseSnake { - case_id: string; - comments: CommentSnake[]; - created_at: string; - created_by: ElasticUserSnake; - description: string; - state: string; - tags: string[]; - title: string; - updated_at: string; - version: string; -} - export interface Case { - caseId: string; + id: string; comments: Comment[]; createdAt: string; createdBy: ElasticUser; @@ -71,33 +35,22 @@ export interface QueryParams { export interface FilterOptions { search: string; + state: string; tags: string[]; } -export interface AllCasesSnake { - cases: CaseSnake[]; - page: number; - per_page: number; - total: number; -} - export interface AllCases { cases: Case[]; page: number; perPage: number; total: number; } + export enum SortFieldCase { createdAt = 'createdAt', - state = 'state', updatedAt = 'updatedAt', } -export interface ElasticUserSnake { - readonly username: string; - readonly full_name?: string | null; -} - export interface ElasticUser { readonly username: string; readonly fullName?: string | null; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index ce71c26078db9..5f1dc96735d32 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -50,7 +50,7 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { } }; const initialData: Case = { - caseId: '', + id: '', createdAt: '', comments: [], createdBy: { @@ -83,7 +83,11 @@ export const useGetCase = (caseId: string): [CaseState] => { } } catch (error) { if (!didCancel) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); dispatch({ type: FETCH_FAILURE }); } } diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx index 4037823ccfc94..76e9b5c138269 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx @@ -4,58 +4,86 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react'; -import { isEqual } from 'lodash/fp'; -import { - DEFAULT_TABLE_ACTIVE_PAGE, - DEFAULT_TABLE_LIMIT, - FETCH_FAILURE, - FETCH_INIT, - FETCH_SUCCESS, - UPDATE_QUERY_PARAMS, - UPDATE_FILTER_OPTIONS, -} from './constants'; -import { AllCases, SortFieldCase, FilterOptions, QueryParams } from './types'; -import { getTypedPayload } from './utils'; +import { useCallback, useEffect, useReducer } from 'react'; +import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants'; +import { AllCases, SortFieldCase, FilterOptions, QueryParams, Case } from './types'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; import { useStateToaster } from '../../components/toasters'; import * as i18n from './translations'; -import { getCases } from './api'; +import { UpdateByKey } from './use_update_case'; +import { getCases, patchCase } from './api'; export interface UseGetCasesState { + caseCount: CaseCount; data: AllCases; - isLoading: boolean; + filterOptions: FilterOptions; isError: boolean; + loading: string[]; queryParams: QueryParams; - filterOptions: FilterOptions; + selectedCases: Case[]; +} + +export interface CaseCount { + open: number; + closed: number; } -export interface Action { - type: string; - payload?: AllCases | Partial<QueryParams> | FilterOptions; +export interface UpdateCase extends UpdateByKey { + caseId: string; + version: string; } + +export type Action = + | { type: 'FETCH_INIT'; payload: string } + | { type: 'FETCH_CASE_COUNT_SUCCESS'; payload: Partial<CaseCount> } + | { type: 'FETCH_CASES_SUCCESS'; payload: AllCases } + | { type: 'FETCH_FAILURE'; payload: string } + | { type: 'FETCH_UPDATE_CASE_SUCCESS' } + | { type: 'UPDATE_FILTER_OPTIONS'; payload: FilterOptions } + | { type: 'UPDATE_QUERY_PARAMS'; payload: Partial<QueryParams> } + | { type: 'UPDATE_TABLE_SELECTIONS'; payload: Case[] }; + const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesState => { switch (action.type) { - case FETCH_INIT: + case 'FETCH_INIT': return { ...state, - isLoading: true, isError: false, + loading: [...state.loading.filter(e => e !== action.payload), action.payload], + }; + case 'FETCH_UPDATE_CASE_SUCCESS': + return { + ...state, + loading: state.loading.filter(e => e !== 'caseUpdate'), + }; + case 'FETCH_CASE_COUNT_SUCCESS': + return { + ...state, + caseCount: { + ...state.caseCount, + ...action.payload, + }, + loading: state.loading.filter(e => e !== 'caseCount'), }; - case FETCH_SUCCESS: + case 'FETCH_CASES_SUCCESS': return { ...state, - isLoading: false, isError: false, - data: getTypedPayload<AllCases>(action.payload), + data: action.payload, + loading: state.loading.filter(e => e !== 'cases'), }; - case FETCH_FAILURE: + case 'FETCH_FAILURE': return { ...state, - isLoading: false, isError: true, + loading: state.loading.filter(e => e !== action.payload), + }; + case 'UPDATE_FILTER_OPTIONS': + return { + ...state, + filterOptions: action.payload, }; - case UPDATE_QUERY_PARAMS: + case 'UPDATE_QUERY_PARAMS': return { ...state, queryParams: { @@ -63,10 +91,10 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS ...action.payload, }, }; - case UPDATE_FILTER_OPTIONS: + case 'UPDATE_TABLE_SELECTIONS': return { ...state, - filterOptions: getTypedPayload<FilterOptions>(action.payload), + selectedCases: action.payload, }; default: throw new Error(); @@ -74,66 +102,109 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS }; const initialData: AllCases = { + cases: [], page: 0, perPage: 0, total: 0, - cases: [], }; -export const useGetCases = (): [ - UseGetCasesState, - Dispatch<SetStateAction<Partial<QueryParams>>>, - Dispatch<SetStateAction<FilterOptions>> -] => { +interface UseGetCases extends UseGetCasesState { + dispatchUpdateCaseProperty: ({ updateKey, updateValue, caseId, version }: UpdateCase) => void; + getCaseCount: (caseState: keyof CaseCount) => void; + setFilters: (filters: FilterOptions) => void; + setQueryParams: (queryParams: QueryParams) => void; + setSelectedCases: (mySelectedCases: Case[]) => void; +} +export const useGetCases = (): UseGetCases => { const [state, dispatch] = useReducer(dataFetchReducer, { - isLoading: false, - isError: false, + caseCount: { + open: 0, + closed: 0, + }, data: initialData, filterOptions: { search: '', + state: 'open', tags: [], }, + isError: false, + loading: [], queryParams: { page: DEFAULT_TABLE_ACTIVE_PAGE, perPage: DEFAULT_TABLE_LIMIT, sortField: SortFieldCase.createdAt, sortOrder: 'desc', }, + selectedCases: [], }); - const [queryParams, setQueryParams] = useState<Partial<QueryParams>>(state.queryParams); - const [filterQuery, setFilters] = useState<FilterOptions>(state.filterOptions); const [, dispatchToaster] = useStateToaster(); - useEffect(() => { - if (!isEqual(queryParams, state.queryParams)) { - dispatch({ type: UPDATE_QUERY_PARAMS, payload: queryParams }); - } - }, [queryParams, state.queryParams]); + const setSelectedCases = useCallback((mySelectedCases: Case[]) => { + dispatch({ type: 'UPDATE_TABLE_SELECTIONS', payload: mySelectedCases }); + }, []); - useEffect(() => { - if (!isEqual(filterQuery, state.filterOptions)) { - dispatch({ type: UPDATE_FILTER_OPTIONS, payload: filterQuery }); - } - }, [filterQuery, state.filterOptions]); + const setQueryParams = useCallback((newQueryParams: QueryParams) => { + dispatch({ type: 'UPDATE_QUERY_PARAMS', payload: newQueryParams }); + }, []); - useEffect(() => { + const setFilters = useCallback((newFilters: FilterOptions) => { + dispatch({ type: 'UPDATE_FILTER_OPTIONS', payload: newFilters }); + }, []); + + const fetchCases = useCallback((filterOptions: FilterOptions, queryParams: QueryParams) => { let didCancel = false; const fetchData = async () => { - dispatch({ type: FETCH_INIT }); + dispatch({ type: 'FETCH_INIT', payload: 'cases' }); try { const response = await getCases({ - filterOptions: state.filterOptions, - queryParams: state.queryParams, + filterOptions, + queryParams, }); if (!didCancel) { dispatch({ - type: FETCH_SUCCESS, + type: 'FETCH_CASES_SUCCESS', payload: response, }); } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + dispatch({ type: 'FETCH_FAILURE', payload: 'cases' }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + }; + }, []); + + useEffect(() => fetchCases(state.filterOptions, state.queryParams), [ + state.queryParams, + state.filterOptions, + ]); + + const getCaseCount = useCallback((caseState: keyof CaseCount) => { + let didCancel = false; + const fetchData = async () => { + dispatch({ type: 'FETCH_INIT', payload: 'caseCount' }); + try { + const response = await getCases({ + filterOptions: { search: '', state: caseState, tags: [] }, + }); + if (!didCancel) { + dispatch({ + type: 'FETCH_CASE_COUNT_SUCCESS', + payload: { [caseState]: response.total }, + }); + } } catch (error) { if (!didCancel) { errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); - dispatch({ type: FETCH_FAILURE }); + dispatch({ type: 'FETCH_FAILURE', payload: 'caseCount' }); } } }; @@ -141,6 +212,46 @@ export const useGetCases = (): [ return () => { didCancel = true; }; - }, [state.queryParams, state.filterOptions]); - return [state, setQueryParams, setFilters]; + }, []); + + const dispatchUpdateCaseProperty = useCallback( + ({ updateKey, updateValue, caseId, version }: UpdateCase) => { + let didCancel = false; + const fetchData = async () => { + dispatch({ type: 'FETCH_INIT', payload: 'caseUpdate' }); + try { + await patchCase( + caseId, + { [updateKey]: updateValue }, + version ?? '' // saved object versions are typed as string | undefined, hope that's not true + ); + if (!didCancel) { + dispatch({ type: 'FETCH_UPDATE_CASE_SUCCESS' }); + fetchCases(state.filterOptions, state.queryParams); + getCaseCount('open'); + getCaseCount('closed'); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: 'FETCH_FAILURE', payload: 'caseUpdate' }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + }; + }, + [state.filterOptions, state.queryParams] + ); + + return { + ...state, + dispatchUpdateCaseProperty, + getCaseCount, + setFilters, + setQueryParams, + setSelectedCases, + }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx index f796ae550c9ec..7d3e00a4f2be4 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx @@ -5,12 +5,12 @@ */ import { useEffect, useReducer } from 'react'; -import chrome from 'ui/chrome'; import { useStateToaster } from '../../components/toasters'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; -import * as i18n from './translations'; + +import { getTags } from './api'; import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; -import { throwIfNotOk } from '../../hooks/api/api'; +import * as i18n from './translations'; interface TagsState { data: string[]; @@ -63,22 +63,17 @@ export const useGetTags = (): [TagsState] => { const fetchData = async () => { dispatch({ type: FETCH_INIT }); try { - const response = await fetch(`${chrome.getBasePath()}/api/cases/tags`, { - method: 'GET', - credentials: 'same-origin', - headers: { - 'content-type': 'application/json', - 'kbn-system-api': 'true', - }, - }); + const response = await getTags(); if (!didCancel) { - await throwIfNotOk(response); - const responseJson = await response.json(); - dispatch({ type: FETCH_SUCCESS, payload: responseJson }); + dispatch({ type: FETCH_SUCCESS, payload: response }); } } catch (error) { if (!didCancel) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); dispatch({ type: FETCH_FAILURE }); } } diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx index 0fcc8a3a1abec..7497b30395155 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx @@ -4,24 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react'; +import { useReducer, useCallback } from 'react'; + +import { CaseRequest } from '../../../../../../plugins/case/common/api'; import { useStateToaster } from '../../components/toasters'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; + +import { postCase } from './api'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; import * as i18n from './translations'; -import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS, POST_NEW_CASE } from './constants'; -import { Case, NewCase } from './types'; -import { createCase } from './api'; -import { getTypedPayload } from './utils'; +import { Case } from './types'; interface NewCaseState { - data: NewCase; - newCase?: Case; + caseData: Case | null; isLoading: boolean; isError: boolean; } interface Action { type: string; - payload?: NewCase | Case; + payload?: Case; } const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => { @@ -32,19 +33,12 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => isLoading: true, isError: false, }; - case POST_NEW_CASE: - return { - ...state, - isLoading: false, - isError: false, - data: getTypedPayload<NewCase>(action.payload), - }; case FETCH_SUCCESS: return { ...state, isLoading: false, isError: false, - newCase: getTypedPayload<Case>(action.payload), + caseData: action.payload ?? null, }; case FETCH_FAILURE: return { @@ -56,41 +50,43 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => throw new Error(); } }; -const initialData: NewCase = { - description: '', - isNew: false, - tags: [], - title: '', -}; -export const usePostCase = (): [NewCaseState, Dispatch<SetStateAction<NewCase>>] => { +interface UsePostCase extends NewCaseState { + postCase: (data: CaseRequest) => void; +} +export const usePostCase = (): UsePostCase => { const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, - data: initialData, + caseData: null, }); - const [formData, setFormData] = useState(initialData); const [, dispatchToaster] = useStateToaster(); - useEffect(() => { - dispatch({ type: POST_NEW_CASE, payload: formData }); - }, [formData]); - - useEffect(() => { - const postCase = async () => { + const postMyCase = useCallback(async (data: CaseRequest) => { + let cancel = false; + try { dispatch({ type: FETCH_INIT }); - try { - const { isNew, ...dataWithoutIsNew } = state.data; - const response = await createCase(dataWithoutIsNew); - dispatch({ type: FETCH_SUCCESS, payload: response }); - } catch (error) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + const response = await postCase({ ...data, state: 'open' }); + if (!cancel) { + dispatch({ + type: FETCH_SUCCESS, + payload: response, + }); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); dispatch({ type: FETCH_FAILURE }); } - }; - if (state.data.isNew) { - postCase(); } - }, [state.data.isNew]); - return [state, setFormData]; + return () => { + cancel = true; + }; + }, []); + + return { ...state, postCase: postMyCase }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx index d8abda25af286..63d24e2935c2a 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx @@ -4,25 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react'; +import { useReducer, useCallback } from 'react'; + +import { CommentRequest } from '../../../../../../plugins/case/common/api'; import { useStateToaster } from '../../components/toasters'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; + +import { postComment } from './api'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; import * as i18n from './translations'; -import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS, POST_NEW_COMMENT } from './constants'; -import { Comment, NewComment } from './types'; -import { createComment } from './api'; -import { getTypedPayload } from './utils'; +import { Comment } from './types'; interface NewCommentState { - data: NewComment; - newComment?: Comment; + commentData: Comment | null; isLoading: boolean; isError: boolean; caseId: string; } interface Action { type: string; - payload?: NewComment | Comment; + payload?: Comment; } const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentState => { @@ -33,19 +34,12 @@ const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentSta isLoading: true, isError: false, }; - case POST_NEW_COMMENT: - return { - ...state, - isLoading: false, - isError: false, - data: getTypedPayload<NewComment>(action.payload), - }; case FETCH_SUCCESS: return { ...state, isLoading: false, isError: false, - newComment: getTypedPayload<Comment>(action.payload), + commentData: action.payload ?? null, }; case FETCH_FAILURE: return { @@ -57,41 +51,42 @@ const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentSta throw new Error(); } }; -const initialData: NewComment = { - comment: '', -}; -export const usePostComment = ( - caseId: string -): [NewCommentState, Dispatch<SetStateAction<NewComment>>] => { +interface UsePostComment extends NewCommentState { + postComment: (data: CommentRequest) => void; +} + +export const usePostComment = (caseId: string): UsePostComment => { const [state, dispatch] = useReducer(dataFetchReducer, { + commentData: null, isLoading: false, isError: false, caseId, - data: initialData, }); - const [formData, setFormData] = useState(initialData); const [, dispatchToaster] = useStateToaster(); - useEffect(() => { - dispatch({ type: POST_NEW_COMMENT, payload: formData }); - }, [formData]); - - useEffect(() => { - const postComment = async () => { + const postMyComment = useCallback(async (data: CommentRequest) => { + let cancel = false; + try { dispatch({ type: FETCH_INIT }); - try { - const { isNew, ...dataWithoutIsNew } = state.data; - const response = await createComment(dataWithoutIsNew, state.caseId); + const response = await postComment(data, state.caseId); + if (!cancel) { dispatch({ type: FETCH_SUCCESS, payload: response }); - } catch (error) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); dispatch({ type: FETCH_FAILURE }); } - }; - if (state.data.isNew) { - postComment(); } - }, [state.data.isNew]); - return [state, setFormData]; + return () => { + cancel = true; + }; + }, []); + + return { ...state, postComment: postMyComment }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index ebbb1e14dc237..21c8fb5dc7032 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -4,32 +4,35 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useReducer } from 'react'; +import { useReducer, useCallback } from 'react'; + +import { CaseRequest } from '../../../../../../plugins/case/common/api'; import { useStateToaster } from '../../components/toasters'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; -import * as i18n from './translations'; + +import { patchCase } from './api'; import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; +import * as i18n from './translations'; import { Case } from './types'; -import { updateCaseProperty } from './api'; import { getTypedPayload } from './utils'; -type UpdateKey = keyof Case; +type UpdateKey = keyof CaseRequest; interface NewCaseState { - data: Case; + caseData: Case; isLoading: boolean; isError: boolean; updateKey: UpdateKey | null; } -interface UpdateByKey { +export interface UpdateByKey { updateKey: UpdateKey; - updateValue: Case[UpdateKey]; + updateValue: CaseRequest[UpdateKey]; } interface Action { type: string; - payload?: Partial<Case> | UpdateKey; + payload?: Case | UpdateKey; } const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => { @@ -47,10 +50,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => ...state, isLoading: false, isError: false, - data: { - ...state.data, - ...getTypedPayload<Case>(action.payload), - }, + caseData: getTypedPayload<Case>(action.payload), updateKey: null, }; case FETCH_FAILURE: @@ -65,32 +65,47 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => } }; -export const useUpdateCase = ( - caseId: string, - initialData: Case -): [NewCaseState, (updates: UpdateByKey) => void] => { +interface UseUpdateCase extends NewCaseState { + updateCaseProperty: (updates: UpdateByKey) => void; +} +export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase => { const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, - data: initialData, + caseData: initialData, updateKey: null, }); const [, dispatchToaster] = useStateToaster(); - const dispatchUpdateCaseProperty = async ({ updateKey, updateValue }: UpdateByKey) => { - dispatch({ type: FETCH_INIT, payload: updateKey }); - try { - const response = await updateCaseProperty( - caseId, - { [updateKey]: updateValue }, - state.data.version ?? '' // saved object versions are typed as string | undefined, hope that's not true - ); - dispatch({ type: FETCH_SUCCESS, payload: response }); - } catch (error) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); - dispatch({ type: FETCH_FAILURE }); - } - }; + const dispatchUpdateCaseProperty = useCallback( + async ({ updateKey, updateValue }: UpdateByKey) => { + let cancel = false; + try { + dispatch({ type: FETCH_INIT, payload: updateKey }); + const response = await patchCase( + caseId, + { [updateKey]: updateValue }, + state.caseData.version + ); + if (!cancel) { + dispatch({ type: FETCH_SUCCESS, payload: response }); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + dispatch({ type: FETCH_FAILURE }); + } + } + return () => { + cancel = true; + }; + }, + [state] + ); - return [state, dispatchUpdateCaseProperty]; + return { ...state, updateCaseProperty: dispatchUpdateCaseProperty }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx index bc8369117433a..d7649cb7d8fdb 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx @@ -4,17 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useReducer, useRef } from 'react'; +import { useReducer, useCallback } from 'react'; + import { useStateToaster } from '../../components/toasters'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; -import * as i18n from './translations'; + +import { patchComment } from './api'; import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; +import * as i18n from './translations'; import { Comment } from './types'; -import { updateComment } from './api'; import { getTypedPayload } from './utils'; -interface CommetUpdateState { - data: Comment[]; +interface CommentUpdateState { + comments: Comment[]; isLoadingIds: string[]; isError: boolean; } @@ -29,7 +31,7 @@ interface Action { payload?: CommentUpdate | string; } -const dataFetchReducer = (state: CommetUpdateState, action: Action): CommetUpdateState => { +const dataFetchReducer = (state: CommentUpdateState, action: Action): CommentUpdateState => { switch (action.type) { case FETCH_INIT: return { @@ -40,15 +42,19 @@ const dataFetchReducer = (state: CommetUpdateState, action: Action): CommetUpdat case FETCH_SUCCESS: const updatePayload = getTypedPayload<CommentUpdate>(action.payload); - const foundIndex = state.data.findIndex( - comment => comment.commentId === updatePayload.commentId + const foundIndex = state.comments.findIndex( + comment => comment.id === updatePayload.commentId ); - state.data[foundIndex] = { ...state.data[foundIndex], ...updatePayload.update }; + const newComments = state.comments; + if (foundIndex !== -1) { + newComments[foundIndex] = { ...state.comments[foundIndex], ...updatePayload.update }; + } + return { ...state, isLoadingIds: state.isLoadingIds.filter(id => updatePayload.commentId !== id), isError: false, - data: [...state.data], + comments: newComments, }; case FETCH_FAILURE: return { @@ -63,30 +69,46 @@ const dataFetchReducer = (state: CommetUpdateState, action: Action): CommetUpdat } }; -export const useUpdateComment = ( - comments: Comment[] -): [CommetUpdateState, (commentId: string, commentUpdate: string) => void] => { +interface UseUpdateComment extends CommentUpdateState { + updateComment: (commentId: string, commentUpdate: string) => void; +} + +export const useUpdateComment = (comments: Comment[]): UseUpdateComment => { const [state, dispatch] = useReducer(dataFetchReducer, { isLoadingIds: [], isError: false, - data: comments, + comments, }); - const dispatchUpdateComment = useRef<(commentId: string, commentUpdate: string) => void>(); const [, dispatchToaster] = useStateToaster(); - dispatchUpdateComment.current = async (commentId: string, commentUpdate: string) => { - dispatch({ type: FETCH_INIT, payload: commentId }); - try { - const currentComment = state.data.find(comment => comment.commentId === commentId) ?? { - version: '', + const dispatchUpdateComment = useCallback( + async (commentId: string, commentUpdate: string) => { + let cancel = false; + try { + dispatch({ type: FETCH_INIT, payload: commentId }); + const currentComment = state.comments.find(comment => comment.id === commentId) ?? { + version: '', + }; + const response = await patchComment(commentId, commentUpdate, currentComment.version); + if (!cancel) { + dispatch({ type: FETCH_SUCCESS, payload: { update: response, commentId } }); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + dispatch({ type: FETCH_FAILURE, payload: commentId }); + } + } + return () => { + cancel = true; }; - const response = await updateComment(commentId, commentUpdate, currentComment.version); - dispatch({ type: FETCH_SUCCESS, payload: { update: response, commentId } }); - } catch (error) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); - dispatch({ type: FETCH_FAILURE, payload: commentId }); - } - }; + }, + [state] + ); - return [state, dispatchUpdateComment.current]; + return { ...state, updateComment: dispatchUpdateComment }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts index 14a3819bdfdad..a377c496fe726 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts @@ -5,7 +5,21 @@ */ import { camelCase, isArray, isObject, set } from 'lodash'; -import { AllCases, AllCasesSnake, Case, CaseSnake } from './types'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { + CaseResponse, + CaseResponseRt, + CasesResponse, + CasesResponseRt, + throwErrors, + CommentResponse, + CommentResponseRt, +} from '../../../../../../plugins/case/common/api'; +import { ToasterErrors } from '../../hooks/api/throw_if_not_ok'; +import { AllCases, Case } from './types'; export const getTypedPayload = <T>(a: unknown): T => a as T; @@ -32,9 +46,20 @@ export const convertToCamelCase = <T, U extends {}>(snakeCase: T): U => return acc; }, {} as U); -export const convertAllCasesToCamel = (snakeCases: AllCasesSnake): AllCases => ({ - cases: snakeCases.cases.map(snakeCase => convertToCamelCase<CaseSnake, Case>(snakeCase)), +export const convertAllCasesToCamel = (snakeCases: CasesResponse): AllCases => ({ + cases: snakeCases.cases.map(snakeCase => convertToCamelCase<CaseResponse, Case>(snakeCase)), page: snakeCases.page, perPage: snakeCases.per_page, total: snakeCases.total, }); + +export const createToasterPlainError = (message: string) => new ToasterErrors([message]); + +export const decodeCaseResponse = (respCase?: CaseResponse) => + pipe(CaseResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity)); + +export const decodeCasesResponse = (respCases?: CasesResponse) => + pipe(CasesResponseRt.decode(respCases), fold(throwErrors(createToasterPlainError), identity)); + +export const decodeCommentResponse = (respComment?: CommentResponse) => + pipe(CommentResponseRt.decode(respComment), fold(throwErrors(createToasterPlainError), identity)); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts index b348678e789f8..05446577a0fa0 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts @@ -20,7 +20,7 @@ import { getPrePackagedRulesStatus, } from './api'; import { ruleMock, rulesMock } from './mock'; -import { ToasterErrors } from '../../../components/ml/api/throw_if_not_ok'; +import { ToasterErrors } from '../../../hooks/api/throw_if_not_ok'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/get_index_error.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/get_index_error.ts index 4f45b480772f2..79dae5b8acb87 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/get_index_error.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/get_index_error.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MessageBody } from '../../../../components/ml/api/throw_if_not_ok'; +import { MessageBody } from '../../../../hooks/api/throw_if_not_ok'; export class SignalIndexError extends Error { message: string = ''; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/post_index_error.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/post_index_error.ts index d6d8cccfb4540..227699af71b42 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/post_index_error.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/post_index_error.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MessageBody } from '../../../../components/ml/api/throw_if_not_ok'; +import { MessageBody } from '../../../../hooks/api/throw_if_not_ok'; export class PostSignalError extends Error { message: string = ''; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/privilege_user_error.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/privilege_user_error.ts index 5cd458a7fe9aa..19915e898bbeb 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/privilege_user_error.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/privilege_user_error.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MessageBody } from '../../../../components/ml/api/throw_if_not_ok'; +import { MessageBody } from '../../../../hooks/api/throw_if_not_ok'; export class PrivilegeUserError extends Error { message: string = ''; diff --git a/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx b/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx index 69848c08fa3f8..1dfd6416531ee 100644 --- a/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx +++ b/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx @@ -6,7 +6,7 @@ import * as i18n from '../translations'; import { StartServices } from '../../plugin'; -import { parseJsonFromBody, ToasterErrors } from '../../components/ml/api/throw_if_not_ok'; +import { parseJsonFromBody, ToasterErrors } from './throw_if_not_ok'; import { IndexPatternSavedObject, IndexPatternSavedObjectAttributes } from '../types'; /** diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts b/x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.test.ts similarity index 99% rename from x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts rename to x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.test.ts index 9fd0010535203..bc0c765d6f2df 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts +++ b/x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.test.ts @@ -14,7 +14,7 @@ import { ToasterErrors, tryParseResponse, } from './throw_if_not_ok'; -import { SetupMlResponse } from '../../ml_popover/types'; +import { SetupMlResponse } from '../../components/ml_popover/types'; describe('throw_if_not_ok', () => { afterEach(() => { diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts b/x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.ts similarity index 91% rename from x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts rename to x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.ts index 6ca843207a15e..7d70106b0e562 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts +++ b/x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.ts @@ -6,11 +6,11 @@ import { has } from 'lodash/fp'; -import * as i18n from './translations'; -import { MlError } from '../types'; -import { SetupMlResponse } from '../../ml_popover/types'; +import * as i18n from '../../components/ml/api/translations'; +import { MlError } from '../../components/ml/types'; +import { SetupMlResponse } from '../../components/ml_popover/types'; -export { MessageBody, parseJsonFromBody } from '../../../utils/api'; +export { MessageBody, parseJsonFromBody } from '../../utils/api'; export interface MlStartJobError { error: MlError; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/case.tsx b/x-pack/legacy/plugins/siem/public/pages/case/case.tsx index 15a6d076f1009..9255dee461940 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/case.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/case.tsx @@ -6,29 +6,13 @@ import React from 'react'; -import { EuiButton, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { CaseHeaderPage } from './components/case_header_page'; import { WrapperPage } from '../../components/wrapper_page'; import { AllCases } from './components/all_cases'; import { SpyRoute } from '../../utils/route/spy_routes'; -import * as i18n from './translations'; -import { getCreateCaseUrl, getConfigureCasesUrl } from '../../components/link_to'; export const CasesPage = React.memo(() => ( <> <WrapperPage> - <CaseHeaderPage subtitle={i18n.PAGE_SUBTITLE} title={i18n.PAGE_TITLE}> - <EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={true}> - <EuiFlexItem grow={false}> - <EuiButton fill href={getCreateCaseUrl()} iconType="plusInCircle"> - {i18n.CREATE_TITLE} - </EuiButton> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButtonIcon href={getConfigureCasesUrl()} iconType="gear" /> - </EuiFlexItem> - </EuiFlexGroup> - </CaseHeaderPage> <AllCases /> </WrapperPage> <SpyRoute /> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx index c8e0dafcf5742..16c6101b80d40 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx @@ -3,15 +3,17 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; + import { EuiButton, EuiLoadingSpinner } from '@elastic/eui'; +import React, { useCallback } from 'react'; import styled from 'styled-components'; -import { Form, useForm, UseField } from '../../../../shared_imports'; -import { NewComment } from '../../../../containers/case/types'; + +import { CommentRequest } from '../../../../../../../../plugins/case/common/api'; import { usePostComment } from '../../../../containers/case/use_post_comment'; -import { schema } from './schema'; -import * as i18n from '../../translations'; import { MarkdownEditorForm } from '../../../../components/markdown_editor/form'; +import { Form, useForm, UseField } from '../../../../shared_imports'; +import * as i18n from '../../translations'; +import { schema } from './schema'; const MySpinner = styled(EuiLoadingSpinner)` position: absolute; @@ -19,24 +21,26 @@ const MySpinner = styled(EuiLoadingSpinner)` left: 50%; `; +const initialCommentValue: CommentRequest = { + comment: '', +}; + export const AddComment = React.memo<{ caseId: string; }>(({ caseId }) => { - const [{ data, isLoading, newComment }, setFormData] = usePostComment(caseId); - const { form } = useForm({ - defaultValue: data, + const { commentData, isLoading, postComment } = usePostComment(caseId); + const { form } = useForm<CommentRequest>({ + defaultValue: initialCommentValue, options: { stripEmptyFields: false }, schema, }); const onSubmit = useCallback(async () => { - const { isValid, data: newData } = await form.submit(); - if (isValid && newData.comment) { - setFormData({ ...newData, isNew: true } as NewComment); - } else if (isValid && data.comment) { - setFormData({ ...data, ...newData, isNew: true } as NewComment); + const { isValid, data } = await form.submit(); + if (isValid) { + await postComment(data); } - }, [form, data]); + }, [form]); return ( <> @@ -64,7 +68,7 @@ export const AddComment = React.memo<{ }} /> </Form> - {newComment && + {commentData != null && 'TO DO new comment got added but we didnt update the UI yet. Refresh the page to see your comment ;)'} </> ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx index 5f30f59149d99..c61874a8dabfc 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx @@ -3,12 +3,14 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + +import { CommentRequest } from '../../../../../../../../plugins/case/common/api'; import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../../shared_imports'; import * as i18n from '../../translations'; const { emptyField } = fieldValidators; -export const schema: FormSchema = { +export const schema: FormSchema<CommentRequest> = { comment: { type: FIELD_TYPES.TEXTAREA, validations: [ diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 0169493773b74..2e57e5f2f95d9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -11,7 +11,7 @@ export const useGetCasesMockState: UseGetCasesState = { data: { cases: [ { - caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, comments: [], @@ -23,7 +23,7 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { - caseId: '362a5c10-4e99-11ea-9290-35d05cb55c15', + id: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, comments: [], @@ -35,7 +35,7 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { - caseId: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', + id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, comments: [], @@ -47,7 +47,7 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { - caseId: '31890e90-4e99-11ea-9290-35d05cb55c15', + id: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, comments: [], @@ -59,7 +59,7 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { - caseId: '2f5b3210-4e99-11ea-9290-35d05cb55c15', + id: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, comments: [], @@ -75,7 +75,12 @@ export const useGetCasesMockState: UseGetCasesState = { perPage: 5, total: 10, }, - isLoading: false, + caseCount: { + open: 0, + closed: 0, + }, + loading: [], + selectedCases: [], isError: false, queryParams: { page: 1, @@ -83,5 +88,5 @@ export const useGetCasesMockState: UseGetCasesState = { sortField: SortFieldCase.createdAt, sortOrder: 'desc', }, - filterOptions: { search: '', tags: [] }, + filterOptions: { search: '', tags: [], state: 'open' }, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx new file mode 100644 index 0000000000000..0ec09f2b57918 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types'; +import { Dispatch } from 'react'; +import { Case } from '../../../../containers/case/types'; + +import * as i18n from './translations'; +import { UpdateCase } from '../../../../containers/case/use_get_cases'; + +interface GetActions { + caseStatus: string; + dispatchUpdate: Dispatch<UpdateCase>; +} + +export const getActions = ({ + caseStatus, + dispatchUpdate, +}: GetActions): Array<DefaultItemIconButtonAction<Case>> => [ + { + description: i18n.DELETE, + icon: 'trash', + name: i18n.DELETE, + // eslint-disable-next-line no-console + onClick: ({ id }: Case) => console.log('TO DO Delete case', id), + type: 'icon', + 'data-test-subj': 'action-delete', + }, + caseStatus === 'open' + ? { + description: i18n.CLOSE_CASE, + icon: 'magnet', + name: i18n.CLOSE_CASE, + onClick: (theCase: Case) => + dispatchUpdate({ + updateKey: 'state', + updateValue: 'closed', + caseId: theCase.id, + version: theCase.version, + }), + type: 'icon', + 'data-test-subj': 'action-close', + } + : { + description: i18n.REOPEN_CASE, + icon: 'magnet', + name: i18n.REOPEN_CASE, + onClick: (theCase: Case) => + dispatchUpdate({ + updateKey: 'state', + updateValue: 'open', + caseId: theCase.id, + version: theCase.version, + }), + type: 'icon', + 'data-test-subj': 'action-open', + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 9c276d1b24da1..f6ed2694fdc40 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -4,7 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { EuiBadge, EuiTableFieldDataColumnType, EuiTableComputedColumnType } from '@elastic/eui'; +import { + EuiBadge, + EuiTableFieldDataColumnType, + EuiTableComputedColumnType, + EuiTableActionsColumnType, + EuiAvatar, +} from '@elastic/eui'; +import styled from 'styled-components'; +import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types'; import { getEmptyTagValue } from '../../../../components/empty_value'; import { Case } from '../../../../containers/case/types'; import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; @@ -12,17 +20,61 @@ import { CaseDetailsLink } from '../../../../components/links'; import { TruncatableText } from '../../../../components/truncatable_text'; import * as i18n from './translations'; -export type CasesColumns = EuiTableFieldDataColumnType<Case> | EuiTableComputedColumnType<Case>; +export type CasesColumns = + | EuiTableFieldDataColumnType<Case> + | EuiTableComputedColumnType<Case> + | EuiTableActionsColumnType<Case>; -const renderStringField = (field: string, dataTestSubj: string) => - field != null ? <span data-test-subj={dataTestSubj}>{field}</span> : getEmptyTagValue(); +const MediumShadeText = styled.p` + color: ${({ theme }) => theme.eui.euiColorMediumShade}; +`; -export const getCasesColumns = (): CasesColumns[] => [ +const Spacer = styled.span` + margin-left: ${({ theme }) => theme.eui.paddingSizes.s}; +`; + +const TempNumberComponent = () => <span>{1}</span>; +TempNumberComponent.displayName = 'TempNumberComponent'; + +export const getCasesColumns = ( + actions: Array<DefaultItemIconButtonAction<Case>> +): CasesColumns[] => [ { name: i18n.NAME, render: (theCase: Case) => { - if (theCase.caseId != null && theCase.title != null) { - return <CaseDetailsLink detailName={theCase.caseId}>{theCase.title}</CaseDetailsLink>; + if (theCase.id != null && theCase.title != null) { + const caseDetailsLinkComponent = ( + <CaseDetailsLink detailName={theCase.id}>{theCase.title}</CaseDetailsLink> + ); + return theCase.state === 'open' ? ( + caseDetailsLinkComponent + ) : ( + <> + <MediumShadeText> + {caseDetailsLinkComponent} + <Spacer>{i18n.CLOSED}</Spacer> + </MediumShadeText> + </> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: 'createdBy', + name: i18n.REPORTER, + render: (createdBy: Case['createdBy']) => { + if (createdBy != null) { + return ( + <> + <EuiAvatar + className="userAction__circle" + name={createdBy.fullName ? createdBy.fullName : createdBy.username} + size="s" + /> + <Spacer data-test-subj="case-table-column-createdBy">{createdBy.username}</Spacer> + </> + ); } return getEmptyTagValue(); }, @@ -50,9 +102,16 @@ export const getCasesColumns = (): CasesColumns[] => [ }, truncateText: true, }, + { + align: 'right', + field: 'commentCount', // TO DO once we have commentCount returned in the API: https://github.com/elastic/kibana/issues/58525 + name: i18n.COMMENTS, + sortable: true, + render: TempNumberComponent, + }, { field: 'createdAt', - name: i18n.CREATED_AT, + name: i18n.OPENED_ON, sortable: true, render: (createdAt: Case['createdAt']) => { if (createdAt != null) { @@ -67,31 +126,7 @@ export const getCasesColumns = (): CasesColumns[] => [ }, }, { - field: 'createdBy.username', - name: i18n.REPORTER, - render: (createdBy: Case['createdBy']['username']) => - renderStringField(createdBy, `case-table-column-username`), - }, - { - field: 'updatedAt', - name: i18n.LAST_UPDATED, - sortable: true, - render: (updatedAt: Case['updatedAt']) => { - if (updatedAt != null) { - return ( - <FormattedRelativePreferenceDate - value={updatedAt} - data-test-subj={`case-table-column-updatedAt`} - /> - ); - } - return getEmptyTagValue(); - }, - }, - { - field: 'state', - name: i18n.STATE, - sortable: true, - render: (state: Case['state']) => renderStringField(state, `case-table-column-state`), + name: 'Actions', + actions, }, ]; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx index 5a87cf53142f7..40a76c636954f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx @@ -13,13 +13,21 @@ import { useGetCasesMockState } from './__mock__'; import * as apiHook from '../../../../containers/case/use_get_cases'; describe('AllCases', () => { - const setQueryParams = jest.fn(); const setFilters = jest.fn(); + const setQueryParams = jest.fn(); + const setSelectedCases = jest.fn(); + const getCaseCount = jest.fn(); + const dispatchUpdateCaseProperty = jest.fn(); beforeEach(() => { jest.resetAllMocks(); - jest - .spyOn(apiHook, 'useGetCases') - .mockReturnValue([useGetCasesMockState, setQueryParams, setFilters]); + jest.spyOn(apiHook, 'useGetCases').mockReturnValue({ + ...useGetCasesMockState, + dispatchUpdateCaseProperty, + getCaseCount, + setFilters, + setQueryParams, + setSelectedCases, + }); moment.tz.setDefault('UTC'); }); it('should render AllCases', () => { @@ -33,19 +41,13 @@ describe('AllCases', () => { .find(`a[data-test-subj="case-details-link"]`) .first() .prop('href') - ).toEqual(`#/link-to/case/${useGetCasesMockState.data.cases[0].caseId}`); + ).toEqual(`#/link-to/case/${useGetCasesMockState.data.cases[0].id}`); expect( wrapper .find(`a[data-test-subj="case-details-link"]`) .first() .text() ).toEqual(useGetCasesMockState.data.cases[0].title); - expect( - wrapper - .find(`[data-test-subj="case-table-column-state"]`) - .first() - .text() - ).toEqual(useGetCasesMockState.data.cases[0].state); expect( wrapper .find(`span[data-test-subj="case-table-column-tags-0"]`) @@ -54,7 +56,7 @@ describe('AllCases', () => { ).toEqual(useGetCasesMockState.data.cases[0].tags[0]); expect( wrapper - .find(`[data-test-subj="case-table-column-username"]`) + .find(`[data-test-subj="case-table-column-createdBy"]`) .first() .text() ).toEqual(useGetCasesMockState.data.cases[0].createdBy.username); @@ -64,13 +66,6 @@ describe('AllCases', () => { .first() .prop('value') ).toEqual(useGetCasesMockState.data.cases[0].createdAt); - expect( - wrapper - .find(`[data-test-subj="case-table-column-updatedAt"]`) - .first() - .prop('value') - ).toEqual(useGetCasesMockState.data.cases[0].updatedAt); - expect( wrapper .find(`[data-test-subj="case-table-case-count"]`) @@ -85,12 +80,13 @@ describe('AllCases', () => { </TestProviders> ); wrapper - .find('[data-test-subj="tableHeaderCell_state_5"] [data-test-subj="tableHeaderSortButton"]') + .find('[data-test-subj="tableHeaderSortButton"]') + .first() .simulate('click'); expect(setQueryParams).toBeCalledWith({ page: 1, perPage: 5, - sortField: 'state', + sortField: 'createdAt', sortOrder: 'asc', }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index 3253a036c2990..484d9051ee43f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -8,45 +8,85 @@ import React, { useCallback, useMemo } from 'react'; import { EuiBasicTable, EuiButton, + EuiButtonIcon, + EuiContextMenuPanel, EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, EuiLoadingContent, + EuiProgress, EuiTableSortingType, } from '@elastic/eui'; -import { isEmpty } from 'lodash/fp'; +import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types'; +import styled, { css } from 'styled-components'; import * as i18n from './translations'; import { getCasesColumns } from './columns'; -import { SortFieldCase, Case, FilterOptions } from '../../../../containers/case/types'; +import { Case, FilterOptions, SortFieldCase } from '../../../../containers/case/types'; import { useGetCases } from '../../../../containers/case/use_get_cases'; import { EuiBasicTableOnChange } from '../../../detection_engine/rules/types'; import { Panel } from '../../../../components/panel'; -import { HeaderSection } from '../../../../components/header_section'; import { CasesTableFilters } from './table_filters'; import { UtilityBar, + UtilityBarAction, UtilityBarGroup, UtilityBarSection, UtilityBarText, -} from '../../../../components/detection_engine/utility_bar'; -import { getCreateCaseUrl } from '../../../../components/link_to'; +} from '../../../../components/utility_bar'; +import { getConfigureCasesUrl, getCreateCaseUrl } from '../../../../components/link_to'; +import { getBulkItems } from '../bulk_actions'; +import { CaseHeaderPage } from '../case_header_page'; +import { OpenClosedStats } from '../open_closed_stats'; +import { getActions } from './actions'; + +const Div = styled.div` + margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; +`; +const FlexItemDivider = styled(EuiFlexItem)` + ${({ theme }) => css` + .euiFlexGroup--gutterMedium > &.euiFlexItem { + border-right: ${theme.eui.euiBorderThin}; + padding-right: ${theme.eui.euiSize}; + margin-right: ${theme.eui.euiSize}; + } + `} +`; + +const ProgressLoader = styled(EuiProgress)` + ${({ theme }) => css` + .euiFlexGroup--gutterMedium > &.euiFlexItem { + top: 2px; + border-radius: ${theme.eui.euiBorderRadius}; + z-index: ${theme.eui.euiZHeader}; + } + `} +`; + const getSortField = (field: string): SortFieldCase => { if (field === SortFieldCase.createdAt) { return SortFieldCase.createdAt; - } else if (field === SortFieldCase.state) { - return SortFieldCase.state; } else if (field === SortFieldCase.updatedAt) { return SortFieldCase.updatedAt; } return SortFieldCase.createdAt; }; export const AllCases = React.memo(() => { - const [ - { data, isLoading, queryParams, filterOptions }, - setQueryParams, + const { + caseCount, + data, + dispatchUpdateCaseProperty, + filterOptions, + getCaseCount, + loading, + queryParams, + selectedCases, setFilters, - ] = useGetCases(); + setQueryParams, + setSelectedCases, + } = useGetCases(); const tableOnChangeCallback = useCallback( ({ page, sort }: EuiBasicTableOnChange) => { @@ -77,7 +117,13 @@ export const AllCases = React.memo(() => { [filterOptions, setFilters] ); - const memoizedGetCasesColumns = useMemo(() => getCasesColumns(), []); + const actions = useMemo( + () => + getActions({ caseStatus: filterOptions.state, dispatchUpdate: dispatchUpdateCaseProperty }), + [filterOptions.state, dispatchUpdateCaseProperty] + ); + + const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions), [filterOptions.state]); const memoizedPagination = useMemo( () => ({ pageIndex: queryParams.page - 1, @@ -88,55 +134,132 @@ export const AllCases = React.memo(() => { [data, queryParams] ); + const getBulkItemsPopoverContent = useCallback( + (closePopover: () => void) => ( + <EuiContextMenuPanel + items={getBulkItems({ + closePopover, + selectedCases, + caseStatus: filterOptions.state, + })} + /> + ), + [selectedCases, filterOptions.state] + ); + const sorting: EuiTableSortingType<Case> = { sort: { field: queryParams.sortField, direction: queryParams.sortOrder }, }; + const euiBasicTableSelectionProps = useMemo<EuiTableSelectionType<Case>>( + () => ({ + selectable: (item: Case) => true, + onSelectionChange: setSelectedCases, + }), + [selectedCases] + ); + const isCasesLoading = useMemo( + () => loading.indexOf('cases') > -1 || loading.indexOf('caseUpdate') > -1, + [loading] + ); + const isDataEmpty = useMemo(() => data.total === 0, [data]); return ( - <Panel loading={isLoading}> - <HeaderSection split title={i18n.ALL_CASES}> + <> + <CaseHeaderPage title={i18n.PAGE_TITLE}> + <EuiFlexGroup alignItems="center" gutterSize="m" responsive={false} wrap={true}> + <EuiFlexItem grow={false}> + <OpenClosedStats + caseCount={caseCount} + caseState={'open'} + getCaseCount={getCaseCount} + isLoading={loading.indexOf('caseCount') > -1} + /> + </EuiFlexItem> + <FlexItemDivider grow={false}> + <OpenClosedStats + caseCount={caseCount} + caseState={'closed'} + getCaseCount={getCaseCount} + isLoading={loading.indexOf('caseCount') > -1} + /> + </FlexItemDivider> + <EuiFlexItem grow={false}> + <EuiButton fill href={getCreateCaseUrl()} iconType="plusInCircle"> + {i18n.CREATE_TITLE} + </EuiButton> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonIcon + aria-label={i18n.CONFIGURE_CASES_BUTTON} + href={getConfigureCasesUrl()} + iconType="gear" + /> + </EuiFlexItem> + </EuiFlexGroup> + </CaseHeaderPage> + {isCasesLoading && !isDataEmpty && <ProgressLoader size="xs" color="accent" />} + <Panel loading={isCasesLoading}> <CasesTableFilters onFilterChanged={onFilterChangedCallback} - initial={{ search: filterOptions.search, tags: filterOptions.tags }} + initial={{ + search: filterOptions.search, + tags: filterOptions.tags, + state: filterOptions.state, + }} /> - </HeaderSection> - {isLoading && isEmpty(data.cases) && ( - <EuiLoadingContent data-test-subj="initialLoadingPanelAllCases" lines={10} /> - )} - {!isLoading && !isEmpty(data.cases) && ( - <> - <UtilityBar border> - <UtilityBarSection> - <UtilityBarGroup> - <UtilityBarText data-test-subj="case-table-case-count"> - {i18n.SHOWING_CASES(data.total ?? 0)} - </UtilityBarText> - </UtilityBarGroup> - </UtilityBarSection> - </UtilityBar> - <EuiBasicTable - columns={memoizedGetCasesColumns} - itemId="id" - items={data.cases} - noItemsMessage={ - <EuiEmptyPrompt - title={<h3>{i18n.NO_CASES}</h3>} - titleSize="xs" - body={i18n.NO_CASES_BODY} - actions={ - <EuiButton fill size="s" href={getCreateCaseUrl()} iconType="plusInCircle"> - {i18n.ADD_NEW_CASE} - </EuiButton> - } - /> - } - onChange={tableOnChangeCallback} - pagination={memoizedPagination} - sorting={sorting} - /> - </> - )} - </Panel> + {isCasesLoading && isDataEmpty ? ( + <Div> + <EuiLoadingContent data-test-subj="initialLoadingPanelAllCases" lines={10} /> + </Div> + ) : ( + <Div> + <UtilityBar border> + <UtilityBarSection> + <UtilityBarGroup> + <UtilityBarText data-test-subj="case-table-case-count"> + {i18n.SHOWING_CASES(data.total ?? 0)} + </UtilityBarText> + </UtilityBarGroup> + <UtilityBarGroup> + <UtilityBarText data-test-subj="case-table-selected-case-count"> + {i18n.SELECTED_CASES(selectedCases.length)} + </UtilityBarText> + <UtilityBarAction + iconSide="right" + iconType="arrowDown" + popoverContent={getBulkItemsPopoverContent} + > + {i18n.BULK_ACTIONS} + </UtilityBarAction> + </UtilityBarGroup> + </UtilityBarSection> + </UtilityBar> + <EuiBasicTable + columns={memoizedGetCasesColumns} + isSelectable + itemId="caseId" + items={data.cases} + noItemsMessage={ + <EuiEmptyPrompt + title={<h3>{i18n.NO_CASES}</h3>} + titleSize="xs" + body={i18n.NO_CASES_BODY} + actions={ + <EuiButton fill size="s" href={getCreateCaseUrl()} iconType="plusInCircle"> + {i18n.ADD_NEW_CASE} + </EuiButton> + } + /> + } + onChange={tableOnChangeCallback} + pagination={memoizedPagination} + selection={euiBasicTableSelectionProps} + sorting={sorting} + /> + </Div> + )} + </Panel> + </> ); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx index e593623788046..5256fb6d7b3ee 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx @@ -6,20 +6,22 @@ import React, { useCallback, useState } from 'react'; import { isEqual } from 'lodash/fp'; -import { EuiFieldSearch, EuiFilterGroup, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiFieldSearch, + EuiFilterButton, + EuiFilterGroup, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import * as i18n from './translations'; import { FilterOptions } from '../../../../containers/case/types'; import { useGetTags } from '../../../../containers/case/use_get_tags'; -import { TagsFilterPopover } from '../../../../pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover'; +import { FilterPopover } from '../../../../components/filter_popover'; -interface Initial { - search: string; - tags: string[]; -} interface CasesTableFiltersProps { onFilterChanged: (filterOptions: Partial<FilterOptions>) => void; - initial: Initial; + initial: FilterOptions; } /** @@ -31,17 +33,18 @@ interface CasesTableFiltersProps { const CasesTableFiltersComponent = ({ onFilterChanged, - initial = { search: '', tags: [] }, + initial = { search: '', tags: [], state: 'open' }, }: CasesTableFiltersProps) => { const [search, setSearch] = useState(initial.search); const [selectedTags, setSelectedTags] = useState(initial.tags); - const [{ isLoading, data }] = useGetTags(); + const [showOpenCases, setShowOpenCases] = useState(initial.state === 'open'); + const [{ data }] = useGetTags(); const handleSelectedTags = useCallback( newTags => { if (!isEqual(newTags, selectedTags)) { setSelectedTags(newTags); - onFilterChanged({ search, tags: newTags }); + onFilterChanged({ tags: newTags }); } }, [search, selectedTags] @@ -51,12 +54,20 @@ const CasesTableFiltersComponent = ({ const trimSearch = newSearch.trim(); if (!isEqual(trimSearch, search)) { setSearch(trimSearch); - onFilterChanged({ tags: selectedTags, search: trimSearch }); + onFilterChanged({ search: trimSearch }); } }, [search, selectedTags] ); - + const handleToggleFilter = useCallback( + showOpen => { + if (showOpen !== showOpenCases) { + setShowOpenCases(showOpen); + onFilterChanged({ state: showOpen ? 'open' : 'closed' }); + } + }, + [showOpenCases] + ); return ( <EuiFlexGroup gutterSize="m" justifyContent="flexEnd"> <EuiFlexItem grow={true}> @@ -71,11 +82,32 @@ const CasesTableFiltersComponent = ({ <EuiFlexItem grow={false}> <EuiFilterGroup> - <TagsFilterPopover - isLoading={isLoading} - onSelectedTagsChanged={handleSelectedTags} - selectedTags={selectedTags} - tags={data} + <EuiFilterButton + withNext + hasActiveFilters={showOpenCases} + onClick={handleToggleFilter.bind(null, true)} + > + {i18n.OPEN_CASES} + </EuiFilterButton> + <EuiFilterButton + hasActiveFilters={!showOpenCases} + onClick={handleToggleFilter.bind(null, false)} + > + {i18n.CLOSED_CASES} + </EuiFilterButton> + <FilterPopover + buttonLabel={i18n.REPORTER} + onSelectedOptionsChanged={() => {}} + selectedOptions={[]} + options={[]} + optionsEmptyLabel={i18n.NO_REPORTERS_AVAILABLE} + /> + <FilterPopover + buttonLabel={i18n.TAGS} + onSelectedOptionsChanged={handleSelectedTags} + selectedOptions={selectedTags} + options={data} + optionsEmptyLabel={i18n.NO_TAGS_AVAILABLE} /> </EuiFilterGroup> </EuiFlexItem> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts index ab8e22ebcf1be..19117136ed046 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts @@ -8,9 +8,6 @@ import { i18n } from '@kbn/i18n'; export * from '../../translations'; -export const ALL_CASES = i18n.translate('xpack.siem.case.caseTable.title', { - defaultMessage: 'All Cases', -}); export const NO_CASES = i18n.translate('xpack.siem.case.caseTable.noCases.title', { defaultMessage: 'No Cases', }); @@ -21,6 +18,12 @@ export const ADD_NEW_CASE = i18n.translate('xpack.siem.case.caseTable.addNewCase defaultMessage: 'Add New Case', }); +export const SELECTED_CASES = (totalRules: number) => + i18n.translate('xpack.siem.case.caseTable.selectedCasesTitle', { + values: { totalRules }, + defaultMessage: 'Selected {totalRules} {totalRules, plural, =1 {case} other {cases}}', + }); + export const SHOWING_CASES = (totalRules: number) => i18n.translate('xpack.siem.case.caseTable.showingCasesTitle', { values: { totalRules }, @@ -33,16 +36,36 @@ export const UNIT = (totalCount: number) => defaultMessage: `{totalCount, plural, =1 {case} other {cases}}`, }); -export const SEARCH_CASES = i18n.translate( - 'xpack.siem.detectionEngine.case.caseTable.searchAriaLabel', - { - defaultMessage: 'Search cases', - } -); - -export const SEARCH_PLACEHOLDER = i18n.translate( - 'xpack.siem.detectionEngine.case.caseTable.searchPlaceholder', - { - defaultMessage: 'e.g. case name', - } -); +export const SEARCH_CASES = i18n.translate('xpack.siem.case.caseTable.searchAriaLabel', { + defaultMessage: 'Search cases', +}); + +export const BULK_ACTIONS = i18n.translate('xpack.siem.case.caseTable.bulkActions', { + defaultMessage: 'Bulk actions', +}); + +export const SEARCH_PLACEHOLDER = i18n.translate('xpack.siem.case.caseTable.searchPlaceholder', { + defaultMessage: 'e.g. case name', +}); +export const OPEN_CASES = i18n.translate('xpack.siem.case.caseTable.openCases', { + defaultMessage: 'Open cases', +}); +export const CLOSED_CASES = i18n.translate('xpack.siem.case.caseTable.closedCases', { + defaultMessage: 'Closed cases', +}); + +export const CLOSED = i18n.translate('xpack.siem.case.caseTable.closed', { + defaultMessage: 'Closed', +}); +export const DELETE = i18n.translate('xpack.siem.case.caseTable.delete', { + defaultMessage: 'Delete', +}); +export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { + defaultMessage: 'Reopen case', +}); +export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { + defaultMessage: 'Close case', +}); +export const DUPLICATE_CASE = i18n.translate('xpack.siem.case.caseTable.duplicateCase', { + defaultMessage: 'Duplicate case', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/index.tsx new file mode 100644 index 0000000000000..2fe25a7d1f5d0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/index.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiContextMenuItem } from '@elastic/eui'; +import React from 'react'; +import * as i18n from './translations'; +import { Case } from '../../../../containers/case/types'; + +interface GetBulkItems { + // cases: Case[]; + closePopover: () => void; + // dispatch: Dispatch<Action>; + // dispatchToaster: Dispatch<ActionToaster>; + // reFetchCases: (refreshPrePackagedCase?: boolean) => void; + selectedCases: Case[]; + caseStatus: string; +} + +export const getBulkItems = ({ + // cases, + closePopover, + caseStatus, + // dispatch, + // dispatchToaster, + // reFetchCases, + selectedCases, +}: GetBulkItems) => { + return [ + caseStatus === 'open' ? ( + <EuiContextMenuItem + key={i18n.BULK_ACTION_CLOSE_SELECTED} + icon="magnet" + disabled={true} // TO DO + onClick={async () => { + closePopover(); + // await deleteCasesAction(selectedCases, dispatch, dispatchToaster); + // reFetchCases(true); + }} + > + {i18n.BULK_ACTION_CLOSE_SELECTED} + </EuiContextMenuItem> + ) : ( + <EuiContextMenuItem + key={i18n.BULK_ACTION_OPEN_SELECTED} + icon="magnet" + disabled={true} // TO DO + onClick={async () => { + closePopover(); + // await deleteCasesAction(selectedCases, dispatch, dispatchToaster); + // reFetchCases(true); + }} + > + {i18n.BULK_ACTION_OPEN_SELECTED} + </EuiContextMenuItem> + ), + <EuiContextMenuItem + key={i18n.BULK_ACTION_DELETE_SELECTED} + icon="trash" + disabled={true} // TO DO + onClick={async () => { + closePopover(); + // await deleteCasesAction(selectedCases, dispatch, dispatchToaster); + // reFetchCases(true); + }} + > + {i18n.BULK_ACTION_DELETE_SELECTED} + </EuiContextMenuItem>, + ]; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts new file mode 100644 index 0000000000000..0bf213868bd76 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const BULK_ACTION_CLOSE_SELECTED = i18n.translate( + 'xpack.siem.case.caseTable.bulkActions.closeSelectedTitle', + { + defaultMessage: 'Close selected', + } +); + +export const BULK_ACTION_OPEN_SELECTED = i18n.translate( + 'xpack.siem.case.caseTable.bulkActions.openSelectedTitle', + { + defaultMessage: 'Open selected', + } +); + +export const BULK_ACTION_DELETE_SELECTED = i18n.translate( + 'xpack.siem.case.caseTable.bulkActions.deleteSelectedTitle', + { + defaultMessage: 'Delete selected', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index 89d321c6d106a..c2d3cae6774b0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -10,11 +10,11 @@ import { Case } from '../../../../../containers/case/types'; export const caseProps: CaseProps = { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', initialData: { - caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', comments: [ { comment: 'Solve this fast!', - commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', + id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', createdAt: '2020-02-20T23:06:33.798Z', createdBy: { fullName: 'Steph Milovic', @@ -36,11 +36,11 @@ export const caseProps: CaseProps = { }; export const data: Case = { - caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', comments: [ { comment: 'Solve this fast!', - commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', + id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', createdAt: '2020-02-20T23:06:33.798Z', createdBy: { fullName: 'Steph Milovic', diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 1539b3de5a0c1..e3bbfc0a83d71 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -12,16 +12,17 @@ import { caseProps, data } from './__mock__'; import { TestProviders } from '../../../../mock'; describe('CaseView ', () => { - const dispatchUpdateCaseProperty = jest.fn(); + const updateCaseProperty = jest.fn(); beforeEach(() => { jest.resetAllMocks(); - jest - .spyOn(apiHook, 'useUpdateCase') - .mockReturnValue([ - { data, isLoading: false, isError: false, updateKey: null }, - dispatchUpdateCaseProperty, - ]); + jest.spyOn(apiHook, 'useUpdateCase').mockReturnValue({ + caseData: data, + isLoading: false, + isError: false, + updateKey: null, + updateCaseProperty, + }); }); it('should render CaseComponent', () => { @@ -79,7 +80,7 @@ describe('CaseView ', () => { .find('input[data-test-subj="toggle-case-state"]') .simulate('change', { target: { value: false } }); - expect(dispatchUpdateCaseProperty).toBeCalledWith({ + expect(updateCaseProperty).toBeCalledWith({ updateKey: 'state', updateValue: 'closed', }); @@ -94,7 +95,7 @@ describe('CaseView ', () => { expect( wrapper .find( - `div[data-test-subj="user-action-${data.comments[0].commentId}-avatar"] [data-test-subj="user-action-avatar"]` + `div[data-test-subj="user-action-${data.comments[0].id}-avatar"] [data-test-subj="user-action-avatar"]` ) .first() .prop('name') @@ -103,7 +104,7 @@ describe('CaseView ', () => { expect( wrapper .find( - `div[data-test-subj="user-action-${data.comments[0].commentId}"] [data-test-subj="user-action-title"] strong` + `div[data-test-subj="user-action-${data.comments[0].id}"] [data-test-subj="user-action-title"] strong` ) .first() .text() @@ -112,7 +113,7 @@ describe('CaseView ', () => { expect( wrapper .find( - `div[data-test-subj="user-action-${data.comments[0].commentId}"] [data-test-subj="markdown"]` + `div[data-test-subj="user-action-${data.comments[0].id}"] [data-test-subj="markdown"]` ) .first() .prop('source') diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 605f9e8fa1713..c917d27aebea3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -60,10 +60,7 @@ export interface CaseProps { } export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => { - const [{ data, isLoading, updateKey }, dispatchUpdateCaseProperty] = useUpdateCase( - caseId, - initialData - ); + const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData); const onUpdateField = useCallback( (newUpdateKey: keyof Case, updateValue: Case[keyof Case]) => { @@ -71,7 +68,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => case 'title': const titleUpdate = getTypedPayload<string>(updateValue); if (titleUpdate.length > 0) { - dispatchUpdateCaseProperty({ + updateCaseProperty({ updateKey: 'title', updateValue: titleUpdate, }); @@ -80,7 +77,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => case 'description': const descriptionUpdate = getTypedPayload<string>(updateValue); if (descriptionUpdate.length > 0) { - dispatchUpdateCaseProperty({ + updateCaseProperty({ updateKey: 'description', updateValue: descriptionUpdate, }); @@ -88,15 +85,15 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => break; case 'tags': const tagsUpdate = getTypedPayload<string[]>(updateValue); - dispatchUpdateCaseProperty({ + updateCaseProperty({ updateKey: 'tags', updateValue: tagsUpdate, }); break; case 'state': const stateUpdate = getTypedPayload<string>(updateValue); - if (data.state !== updateValue) { - dispatchUpdateCaseProperty({ + if (caseData.state !== updateValue) { + updateCaseProperty({ updateKey: 'state', updateValue: stateUpdate, }); @@ -105,7 +102,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => return null; } }, - [dispatchUpdateCaseProperty, data.state] + [updateCaseProperty, caseData.state] ); // TO DO refactor each of these const's into their own components @@ -146,11 +143,11 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => titleNode={ <EditableTitle isLoading={isLoading && updateKey === 'title'} - title={data.title} + title={caseData.title} onSubmit={onSubmit} /> } - title={data.title} + title={caseData.title} > <EuiFlexGroup gutterSize="l" justifyContent="flexEnd"> <EuiFlexItem grow={false}> @@ -160,10 +157,10 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => <EuiDescriptionListTitle>{i18n.STATUS}</EuiDescriptionListTitle> <EuiDescriptionListDescription> <EuiBadge - color={data.state === 'open' ? 'secondary' : 'danger'} + color={caseData.state === 'open' ? 'secondary' : 'danger'} data-test-subj="case-view-state" > - {data.state} + {caseData.state} </EuiBadge> </EuiDescriptionListDescription> </EuiFlexItem> @@ -172,7 +169,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => <EuiDescriptionListDescription> <FormattedRelativePreferenceDate data-test-subj="case-view-createdAt" - value={data.createdAt} + value={caseData.createdAt} /> </EuiDescriptionListDescription> </EuiFlexItem> @@ -184,10 +181,10 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => <EuiFlexItem> <EuiButtonToggle data-test-subj="toggle-case-state" - iconType={data.state === 'open' ? 'checkInCircleFilled' : 'magnet'} + iconType={caseData.state === 'open' ? 'checkInCircleFilled' : 'magnet'} isLoading={isLoading && updateKey === 'state'} - isSelected={data.state === 'open'} - label={data.state === 'open' ? 'Close case' : 'Reopen case'} + isSelected={caseData.state === 'open'} + label={caseData.state === 'open' ? 'Close case' : 'Reopen case'} onChange={toggleStateCase} /> </EuiFlexItem> @@ -204,7 +201,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => <EuiFlexGroup> <EuiFlexItem grow={6}> <UserActionTree - data={data} + data={caseData} isLoadingDescription={isLoading && updateKey === 'description'} onUpdateField={onUpdateField} /> @@ -213,11 +210,11 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => <UserList data-test-subj="case-view-user-list" headline={i18n.REPORTER} - users={[data.createdBy]} + users={[caseData.createdBy]} /> <TagList data-test-subj="case-view-tag-list" - tags={data.tags} + tags={caseData.tags} onSubmit={onSubmitTags} isLoading={isLoading && updateKey === 'tags'} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx index 65d7256fd6e20..840792f510fc0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx @@ -14,8 +14,9 @@ import { } from '@elastic/eui'; import styled, { css } from 'styled-components'; import { Redirect } from 'react-router-dom'; + +import { CaseRequest } from '../../../../../../../../plugins/case/common/api'; import { Field, Form, getUseField, useForm, UseField } from '../../../../shared_imports'; -import { NewCase } from '../../../../containers/case/types'; import { usePostCase } from '../../../../containers/case/use_post_case'; import { schema } from './schema'; import * as i18n from '../../translations'; @@ -42,30 +43,37 @@ const MySpinner = styled(EuiLoadingSpinner)` z-index: 99; `; +const initialCaseValue: CaseRequest = { + description: '', + state: 'open', + tags: [], + title: '', +}; + export const Create = React.memo(() => { - const [{ data, isLoading, newCase }, setFormData] = usePostCase(); + const { caseData, isLoading, postCase } = usePostCase(); const [isCancel, setIsCancel] = useState(false); - const { form } = useForm({ - defaultValue: data, + const { form } = useForm<CaseRequest>({ + defaultValue: initialCaseValue, options: { stripEmptyFields: false }, schema, }); const onSubmit = useCallback(async () => { - const { isValid, data: newData } = await form.submit(); - if (isValid && newData.description) { - setFormData({ ...newData, isNew: true } as NewCase); - } else if (isValid && data.description) { - setFormData({ ...data, ...newData, isNew: true } as NewCase); + const { isValid, data } = await form.submit(); + if (isValid) { + await postCase(data); } - }, [form, data]); + }, [form]); - if (newCase && newCase.caseId) { - return <Redirect to={`/${SiemPageName.case}/${newCase.caseId}`} />; + if (caseData != null && caseData.id) { + return <Redirect to={`/${SiemPageName.case}/${caseData.id}`} />; } + if (isCancel) { return <Redirect to={`/${SiemPageName.case}`} />; } + return ( <EuiPanel> {isLoading && <MySpinner size="xl" />} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx index c81a31f0d4f3f..91d3b77493b03 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx @@ -4,13 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ +import { CaseRequest } from '../../../../../../../../plugins/case/common/api'; import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../../shared_imports'; -import { OptionalFieldLabel } from './optional_field_label'; import * as i18n from '../../translations'; +import { OptionalFieldLabel } from './optional_field_label'; const { emptyField } = fieldValidators; -export const schema: FormSchema = { +export const schemaTags = { + type: FIELD_TYPES.COMBO_BOX, + label: i18n.TAGS, + helpText: i18n.TAGS_HELP, + labelAppend: OptionalFieldLabel, +}; + +export const schema: FormSchema<CaseRequest> = { title: { type: FIELD_TYPES.TEXT, label: i18n.NAME, @@ -28,10 +36,5 @@ export const schema: FormSchema = { }, ], }, - tags: { - type: FIELD_TYPES.COMBO_BOX, - label: i18n.TAGS, - helpText: i18n.TAGS_HELP, - labelAppend: OptionalFieldLabel, - }, + tags: schemaTags, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/open_closed_stats/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/open_closed_stats/index.tsx new file mode 100644 index 0000000000000..8d0fafdfc36ca --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/open_closed_stats/index.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Dispatch, useEffect, useMemo } from 'react'; +import { EuiDescriptionList, EuiLoadingSpinner } from '@elastic/eui'; +import * as i18n from '../all_cases/translations'; +import { CaseCount } from '../../../../containers/case/use_get_cases'; + +export interface Props { + caseCount: CaseCount; + caseState: 'open' | 'closed'; + getCaseCount: Dispatch<keyof CaseCount>; + isLoading: boolean; +} + +export const OpenClosedStats = React.memo<Props>( + ({ caseCount, caseState, getCaseCount, isLoading }) => { + useEffect(() => { + getCaseCount(caseState); + }, [caseState]); + + const openClosedStats = useMemo( + () => [ + { + title: caseState === 'open' ? i18n.OPEN_CASES : i18n.CLOSED_CASES, + description: isLoading ? <EuiLoadingSpinner /> : caseCount[caseState], + }, + ], + [caseCount, caseState, isLoading] + ); + return <EuiDescriptionList textStyle="reverse" listItems={openClosedStats} />; + } +); + +OpenClosedStats.displayName = 'OpenClosedStats'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx index 26a89408069fb..50ba114de528e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import { FormSchema } from '../../../../shared_imports'; -import { schema as createSchema } from '../create/schema'; +import { schemaTags } from '../create/schema'; export const schema: FormSchema = { - tags: createSchema.tags, + tags: schemaTags, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 63e0bbeb443c2..b68bfd73e50e9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -23,10 +23,8 @@ const DescriptionId = 'description'; const NewId = 'newComent'; export const UserActionTree = React.memo( - ({ data, onUpdateField, isLoadingDescription }: UserActionTreeProps) => { - const [{ data: comments, isLoadingIds }, dispatchUpdateComment] = useUpdateComment( - data.comments - ); + ({ data: caseData, onUpdateField, isLoadingDescription }: UserActionTreeProps) => { + const { comments, isLoadingIds, updateComment } = useUpdateComment(caseData.comments); const [manageMarkdownEditIds, setManangeMardownEditIds] = useState<string[]>([]); @@ -44,16 +42,16 @@ export const UserActionTree = React.memo( const handleSaveComment = useCallback( (id: string, content: string) => { handleManageMarkdownEditId(id); - dispatchUpdateComment(id, content); + updateComment(id, content); }, - [handleManageMarkdownEditId, dispatchUpdateComment] + [handleManageMarkdownEditId, updateComment] ); const MarkdownDescription = useMemo( () => ( <UserActionMarkdown id={DescriptionId} - content={data.description} + content={caseData.description} isEditable={manageMarkdownEditIds.includes(DescriptionId)} onSaveContent={(content: string) => { handleManageMarkdownEditId(DescriptionId); @@ -62,45 +60,45 @@ export const UserActionTree = React.memo( onChangeEditable={handleManageMarkdownEditId} /> ), - [data.description, handleManageMarkdownEditId, manageMarkdownEditIds, onUpdateField] + [caseData.description, handleManageMarkdownEditId, manageMarkdownEditIds, onUpdateField] ); - const MarkdownNewComment = useMemo(() => <AddComment caseId={data.caseId} />, [data.caseId]); + const MarkdownNewComment = useMemo(() => <AddComment caseId={caseData.id} />, [caseData.id]); return ( <> <UserActionItem - createdAt={data.createdAt} + createdAt={caseData.createdAt} id={DescriptionId} isEditable={manageMarkdownEditIds.includes(DescriptionId)} isLoading={isLoadingDescription} labelAction={i18n.EDIT_DESCRIPTION} labelTitle={i18n.ADDED_DESCRIPTION} - fullName={data.createdBy.fullName ?? data.createdBy.username} + fullName={caseData.createdBy.fullName ?? caseData.createdBy.username} markdown={MarkdownDescription} onEdit={handleManageMarkdownEditId.bind(null, DescriptionId)} - userName={data.createdBy.username} + userName={caseData.createdBy.username} /> {comments.map(comment => ( <UserActionItem - key={comment.commentId} + key={comment.id} createdAt={comment.createdAt} - id={comment.commentId} - isEditable={manageMarkdownEditIds.includes(comment.commentId)} - isLoading={isLoadingIds.includes(comment.commentId)} + id={comment.id} + isEditable={manageMarkdownEditIds.includes(comment.id)} + isLoading={isLoadingIds.includes(comment.id)} labelAction={i18n.EDIT_COMMENT} labelTitle={i18n.ADDED_COMMENT} fullName={comment.createdBy.fullName ?? comment.createdBy.username} markdown={ <UserActionMarkdown - id={comment.commentId} + id={comment.id} content={comment.comment} - isEditable={manageMarkdownEditIds.includes(comment.commentId)} + isEditable={manageMarkdownEditIds.includes(comment.id)} onChangeEditable={handleManageMarkdownEditId} - onSaveContent={handleSaveComment.bind(null, comment.commentId)} + onSaveContent={handleSaveComment.bind(null, comment.id)} /> } - onEdit={handleManageMarkdownEditId.bind(null, comment.commentId)} + onEdit={handleManageMarkdownEditId.bind(null, comment.id)} userName={comment.createdBy.username} /> ))} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 5f0509586fc81..fc64bd64ec4a2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -18,8 +18,8 @@ export const NAME = i18n.translate('xpack.siem.case.caseView.name', { defaultMessage: 'Name', }); -export const CREATED_AT = i18n.translate('xpack.siem.case.caseView.createdAt', { - defaultMessage: 'Created at', +export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', { + defaultMessage: 'Opened on', }); export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', { @@ -88,6 +88,21 @@ export const TAGS = i18n.translate('xpack.siem.case.caseView.tags', { defaultMessage: 'Tags', }); +export const NO_TAGS_AVAILABLE = i18n.translate('xpack.siem.case.allCases.noTagsAvailable', { + defaultMessage: 'No tags available', +}); + +export const NO_REPORTERS_AVAILABLE = i18n.translate( + 'xpack.siem.case.caseView.noReportersAvailable', + { + defaultMessage: 'No reporters available.', + } +); + +export const COMMENTS = i18n.translate('xpack.siem.case.allCases.comments', { + defaultMessage: 'Comments', +}); + export const TAGS_HELP = i18n.translate('xpack.siem.case.createCase.fieldTagsHelpText', { defaultMessage: 'Type one or more custom identifying tags for this case. Press enter after each tag to begin a new one.', diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/activity_monitor/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/activity_monitor/index.tsx index 4c7cfac33c546..31420ad07cd50 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/activity_monitor/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/activity_monitor/index.tsx @@ -13,7 +13,7 @@ import { UtilityBarGroup, UtilityBarSection, UtilityBarText, -} from '../../../../components/detection_engine/utility_bar'; +} from '../../../../components/utility_bar'; import { columns } from './columns'; import { ColumnTypes, PageTypes, SortTypes } from './types'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx index 86772eb0e155d..25c0424cadf11 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx @@ -13,7 +13,7 @@ import { UtilityBarGroup, UtilityBarSection, UtilityBarText, -} from '../../../../../components/detection_engine/utility_bar'; +} from '../../../../../components/utility_bar'; import * as i18n from './translations'; import { useUiSetting$ } from '../../../../../lib/kibana'; import { DEFAULT_NUMBER_FORMAT } from '../../../../../../common/constants'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index 2214190de6a16..8cbad4e89c106 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -42,7 +42,7 @@ export const getActions = ( ) => [ { description: i18n.EDIT_RULE_SETTINGS, - icon: 'visControls', + icon: 'controlsHorizontal', name: i18n.EDIT_RULE_SETTINGS, onClick: (rowItem: Rule) => editRuleAction(rowItem, history), enabled: (rowItem: Rule) => !rowItem.immutable, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index 9676b83a26f55..e7d68164c4ef4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -30,7 +30,7 @@ import { UtilityBarGroup, UtilityBarSection, UtilityBarText, -} from '../../../../components/detection_engine/utility_bar'; +} from '../../../../components/utility_bar'; import { useStateToaster } from '../../../../components/toasters'; import { Loader } from '../../../../components/loader'; import { Panel } from '../../../../components/panel'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx index 9a68797aea79b..97649fb03dac0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx @@ -113,8 +113,8 @@ export const ImportRuleModalComponent = ({ <EuiFilePicker id="rule-file-picker" initialPromptText={i18n.INITIAL_PROMPT_TEXT} - onChange={(files: FileList) => { - setSelectedFiles(Object.keys(files).length > 0 ? files : null); + onChange={(files: FileList | null) => { + setSelectedFiles(files && files.length > 0 ? files : null); }} display={'large'} fullWidth={true} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx index 83dd18f0f14b7..cd255b0951597 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx @@ -274,7 +274,7 @@ const RuleDetailsPageComponent: FC<PropsFromRedux> = ({ <EuiFlexItem grow={false}> <EuiButton href={getEditRuleUrl(ruleId ?? '')} - iconType="visControls" + iconType="controlsHorizontal" isDisabled={(userHasNoPermissions || rule?.immutable) ?? true} > {ruleI18n.EDIT_RULE_SETTINGS} diff --git a/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings.ts b/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings.ts deleted file mode 100644 index 80cdb9e979a68..0000000000000 --- a/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings.ts +++ /dev/null @@ -1,81 +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; - * you may not use this file except in compliance with the Elastic License. - */ -/* eslint-disable @typescript-eslint/no-empty-interface */ -/* eslint-disable @typescript-eslint/camelcase */ -import { CaseAttributes, CommentAttributes } from '../../../../../../../x-pack/plugins/case/server'; -import { ElasticsearchMappingOf } from '../../utils/typed_elasticsearch_mappings'; - -// Temporary file to write mappings for case -// while Saved Object Mappings API is programmed for the NP -// See: https://github.com/elastic/kibana/issues/50309 - -export const caseSavedObjectType = 'case-workflow'; -export const caseCommentSavedObjectType = 'case-workflow-comment'; - -export const caseSavedObjectMappings: { - [caseSavedObjectType]: ElasticsearchMappingOf<CaseAttributes>; -} = { - [caseSavedObjectType]: { - properties: { - created_at: { - type: 'date', - }, - description: { - type: 'text', - }, - title: { - type: 'keyword', - }, - created_by: { - properties: { - username: { - type: 'keyword', - }, - full_name: { - type: 'keyword', - }, - }, - }, - state: { - type: 'keyword', - }, - tags: { - type: 'keyword', - }, - updated_at: { - type: 'date', - }, - }, - }, -}; - -export const caseCommentSavedObjectMappings: { - [caseCommentSavedObjectType]: ElasticsearchMappingOf<CommentAttributes>; -} = { - [caseCommentSavedObjectType]: { - properties: { - comment: { - type: 'text', - }, - created_at: { - type: 'date', - }, - created_by: { - properties: { - full_name: { - type: 'keyword', - }, - username: { - type: 'keyword', - }, - }, - }, - updated_at: { - type: 'date', - }, - }, - }, -}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts index ec3db96ddc2f4..2b4fb8fa08a60 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts @@ -63,7 +63,7 @@ describe('add_prepackaged_rules_route', () => { }); test('returns 404 if alertClient is not available on the route', async () => { - context.alerting.getAlertsClient = jest.fn(); + context.alerting!.getAlertsClient = jest.fn(); const request = addPrepackagedRulesRequest(); const response = await server.inject(request, context); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts index 13373a2c2bbf0..4e08188af0d12 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts @@ -33,6 +33,9 @@ export const addPrepackedRulesRoute = (router: IRouter) => { const siemResponse = buildSiemResponse(response); try { + if (!context.alerting || !context.actions) { + return siemResponse.error({ statusCode: 404 }); + } const alertsClient = context.alerting.getAlertsClient(); const actionsClient = context.actions.getActionsClient(); const clusterClient = context.core.elasticsearch.dataClient; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index a497890b0599a..6ad9efebce2dd 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -39,7 +39,7 @@ describe('create_rules_bulk', () => { }); test('returns 404 if alertClient is not available on the route', async () => { - context.alerting.getAlertsClient = jest.fn(); + context.alerting!.getAlertsClient = jest.fn(); const response = await server.inject(getReadBulkRequest(), context); expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index 84841481a6c6f..ee8539faacf3e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -35,11 +35,14 @@ export const createRulesBulkRoute = (router: IRouter) => { }, }, async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + if (!context.alerting || !context.actions) { + return siemResponse.error({ statusCode: 404 }); + } const alertsClient = context.alerting.getAlertsClient(); const actionsClient = context.actions.getActionsClient(); const clusterClient = context.core.elasticsearch.dataClient; const siemClient = context.siem.getSiemClient(); - const siemResponse = buildSiemResponse(response); if (!actionsClient || !alertsClient) { return siemResponse.error({ statusCode: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index ab92f07852bfb..d019668e2a8d1 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -41,7 +41,7 @@ describe('create_rules', () => { }); test('returns 404 if alertClient is not available on the route', async () => { - context.alerting.getAlertsClient = jest.fn(); + context.alerting!.getAlertsClient = jest.fn(); const response = await server.inject(getCreateRequest(), context); expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index 312ebbee3cd8c..cef7ded2b50b4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -59,6 +59,9 @@ export const createRulesRoute = (router: IRouter): void => { const siemResponse = buildSiemResponse(response); try { + if (!context.alerting || !context.actions) { + return siemResponse.error({ statusCode: 404 }); + } const alertsClient = context.alerting.getAlertsClient(); const actionsClient = context.actions.getActionsClient(); const clusterClient = context.core.elasticsearch.dataClient; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts index f804d4c2e55ce..16f9a9524df55 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts @@ -82,7 +82,7 @@ describe('delete_rules', () => { }); test('returns 404 if alertClient is not available on the route', async () => { - context.alerting.getAlertsClient = jest.fn(); + context.alerting!.getAlertsClient = jest.fn(); const response = await server.inject(getDeleteBulkRequest(), context); expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts index c4a1e0bdb2c18..c56f34588cbc6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts @@ -32,10 +32,14 @@ export const deleteRulesBulkRoute = (router: IRouter) => { }, }; const handler: Handler = async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + + if (!context.alerting || !context.actions) { + return siemResponse.error({ statusCode: 404 }); + } const alertsClient = context.alerting.getAlertsClient(); const actionsClient = context.actions.getActionsClient(); const savedObjectsClient = context.core.savedObjects.client; - const siemResponse = buildSiemResponse(response); if (!actionsClient || !alertsClient) { return siemResponse.error({ statusCode: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts index 0e4c22057d706..0519addb275d6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts @@ -56,7 +56,7 @@ describe('delete_rules', () => { }); test('returns 404 if alertClient is not available on the route', async () => { - context.alerting.getAlertsClient = jest.fn(); + context.alerting!.getAlertsClient = jest.fn(); const response = await server.inject(getDeleteRequest(), context); expect(response.status).toEqual(404); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts index a637b7e0ef73e..753b281dbc09e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts @@ -33,6 +33,9 @@ export const deleteRulesRoute = (router: IRouter) => { try { const { id, rule_id: ruleId } = request.query; + if (!context.alerting || !context.actions) { + return siemResponse.error({ statusCode: 404 }); + } const alertsClient = context.alerting.getAlertsClient(); const actionsClient = context.actions.getActionsClient(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts index 88e14ad2b410b..c434f42780e47 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts @@ -27,8 +27,11 @@ export const exportRulesRoute = (router: IRouter, config: LegacyServices['config }, }, async (context, request, response) => { - const alertsClient = context.alerting.getAlertsClient(); const siemResponse = buildSiemResponse(response); + if (!context.alerting) { + return siemResponse.error({ statusCode: 404 }); + } + const alertsClient = context.alerting.getAlertsClient(); if (!alertsClient) { return siemResponse.error({ statusCode: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.test.ts index 4271dcd240546..57759844c100d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.test.ts @@ -36,7 +36,7 @@ describe('find_rules', () => { }); test('returns 404 if alertClient is not available on the route', async () => { - context.alerting.getAlertsClient = jest.fn(); + context.alerting!.getAlertsClient = jest.fn(); const response = await server.inject(getFindRequest(), context); expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.ts index 936957a3bb1ae..961859417ef1b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.ts @@ -32,6 +32,9 @@ export const findRulesRoute = (router: IRouter) => { try { const { query } = request; + if (!context.alerting) { + return siemResponse.error({ statusCode: 404 }); + } const alertsClient = context.alerting.getAlertsClient(); const savedObjectsClient = context.core.savedObjects.client; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts index 182a2c66b67c9..9c86b70b88270 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts @@ -29,7 +29,7 @@ describe('find_statuses', () => { }); test('returns 404 if alertClient is not available on the route', async () => { - context.alerting.getAlertsClient = jest.fn(); + context.alerting!.getAlertsClient = jest.fn(); const response = await server.inject(ruleStatusRequest(), context); expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.ts index f222fa419f440..4f4ae7c2c1fa6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.ts @@ -34,9 +34,12 @@ export const findRulesStatusesRoute = (router: IRouter) => { }, async (context, request, response) => { const { query } = request; + const siemResponse = buildSiemResponse(response); + if (!context.alerting) { + return siemResponse.error({ statusCode: 404 }); + } const alertsClient = context.alerting.getAlertsClient(); const savedObjectsClient = context.core.savedObjects.client; - const siemResponse = buildSiemResponse(response); if (!alertsClient) { return siemResponse.error({ statusCode: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts index 23309944f511e..03059ed5ec5cc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts @@ -58,7 +58,7 @@ describe('get_prepackaged_rule_status_route', () => { }); test('returns 404 if alertClient is not available on the route', async () => { - context.alerting.getAlertsClient = jest.fn(); + context.alerting!.getAlertsClient = jest.fn(); const response = await server.inject(getPrepackagedRulesStatusRequest(), context); expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts index ea20c2763886c..7e16b4495593e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts @@ -28,8 +28,11 @@ export const getPrepackagedRulesStatusRoute = (router: IRouter) => { }, }, async (context, request, response) => { - const alertsClient = context.alerting.getAlertsClient(); const siemResponse = buildSiemResponse(response); + if (!context.alerting) { + return siemResponse.error({ statusCode: 404 }); + } + const alertsClient = context.alerting.getAlertsClient(); if (!alertsClient) { return siemResponse.error({ statusCode: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index c2daa5e8f2f9f..c224e0f055b85 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -78,14 +78,14 @@ describe('import_rules_route', () => { }); test('returns 404 if alertClient is not available on the route', async () => { - context.alerting.getAlertsClient = jest.fn(); + context.alerting!.getAlertsClient = jest.fn(); const response = await server.inject(request, context); expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); }); test('returns 404 if actionsClient is not available on the route', async () => { - context.actions.getActionsClient = jest.fn(); + context.actions!.getActionsClient = jest.fn(); const response = await server.inject(request, context); expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index 38b409cc1dc5b..d9fc9b4e3c04f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -54,12 +54,16 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config }, }, async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + + if (!context.alerting || !context.actions) { + return siemResponse.error({ statusCode: 404 }); + } const alertsClient = context.alerting.getAlertsClient(); const actionsClient = context.actions.getActionsClient(); const clusterClient = context.core.elasticsearch.dataClient; const savedObjectsClient = context.core.savedObjects.client; const siemClient = context.siem.getSiemClient(); - const siemResponse = buildSiemResponse(response); if (!actionsClient || !alertsClient) { return siemResponse.error({ statusCode: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts index 1a7294682688a..19bcd2e7f0596 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts @@ -48,7 +48,7 @@ describe('patch_rules_bulk', () => { }); test('returns 404 if alertClient is not available on the route', async () => { - context.alerting.getAlertsClient = jest.fn(); + context.alerting!.getAlertsClient = jest.fn(); const response = await server.inject(getPatchBulkRequest(), context); expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index 40250aaa5d532..7ca16a75fb562 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -30,10 +30,14 @@ export const patchRulesBulkRoute = (router: IRouter) => { }, }, async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + + if (!context.alerting || !context.actions) { + return siemResponse.error({ statusCode: 404 }); + } const alertsClient = context.alerting.getAlertsClient(); const actionsClient = context.actions.getActionsClient(); const savedObjectsClient = context.core.savedObjects.client; - const siemResponse = buildSiemResponse(response); if (!actionsClient || !alertsClient) { return siemResponse.error({ statusCode: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts index 712adb460d6f2..1658de77e3390 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts @@ -49,7 +49,7 @@ describe('patch_rules', () => { }); test('returns 404 if alertClient is not available on the route', async () => { - context.alerting.getAlertsClient = jest.fn(); + context.alerting!.getAlertsClient = jest.fn(); const response = await server.inject(getPatchRequest(), context); expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts index 951a5c5abdb33..dce5f4037db1c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -60,6 +60,10 @@ export const patchRulesRoute = (router: IRouter) => { const siemResponse = buildSiemResponse(response); try { + if (!context.alerting || !context.actions) { + return siemResponse.error({ statusCode: 404 }); + } + const alertsClient = context.alerting.getAlertsClient(); const actionsClient = context.actions.getActionsClient(); const savedObjectsClient = context.core.savedObjects.client; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.test.ts index a6e84c45f17b4..7ebac9b785c82 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.test.ts @@ -36,7 +36,7 @@ describe('read_signals', () => { }); test('returns 404 if alertClient is not available on the route', async () => { - context.alerting.getAlertsClient = jest.fn(); + context.alerting!.getAlertsClient = jest.fn(); const response = await server.inject(getReadRequest(), context); expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.ts index 584beffa7abb1..e4117166ed4fa 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.ts @@ -30,9 +30,13 @@ export const readRulesRoute = (router: IRouter) => { }, async (context, request, response) => { const { id, rule_id: ruleId } = request.query; + const siemResponse = buildSiemResponse(response); + + if (!context.alerting) { + return siemResponse.error({ statusCode: 404 }); + } const alertsClient = context.alerting.getAlertsClient(); const savedObjectsClient = context.core.savedObjects.client; - const siemResponse = buildSiemResponse(response); try { if (!alertsClient) { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index 438b80302fae4..7a9159ecc852b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -53,7 +53,7 @@ describe('update_rules_bulk', () => { }); test('returns 404 if alertClient is not available on the route', async () => { - context.alerting.getAlertsClient = jest.fn(); + context.alerting!.getAlertsClient = jest.fn(); const response = await server.inject(getUpdateBulkRequest(), context); expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index 4607af524139d..953fb16d26ac6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -30,11 +30,15 @@ export const updateRulesBulkRoute = (router: IRouter) => { }, }, async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + + if (!context.alerting || !context.actions) { + return siemResponse.error({ statusCode: 404 }); + } const alertsClient = context.alerting.getAlertsClient(); const actionsClient = context.actions.getActionsClient(); const savedObjectsClient = context.core.savedObjects.client; const siemClient = context.siem.getSiemClient(); - const siemResponse = buildSiemResponse(response); if (!actionsClient || !alertsClient) { return siemResponse.error({ statusCode: 404 }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index ccdfacd7c3d5b..6ef508b817713 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -50,7 +50,7 @@ describe('update_rules', () => { }); test('returns 404 if alertClient is not available on the route', async () => { - context.alerting.getAlertsClient = jest.fn(); + context.alerting!.getAlertsClient = jest.fn(); const response = await server.inject(getUpdateRequest(), context); expect(response.status).toEqual(404); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index b5825a19f4762..fbb930d780f01 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -60,6 +60,9 @@ export const updateRulesRoute = (router: IRouter) => { const siemResponse = buildSiemResponse(response); try { + if (!context.alerting || !context.actions) { + return siemResponse.error({ statusCode: 404 }); + } const alertsClient = context.alerting.getAlertsClient(); const actionsClient = context.actions.getActionsClient(); const savedObjectsClient = context.core.savedObjects.client; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/tags/read_tags_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/tags/read_tags_route.ts index 4663928ac1e46..e12bf50169c17 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/tags/read_tags_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/tags/read_tags_route.ts @@ -19,9 +19,13 @@ export const readTagsRoute = (router: IRouter) => { }, }, async (context, request, response) => { - const alertsClient = context.alerting.getAlertsClient(); const siemResponse = buildSiemResponse(response); + if (!context.alerting) { + return siemResponse.error({ statusCode: 404 }); + } + const alertsClient = context.alerting.getAlertsClient(); + if (!alertsClient) { return siemResponse.error({ statusCode: 404 }); } diff --git a/x-pack/legacy/plugins/siem/server/saved_objects.ts b/x-pack/legacy/plugins/siem/server/saved_objects.ts index 58da333c7bc9a..76d8837883b8b 100644 --- a/x-pack/legacy/plugins/siem/server/saved_objects.ts +++ b/x-pack/legacy/plugins/siem/server/saved_objects.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { noteSavedObjectType, noteSavedObjectMappings } from './lib/note/saved_object_mappings'; import { pinnedEventSavedObjectType, @@ -16,10 +17,6 @@ import { ruleStatusSavedObjectMappings, ruleStatusSavedObjectType, } from './lib/detection_engine/rules/saved_object_mappings'; -import { - caseSavedObjectMappings, - caseCommentSavedObjectMappings, -} from './lib/case/saved_object_mappings'; export { noteSavedObjectType, @@ -31,8 +28,5 @@ export const savedObjectMappings = { ...timelineSavedObjectMappings, ...noteSavedObjectMappings, ...pinnedEventSavedObjectMappings, - // TODO: Remove once while Saved Object Mappings API is programmed for the NP See: https://github.com/elastic/kibana/issues/50309 - ...caseSavedObjectMappings, - ...caseCommentSavedObjectMappings, ...ruleStatusSavedObjectMappings, }; diff --git a/x-pack/legacy/plugins/transform/public/__mocks__/shared_imports.ts b/x-pack/legacy/plugins/transform/public/__mocks__/shared_imports.ts index aa130b5030fc7..d7fca9820e614 100644 --- a/x-pack/legacy/plugins/transform/public/__mocks__/shared_imports.ts +++ b/x-pack/legacy/plugins/transform/public/__mocks__/shared_imports.ts @@ -6,12 +6,14 @@ jest.mock('ui/new_platform'); -export function XJsonMode() {} -export function setDependencyCache() {} -export const useRequest = () => ({ +export const expandLiteralStrings = jest.fn(); +export const XJsonMode = jest.fn(); +export const setDependencyCache = jest.fn(); +export const useRequest = jest.fn(() => ({ isLoading: false, error: null, data: undefined, -}); +})); export { mlInMemoryTableBasicFactory } from '../../../ml/public/application/components/ml_in_memory_table'; export const SORT_DIRECTION = { ASC: 'asc' }; +export const KqlFilterBar = jest.fn(() => null); diff --git a/x-pack/legacy/plugins/transform/public/app/app_dependencies.tsx b/x-pack/legacy/plugins/transform/public/app/app_dependencies.tsx index 21ffbf5911a21..ec1b896249be6 100644 --- a/x-pack/legacy/plugins/transform/public/app/app_dependencies.tsx +++ b/x-pack/legacy/plugins/transform/public/app/app_dependencies.tsx @@ -27,7 +27,6 @@ const setAppDependencies = (deps: AppDependencies) => { autocomplete: deps.plugins.data.autocomplete, docLinks: deps.core.docLinks, basePath: legacyBasePath as any, - XSRF: deps.plugins.xsrfToken, }); DependenciesContext = createContext<AppDependencies>(deps); return DependenciesContext.Provider; diff --git a/x-pack/legacy/plugins/transform/public/app/common/request.ts b/x-pack/legacy/plugins/transform/public/app/common/request.ts index 3b740de177ef8..31089b86a2c2d 100644 --- a/x-pack/legacy/plugins/transform/public/app/common/request.ts +++ b/x-pack/legacy/plugins/transform/public/app/common/request.ts @@ -7,7 +7,7 @@ import { DefaultOperator } from 'elasticsearch'; import { dictionaryToArray } from '../../../common/types/common'; -import { SavedSearchQuery } from '../lib/kibana'; +import { SavedSearchQuery } from '../hooks/use_search_items'; import { StepDefineExposedState } from '../sections/create_transform/components/step_define/step_define_form'; import { StepDetailsExposedState } from '../sections/create_transform/components/step_details/step_details_form'; diff --git a/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.test.tsx b/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.test.tsx index 81af5c974fe04..095b57de97d9a 100644 --- a/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.test.tsx @@ -7,13 +7,13 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { KibanaContext } from '../lib/kibana'; import { createPublicShim } from '../../shim'; import { getAppProviders } from '../app_dependencies'; import { ToastNotificationText } from './toast_notification_text'; jest.mock('../../shared_imports'); +jest.mock('ui/new_platform'); describe('ToastNotificationText', () => { test('should render the text as plain text', () => { @@ -23,9 +23,7 @@ describe('ToastNotificationText', () => { }; const { container } = render( <Providers> - <KibanaContext.Provider value={{ initialized: false }}> - <ToastNotificationText {...props} /> - </KibanaContext.Provider> + <ToastNotificationText {...props} /> </Providers> ); expect(container.textContent).toBe('a short text message'); @@ -39,9 +37,7 @@ describe('ToastNotificationText', () => { }; const { container } = render( <Providers> - <KibanaContext.Provider value={{ initialized: false }}> - <ToastNotificationText {...props} /> - </KibanaContext.Provider> + <ToastNotificationText {...props} /> </Providers> ); expect(container.textContent).toBe( diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_api.ts b/x-pack/legacy/plugins/transform/public/app/hooks/use_api.ts index 802599aaedd4f..b7ce5e5298b2f 100644 --- a/x-pack/legacy/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/legacy/plugins/transform/public/app/hooks/use_api.ts @@ -94,10 +94,9 @@ const apiFactory = (basePath: string, indicesBasePath: string, http: Http) => ({ export const useApi = () => { const appDeps = useAppDependencies(); - const basePath = appDeps.core.http.basePath.prepend('/api/transform'); - const indicesBasePath = appDeps.core.http.basePath.prepend('/api'); - const xsrfToken = appDeps.plugins.xsrfToken; - const http = httpFactory(xsrfToken); + const basePath = '/api/transform'; + const indicesBasePath = '/api'; + const http = httpFactory(appDeps.core.http); return apiFactory(basePath, indicesBasePath, http); }; diff --git a/x-pack/legacy/plugins/transform/public/app/lib/kibana/common.ts b/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/common.ts similarity index 96% rename from x-pack/legacy/plugins/transform/public/app/lib/kibana/common.ts rename to x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/common.ts index aa4cd21281e22..2258f8f33f01d 100644 --- a/x-pack/legacy/plugins/transform/public/app/lib/kibana/common.ts +++ b/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/common.ts @@ -14,6 +14,8 @@ import { import { matchAllQuery } from '../../common'; +export type SavedSearchQuery = object; + type IndexPatternId = string; type SavedSearchId = string; @@ -60,7 +62,7 @@ export function getIndexPatternIdByTitle(indexPatternTitle: string): string | un return indexPatternCache.find(d => d?.attributes?.title === indexPatternTitle)?.id; } -type CombinedQuery = Record<'bool', any> | unknown; +type CombinedQuery = Record<'bool', any> | object; export function loadCurrentIndexPattern( indexPatterns: IndexPatternsContract, @@ -79,17 +81,20 @@ export function loadCurrentSavedSearch(savedSearches: any, savedSearchId: SavedS function isIndexPattern(arg: any): arg is IndexPattern { return arg !== undefined; } + +export interface SearchItems { + indexPattern: IndexPattern; + savedSearch: any; + query: any; + combinedQuery: CombinedQuery; +} + // Helper for creating the items used for searching and job creation. export function createSearchItems( indexPattern: IndexPattern | undefined, savedSearch: any, config: IUiSettingsClient -): { - indexPattern: IndexPattern; - savedSearch: any; - query: any; - combinedQuery: CombinedQuery; -} { +): SearchItems { // query is only used by the data visualizer as it needs // a lucene query_string. // Using a blank query will cause match_all:{} to be used diff --git a/x-pack/plugins/case/server/constants.ts b/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/index.ts similarity index 67% rename from x-pack/plugins/case/server/constants.ts rename to x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/index.ts index 276dcd135254a..aa4f04f43b335 100644 --- a/x-pack/plugins/case/server/constants.ts +++ b/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export const CASE_SAVED_OBJECT = 'case-workflow'; -export const CASE_COMMENT_SAVED_OBJECT = 'case-workflow-comment'; +export { SavedSearchQuery, SearchItems } from './common'; +export { useSearchItems } from './use_search_items'; diff --git a/x-pack/legacy/plugins/transform/public/app/lib/kibana/kibana_provider.tsx b/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts similarity index 53% rename from x-pack/legacy/plugins/transform/public/app/lib/kibana/kibana_provider.tsx rename to x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts index f2574a4a85f29..12fc75c20ffa4 100644 --- a/x-pack/legacy/plugins/transform/public/app/lib/kibana/kibana_provider.tsx +++ b/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts @@ -4,30 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState, FC } from 'react'; +import { useEffect, useState } from 'react'; + +import { createSavedSearchesLoader } from '../../../shared_imports'; import { useAppDependencies } from '../../app_dependencies'; import { createSearchItems, + getIndexPatternIdByTitle, loadCurrentIndexPattern, loadIndexPatterns, loadCurrentSavedSearch, + SearchItems, } from './common'; -import { InitializedKibanaContextValue, KibanaContext, KibanaContextValue } from './kibana_context'; - -interface Props { - savedObjectId: string; -} +export const useSearchItems = (defaultSavedObjectId: string | undefined) => { + const [savedObjectId, setSavedObjectId] = useState(defaultSavedObjectId); -export const KibanaProvider: FC<Props> = ({ savedObjectId, children }) => { const appDeps = useAppDependencies(); const indexPatterns = appDeps.plugins.data.indexPatterns; + const uiSettings = appDeps.core.uiSettings; const savedObjectsClient = appDeps.core.savedObjects.client; - const savedSearches = appDeps.plugins.savedSearches.getClient(); + const savedSearches = createSavedSearchesLoader({ + savedObjectsClient, + indexPatterns, + chrome: appDeps.core.chrome, + overlays: appDeps.core.overlays, + }); - const [contextValue, setContextValue] = useState<KibanaContextValue>({ initialized: false }); + const [searchItems, setSearchItems] = useState<SearchItems | undefined>(undefined); async function fetchSavedObject(id: string) { await loadIndexPatterns(savedObjectsClient, indexPatterns); @@ -47,31 +53,21 @@ export const KibanaProvider: FC<Props> = ({ savedObjectId, children }) => { // Just let fetchedSavedSearch stay undefined in case it doesn't exist. } - const kibanaConfig = appDeps.core.uiSettings; - - const { - indexPattern: currentIndexPattern, - savedSearch: currentSavedSearch, - combinedQuery, - } = createSearchItems(fetchedIndexPattern, fetchedSavedSearch, kibanaConfig); - - const kibanaContext: InitializedKibanaContextValue = { - indexPatterns, - initialized: true, - kibanaConfig, - combinedQuery, - currentIndexPattern, - currentSavedSearch, - }; - - setContextValue(kibanaContext); + setSearchItems(createSearchItems(fetchedIndexPattern, fetchedSavedSearch, uiSettings)); } useEffect(() => { - fetchSavedObject(savedObjectId); - // fetchSavedObject should not be tracked. + if (savedObjectId !== undefined) { + fetchSavedObject(savedObjectId); + } + // Run this only when savedObjectId changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [savedObjectId]); - return <KibanaContext.Provider value={contextValue}>{children}</KibanaContext.Provider>; + return { + getIndexPatternIdByTitle, + loadIndexPatterns, + searchItems, + setSavedObjectId, + }; }; diff --git a/x-pack/legacy/plugins/transform/public/app/lib/kibana/index.ts b/x-pack/legacy/plugins/transform/public/app/lib/kibana/index.ts deleted file mode 100644 index 62107cb37ff2c..0000000000000 --- a/x-pack/legacy/plugins/transform/public/app/lib/kibana/index.ts +++ /dev/null @@ -1,17 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -export { getIndexPatternIdByTitle, loadIndexPatterns } from './common'; -export { - useKibanaContext, - InitializedKibanaContextValue, - KibanaContext, - KibanaContextValue, - SavedSearchQuery, - RenderOnlyWithInitializedKibanaContext, -} from './kibana_context'; -export { KibanaProvider } from './kibana_provider'; -export { useCurrentIndexPattern } from './use_current_index_pattern'; diff --git a/x-pack/legacy/plugins/transform/public/app/lib/kibana/kibana_context.tsx b/x-pack/legacy/plugins/transform/public/app/lib/kibana/kibana_context.tsx deleted file mode 100644 index 7677c491a7a59..0000000000000 --- a/x-pack/legacy/plugins/transform/public/app/lib/kibana/kibana_context.tsx +++ /dev/null @@ -1,72 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { createContext, useContext, FC } from 'react'; - -import { IUiSettingsClient } from 'kibana/public'; - -import { - IndexPattern, - IndexPatternsContract, -} from '../../../../../../../../src/plugins/data/public'; -import { SavedSearch } from '../../../../../../../../src/plugins/discover/public/'; - -interface UninitializedKibanaContextValue { - initialized: false; -} - -export interface InitializedKibanaContextValue { - combinedQuery: any; - indexPatterns: IndexPatternsContract; - initialized: true; - kibanaConfig: IUiSettingsClient; - currentIndexPattern: IndexPattern; - currentSavedSearch?: SavedSearch; -} - -export type KibanaContextValue = UninitializedKibanaContextValue | InitializedKibanaContextValue; - -export function isKibanaContextInitialized(arg: any): arg is InitializedKibanaContextValue { - return arg.initialized; -} - -export type SavedSearchQuery = object; - -export const KibanaContext = createContext<KibanaContextValue>({ initialized: false }); - -/** - * Custom hook to get the current kibanaContext. - * - * @remarks - * This hook should only be used in components wrapped in `RenderOnlyWithInitializedKibanaContext`, - * otherwise it will throw an error when KibanaContext hasn't been initialized yet. - * In return you get the benefit of not having to check if it's been initialized in the component - * where it's used. - * - * @returns `kibanaContext` - */ -export const useKibanaContext = () => { - const kibanaContext = useContext(KibanaContext); - - if (!isKibanaContextInitialized(kibanaContext)) { - throw new Error('useKibanaContext: kibanaContext not initialized'); - } - - return kibanaContext; -}; - -/** - * Wrapper component to render children only if `kibanaContext` has been initialized. - * In combination with `useKibanaContext` this avoids having to check for the initialization - * in consuming components. - * - * @returns `children` or `null` depending on whether `kibanaContext` is initialized or not. - */ -export const RenderOnlyWithInitializedKibanaContext: FC = ({ children }) => { - const kibanaContext = useContext(KibanaContext); - - return isKibanaContextInitialized(kibanaContext) ? <>{children}</> : null; -}; diff --git a/x-pack/legacy/plugins/transform/public/app/lib/kibana/use_current_index_pattern.ts b/x-pack/legacy/plugins/transform/public/app/lib/kibana/use_current_index_pattern.ts deleted file mode 100644 index 12c5bde171b8b..0000000000000 --- a/x-pack/legacy/plugins/transform/public/app/lib/kibana/use_current_index_pattern.ts +++ /dev/null @@ -1,19 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useContext } from 'react'; - -import { isKibanaContextInitialized, KibanaContext } from './kibana_context'; - -export const useCurrentIndexPattern = () => { - const context = useContext(KibanaContext); - - if (!isKibanaContextInitialized(context)) { - throw new Error('useCurrentIndexPattern: kibanaContext not initialized'); - } - - return context.currentIndexPattern; -}; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx b/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx index c5c46dcac6c95..4618e96cbfd6e 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx @@ -22,6 +22,7 @@ import { } from '@elastic/eui'; import { useApi } from '../../hooks/use_api'; +import { useSearchItems } from '../../hooks/use_search_items'; import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; @@ -29,12 +30,6 @@ import { useAppDependencies, useDocumentationLinks } from '../../app_dependencie import { TransformPivotConfig } from '../../common'; import { breadcrumbService, docTitleService, BREADCRUMB_SECTION } from '../../services/navigation'; import { PrivilegesWrapper } from '../../lib/authorization'; -import { - getIndexPatternIdByTitle, - loadIndexPatterns, - KibanaProvider, - RenderOnlyWithInitializedKibanaContext, -} from '../../lib/kibana'; import { Wizard } from '../create_transform/components/wizard'; @@ -80,7 +75,12 @@ export const CloneTransformSection: FC<Props> = ({ match }) => { const [transformConfig, setTransformConfig] = useState<TransformPivotConfig>(); const [errorMessage, setErrorMessage] = useState(); const [isInitialized, setIsInitialized] = useState(false); - const [savedObjectId, setSavedObjectId] = useState<string | undefined>(undefined); + const { + getIndexPatternIdByTitle, + loadIndexPatterns, + searchItems, + setSavedObjectId, + } = useSearchItems(undefined); const fetchTransformConfig = async () => { try { @@ -169,12 +169,8 @@ export const CloneTransformSection: FC<Props> = ({ match }) => { <pre>{JSON.stringify(errorMessage)}</pre> </EuiCallOut> )} - {savedObjectId !== undefined && isInitialized === true && transformConfig !== undefined && ( - <KibanaProvider savedObjectId={savedObjectId}> - <RenderOnlyWithInitializedKibanaContext> - <Wizard cloneConfig={transformConfig} /> - </RenderOnlyWithInitializedKibanaContext> - </KibanaProvider> + {searchItems !== undefined && isInitialized === true && transformConfig !== undefined && ( + <Wizard cloneConfig={transformConfig} searchItems={searchItems} /> )} </EuiPageContentBody> </EuiPageContent> diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx index 9ff235fb40d8a..157e0f76856c8 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx @@ -6,12 +6,12 @@ import React from 'react'; -import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; interface Props { - options: EuiComboBoxOptionProps[]; + options: EuiComboBoxOptionOption[]; placeholder?: string; - changeHandler(d: EuiComboBoxOptionProps[]): void; + changeHandler(d: EuiComboBoxOptionOption[]): void; testSubj?: string; } diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap deleted file mode 100644 index e43f2e37bb416..0000000000000 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap +++ /dev/null @@ -1,24 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Transform: <SourceIndexPreview /> Minimal initialization 1`] = ` -<div> - <ContextProvider - value={ - Object { - "initialized": false, - } - } - > - <Component - query={ - Object { - "query_string": Object { - "default_operator": "AND", - "query": "the-query", - }, - } - } - /> - </ContextProvider> -</div> -`; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.test.tsx index bfde8f171874e..ddd1a1482fd35 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.test.tsx @@ -39,8 +39,6 @@ describe('Transform: <ExpandedRow />', () => { }, }; - // Using a wrapping <div> element because shallow() would fail - // with the Provider being the outer most component. const wrapper = shallow(<ExpandedRow {...props} />); expect(wrapper).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.test.tsx index 16949425284fd..48eff132cd753 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.test.tsx @@ -4,38 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; -import { KibanaContext } from '../../../../lib/kibana'; +import { createPublicShim } from '../../../../../shim'; +import { getAppProviders } from '../../../../app_dependencies'; import { getPivotQuery } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; import { SourceIndexPreview } from './source_index_preview'; -// workaround to make React.memo() work with enzyme -jest.mock('react', () => { - const r = jest.requireActual('react'); - return { ...r, memo: (x: any) => x }; -}); - +jest.mock('ui/new_platform'); jest.mock('../../../../../shared_imports'); describe('Transform: <SourceIndexPreview />', () => { test('Minimal initialization', () => { + // Arrange const props = { + indexPattern: { + title: 'the-index-pattern-title', + fields: [] as any[], + } as SearchItems['indexPattern'], query: getPivotQuery('the-query'), }; - - // Using a wrapping <div> element because shallow() would fail - // with the Provider being the outer most component. - const wrapper = shallow( - <div> - <KibanaContext.Provider value={{ initialized: false }}> - <SourceIndexPreview {...props} /> - </KibanaContext.Provider> - </div> + const Providers = getAppProviders(createPublicShim()); + const { getByText } = render( + <Providers> + <SourceIndexPreview {...props} /> + </Providers> ); - expect(wrapper).toMatchSnapshot(); + // Act + // Assert + expect(getByText(`Source index ${props.indexPattern.title}`)).toBeInTheDocument(); }); }); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx index 0c9dcfb9b1c04..76ed12ff772f5 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx @@ -22,14 +22,13 @@ import { import { getNestedProperty } from '../../../../../../common/utils/object_utils'; -import { useCurrentIndexPattern } from '../../../../lib/kibana'; - import { euiDataGridStyle, euiDataGridToolbarSettings, EsFieldName, PivotQuery, } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; import { getSourceIndexDevConsoleStatement } from './common'; import { SOURCE_INDEX_STATUS, useSourceIndexData } from './use_source_index_data'; @@ -49,13 +48,13 @@ const SourceIndexPreviewTitle: React.FC<SourceIndexPreviewTitle> = ({ indexPatte ); interface Props { + indexPattern: SearchItems['indexPattern']; query: PivotQuery; } const defaultPagination = { pageIndex: 0, pageSize: 5 }; -export const SourceIndexPreview: React.FC<Props> = React.memo(({ query }) => { - const indexPattern = useCurrentIndexPattern(); +export const SourceIndexPreview: React.FC<Props> = React.memo(({ indexPattern, query }) => { const allFields = indexPattern.fields.map(f => f.name); const indexPatternFields: string[] = allFields.filter(f => { if (indexPattern.metaFields.includes(f)) { diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/__snapshots__/step_create_form.test.tsx.snap b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/__snapshots__/step_create_form.test.tsx.snap deleted file mode 100644 index e034badea9b11..0000000000000 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/__snapshots__/step_create_form.test.tsx.snap +++ /dev/null @@ -1,27 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Transform: <StepCreateForm /> Minimal initialization 1`] = ` -<div> - <ContextProvider - value={ - Object { - "initialized": false, - } - } - > - <Component - createIndexPattern={false} - onChange={[Function]} - overrides={ - Object { - "created": false, - "indexPatternId": undefined, - "started": false, - } - } - transformConfig={Object {}} - transformId="the-transform-id" - /> - </ContextProvider> -</div> -`; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.test.tsx index 625c545ee8c46..7a22af492e36e 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.test.tsx @@ -4,23 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; -import { KibanaContext } from '../../../../lib/kibana'; +import { createPublicShim } from '../../../../../shim'; +import { getAppProviders } from '../../../../app_dependencies'; import { StepCreateForm } from './step_create_form'; -// workaround to make React.memo() work with enzyme -jest.mock('react', () => { - const r = jest.requireActual('react'); - return { ...r, memo: (x: any) => x }; -}); - +jest.mock('ui/new_platform'); jest.mock('../../../../../shared_imports'); describe('Transform: <StepCreateForm />', () => { test('Minimal initialization', () => { + // Arrange const props = { createIndexPattern: false, transformId: 'the-transform-id', @@ -29,16 +27,15 @@ describe('Transform: <StepCreateForm />', () => { onChange() {}, }; - // Using a wrapping <div> element because shallow() would fail - // with the Provider being the outer most component. - const wrapper = shallow( - <div> - <KibanaContext.Provider value={{ initialized: false }}> - <StepCreateForm {...props} /> - </KibanaContext.Provider> - </div> + const Providers = getAppProviders(createPublicShim()); + const { getByText } = render( + <Providers> + <StepCreateForm {...props} /> + </Providers> ); - expect(wrapper).toMatchSnapshot(); + // Act + // Assert + expect(getByText('Create and start')).toBeInTheDocument(); }); }); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx index bbeb97b6b8113..4198c2ea0260d 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx @@ -34,7 +34,6 @@ import { PROGRESS_REFRESH_INTERVAL_MS } from '../../../../../../common/constants import { getTransformProgress, getDiscoverUrl } from '../../../../common'; import { useApi } from '../../../../hooks/use_api'; -import { useKibanaContext } from '../../../../lib/kibana'; import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies'; import { RedirectToTransformManagement } from '../../../../common/navigation'; import { ToastNotificationText } from '../../../../components'; @@ -76,7 +75,8 @@ export const StepCreateForm: FC<Props> = React.memo( ); const deps = useAppDependencies(); - const kibanaContext = useKibanaContext(); + const indexPatterns = deps.plugins.data.indexPatterns; + const uiSettings = deps.core.uiSettings; const toastNotifications = useToastNotifications(); useEffect(() => { @@ -176,7 +176,7 @@ export const StepCreateForm: FC<Props> = React.memo( const indexPatternName = transformConfig.dest.index; try { - const newIndexPattern = await kibanaContext.indexPatterns.make(); + const newIndexPattern = await indexPatterns.make(); Object.assign(newIndexPattern, { id: '', @@ -200,8 +200,8 @@ export const StepCreateForm: FC<Props> = React.memo( // check if there's a default index pattern, if not, // set the newly created one as the default index pattern. - if (!kibanaContext.kibanaConfig.get('defaultIndex')) { - await kibanaContext.kibanaConfig.set('defaultIndex', id); + if (!uiSettings.get('defaultIndex')) { + await uiSettings.set('defaultIndex', id); } toastNotifications.addSuccess( diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/pivot_preview.test.tsx.snap b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/pivot_preview.test.tsx.snap deleted file mode 100644 index a7da172a67b8a..0000000000000 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/pivot_preview.test.tsx.snap +++ /dev/null @@ -1,44 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Transform: <PivotPreview /> Minimal initialization 1`] = ` -<div> - <ContextProvider - value={ - Object { - "initialized": false, - } - } - > - <Component - aggs={ - Object { - "the-agg-name": Object { - "agg": "avg", - "aggName": "the-group-by-agg-name", - "dropDownName": "the-group-by-drop-down-name", - "field": "the-agg-field", - }, - } - } - groupBy={ - Object { - "the-group-by-name": Object { - "agg": "terms", - "aggName": "the-group-by-agg-name", - "dropDownName": "the-group-by-drop-down-name", - "field": "the-group-by-field", - }, - } - } - query={ - Object { - "query_string": Object { - "default_operator": "AND", - "query": "the-query", - }, - } - } - /> - </ContextProvider> -</div> -`; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_form.test.tsx.snap b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_form.test.tsx.snap deleted file mode 100644 index 70a0bfc12b208..0000000000000 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_form.test.tsx.snap +++ /dev/null @@ -1,17 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Transform: <DefinePivotForm /> Minimal initialization 1`] = ` -<div> - <ContextProvider - value={ - Object { - "initialized": false, - } - } - > - <Component - onChange={[Function]} - /> - </ContextProvider> -</div> -`; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_summary.test.tsx.snap b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_summary.test.tsx.snap deleted file mode 100644 index b18233e5c53e3..0000000000000 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_summary.test.tsx.snap +++ /dev/null @@ -1,42 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Transform: <DefinePivotSummary /> Minimal initialization 1`] = ` -<div> - <ContextProvider - value={ - Object { - "initialized": false, - } - } - > - <StepDefineSummary - aggList={ - Object { - "the-agg-name": Object { - "agg": "avg", - "aggName": "the-group-by-agg-name", - "dropDownName": "the-group-by-drop-down-name", - "field": "the-agg-field", - }, - } - } - groupByList={ - Object { - "the-group-by-name": Object { - "agg": "terms", - "aggName": "the-group-by-agg-name", - "dropDownName": "the-group-by-drop-down-name", - "field": "the-group-by-field", - }, - } - } - isAdvancedPivotEditorEnabled={false} - isAdvancedSourceEditorEnabled={false} - searchQuery="the-search-query" - searchString="the-query" - sourceConfigUpdated={false} - valid={true} - /> - </ContextProvider> -</div> -`; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts index 7b78d4ffccfa1..35e1ea02a5cef 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { get } from 'lodash'; -import { EuiComboBoxOptionProps, EuiDataGridSorting } from '@elastic/eui'; +import { EuiComboBoxOptionOption, EuiDataGridSorting } from '@elastic/eui'; import { IndexPattern, KBN_FIELD_TYPES, @@ -112,11 +112,11 @@ const illegalEsAggNameChars = /[[\]>]/g; export function getPivotDropdownOptions(indexPattern: IndexPattern) { // The available group by options - const groupByOptions: EuiComboBoxOptionProps[] = []; + const groupByOptions: EuiComboBoxOptionOption[] = []; const groupByOptionsData: PivotGroupByConfigWithUiSupportDict = {}; // The available aggregations - const aggOptions: EuiComboBoxOptionProps[] = []; + const aggOptions: EuiComboBoxOptionOption[] = []; const aggOptionsData: PivotAggsConfigWithUiSupportDict = {}; const ignoreFieldNames = ['_id', '_index', '_type']; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.test.tsx index 2ac4295da1eed..464b6e1fd9fe3 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.test.tsx @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; -import { KibanaContext } from '../../../../lib/kibana'; - +import { createPublicShim } from '../../../../../shim'; +import { getAppProviders } from '../../../../app_dependencies'; import { getPivotQuery, PivotAggsConfig, @@ -16,19 +17,16 @@ import { PIVOT_SUPPORTED_AGGS, PIVOT_SUPPORTED_GROUP_BY_AGGS, } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; import { PivotPreview } from './pivot_preview'; -// workaround to make React.memo() work with enzyme -jest.mock('react', () => { - const r = jest.requireActual('react'); - return { ...r, memo: (x: any) => x }; -}); - +jest.mock('ui/new_platform'); jest.mock('../../../../../shared_imports'); describe('Transform: <PivotPreview />', () => { test('Minimal initialization', () => { + // Arrange const groupBy: PivotGroupByConfig = { agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS, field: 'the-group-by-field', @@ -44,19 +42,22 @@ describe('Transform: <PivotPreview />', () => { const props = { aggs: { 'the-agg-name': agg }, groupBy: { 'the-group-by-name': groupBy }, + indexPattern: { + title: 'the-index-pattern-title', + fields: [] as any[], + } as SearchItems['indexPattern'], query: getPivotQuery('the-query'), }; - // Using a wrapping <div> element because shallow() would fail - // with the Provider being the outer most component. - const wrapper = shallow( - <div> - <KibanaContext.Provider value={{ initialized: false }}> - <PivotPreview {...props} /> - </KibanaContext.Provider> - </div> + const Providers = getAppProviders(createPublicShim()); + const { getByText } = render( + <Providers> + <PivotPreview {...props} /> + </Providers> ); - expect(wrapper).toMatchSnapshot(); + // Act + // Assert + expect(getByText('Transform pivot preview')).toBeInTheDocument(); }); }); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx index b755956eae24e..9b32bbbae839e 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx @@ -24,8 +24,6 @@ import { import { dictionaryToArray } from '../../../../../../common/types/common'; import { getNestedProperty } from '../../../../../../common/utils/object_utils'; -import { useCurrentIndexPattern } from '../../../../lib/kibana'; - import { euiDataGridStyle, euiDataGridToolbarSettings, @@ -36,6 +34,7 @@ import { PivotGroupByConfigDict, PivotQuery, } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; import { getPivotPreviewDevConsoleStatement, multiColumnSortFactory } from './common'; import { PIVOT_PREVIEW_STATUS, usePivotPreviewData } from './use_pivot_preview_data'; @@ -103,184 +102,186 @@ const ErrorMessage: FC<ErrorMessageProps> = ({ message }) => ( interface PivotPreviewProps { aggs: PivotAggsConfigDict; groupBy: PivotGroupByConfigDict; + indexPattern: SearchItems['indexPattern']; query: PivotQuery; } const defaultPagination = { pageIndex: 0, pageSize: 5 }; -export const PivotPreview: FC<PivotPreviewProps> = React.memo(({ aggs, groupBy, query }) => { - const indexPattern = useCurrentIndexPattern(); - - const { - previewData: data, - previewMappings, - errorMessage, - previewRequest, - status, - } = usePivotPreviewData(indexPattern, query, aggs, groupBy); - const groupByArr = dictionaryToArray(groupBy); - - // Filters mapping properties of type `object`, which get returned for nested field parents. - const columnKeys = Object.keys(previewMappings.properties).filter( - key => previewMappings.properties[key].type !== 'object' - ); - columnKeys.sort(sortColumns(groupByArr)); - - // Column visibility - const [visibleColumns, setVisibleColumns] = useState<EsFieldName[]>(columnKeys); - - useEffect(() => { - setVisibleColumns(columnKeys); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(columnKeys)]); - - const [pagination, setPagination] = useState(defaultPagination); - - // Reset pagination if data changes. This is to avoid ending up with an empty table - // when for example the user selected a page that is not available with the updated data. - useEffect(() => { - setPagination(defaultPagination); - }, [data.length]); - - // EuiDataGrid State - const dataGridColumns = columnKeys.map(id => ({ id })); - - const onChangeItemsPerPage = useCallback( - pageSize => { - setPagination(p => { - const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize); - return { pageIndex, pageSize }; - }); - }, - [setPagination] - ); +export const PivotPreview: FC<PivotPreviewProps> = React.memo( + ({ aggs, groupBy, indexPattern, query }) => { + const { + previewData: data, + previewMappings, + errorMessage, + previewRequest, + status, + } = usePivotPreviewData(indexPattern, query, aggs, groupBy); + const groupByArr = dictionaryToArray(groupBy); + + // Filters mapping properties of type `object`, which get returned for nested field parents. + const columnKeys = Object.keys(previewMappings.properties).filter( + key => previewMappings.properties[key].type !== 'object' + ); + columnKeys.sort(sortColumns(groupByArr)); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState<EsFieldName[]>(columnKeys); + + useEffect(() => { + setVisibleColumns(columnKeys); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(columnKeys)]); + + const [pagination, setPagination] = useState(defaultPagination); + + // Reset pagination if data changes. This is to avoid ending up with an empty table + // when for example the user selected a page that is not available with the updated data. + useEffect(() => { + setPagination(defaultPagination); + }, [data.length]); + + // EuiDataGrid State + const dataGridColumns = columnKeys.map(id => ({ id })); + + const onChangeItemsPerPage = useCallback( + pageSize => { + setPagination(p => { + const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize); + return { pageIndex, pageSize }; + }); + }, + [setPagination] + ); - const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [ - setPagination, - ]); + const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [ + setPagination, + ]); - // Sorting config - const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]); - const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]); + // Sorting config + const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]); + const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]); - if (sortingColumns.length > 0) { - data.sort(multiColumnSortFactory(sortingColumns)); - } + if (sortingColumns.length > 0) { + data.sort(multiColumnSortFactory(sortingColumns)); + } - const pageData = data.slice( - pagination.pageIndex * pagination.pageSize, - (pagination.pageIndex + 1) * pagination.pageSize - ); + const pageData = data.slice( + pagination.pageIndex * pagination.pageSize, + (pagination.pageIndex + 1) * pagination.pageSize + ); - const renderCellValue = useMemo(() => { - return ({ - rowIndex, - columnId, - setCellProps, - }: { - rowIndex: number; - columnId: string; - setCellProps: any; - }) => { - const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize; - - const cellValue = pageData.hasOwnProperty(adjustedRowIndex) - ? getNestedProperty(pageData[adjustedRowIndex], columnId, null) - : null; - - if (typeof cellValue === 'object' && cellValue !== null) { - return JSON.stringify(cellValue); - } + const renderCellValue = useMemo(() => { + return ({ + rowIndex, + columnId, + setCellProps, + }: { + rowIndex: number; + columnId: string; + setCellProps: any; + }) => { + const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize; + + const cellValue = pageData.hasOwnProperty(adjustedRowIndex) + ? getNestedProperty(pageData[adjustedRowIndex], columnId, null) + : null; + + if (typeof cellValue === 'object' && cellValue !== null) { + return JSON.stringify(cellValue); + } - if (cellValue === undefined) { - return null; - } + if (cellValue === undefined) { + return null; + } - return cellValue; - }; - }, [pageData, pagination.pageIndex, pagination.pageSize]); + return cellValue; + }; + }, [pageData, pagination.pageIndex, pagination.pageSize]); + + if (status === PIVOT_PREVIEW_STATUS.ERROR) { + return ( + <div data-test-subj="transformPivotPreview error"> + <PreviewTitle previewRequest={previewRequest} /> + <EuiCallOut + title={i18n.translate('xpack.transform.pivotPreview.PivotPreviewError', { + defaultMessage: 'An error occurred loading the pivot preview.', + })} + color="danger" + iconType="cross" + > + <ErrorMessage message={errorMessage} /> + </EuiCallOut> + </div> + ); + } - if (status === PIVOT_PREVIEW_STATUS.ERROR) { - return ( - <div data-test-subj="transformPivotPreview error"> - <PreviewTitle previewRequest={previewRequest} /> - <EuiCallOut - title={i18n.translate('xpack.transform.pivotPreview.PivotPreviewError', { - defaultMessage: 'An error occurred loading the pivot preview.', - })} - color="danger" - iconType="cross" - > - <ErrorMessage message={errorMessage} /> - </EuiCallOut> - </div> - ); - } + if (data.length === 0) { + let noDataMessage = i18n.translate( + 'xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody', + { + defaultMessage: + 'The preview request did not return any data. Please ensure the optional query returns data and that values exist for the field used by group-by and aggregation fields.', + } + ); - if (data.length === 0) { - let noDataMessage = i18n.translate( - 'xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody', - { - defaultMessage: - 'The preview request did not return any data. Please ensure the optional query returns data and that values exist for the field used by group-by and aggregation fields.', + const aggsArr = dictionaryToArray(aggs); + if (aggsArr.length === 0 || groupByArr.length === 0) { + noDataMessage = i18n.translate( + 'xpack.transform.pivotPreview.PivotPreviewIncompleteConfigCalloutBody', + { + defaultMessage: 'Please choose at least one group-by field and aggregation.', + } + ); } - ); - const aggsArr = dictionaryToArray(aggs); - if (aggsArr.length === 0 || groupByArr.length === 0) { - noDataMessage = i18n.translate( - 'xpack.transform.pivotPreview.PivotPreviewIncompleteConfigCalloutBody', - { - defaultMessage: 'Please choose at least one group-by field and aggregation.', - } + return ( + <div data-test-subj="transformPivotPreview empty"> + <PreviewTitle previewRequest={previewRequest} /> + <EuiCallOut + title={i18n.translate('xpack.transform.pivotPreview.PivotPreviewNoDataCalloutTitle', { + defaultMessage: 'Pivot preview not available', + })} + color="primary" + > + <p>{noDataMessage}</p> + </EuiCallOut> + </div> ); } + + if (columnKeys.length === 0) { + return null; + } + return ( - <div data-test-subj="transformPivotPreview empty"> + <div data-test-subj="transformPivotPreview loaded"> <PreviewTitle previewRequest={previewRequest} /> - <EuiCallOut - title={i18n.translate('xpack.transform.pivotPreview.PivotPreviewNoDataCalloutTitle', { - defaultMessage: 'Pivot preview not available', - })} - color="primary" - > - <p>{noDataMessage}</p> - </EuiCallOut> + <div className="transform__progress"> + {status === PIVOT_PREVIEW_STATUS.LOADING && <EuiProgress size="xs" color="accent" />} + {status !== PIVOT_PREVIEW_STATUS.LOADING && ( + <EuiProgress size="xs" color="accent" max={1} value={0} /> + )} + </div> + {dataGridColumns.length > 0 && data.length > 0 && ( + <EuiDataGrid + aria-label="Source index preview" + columns={dataGridColumns} + columnVisibility={{ visibleColumns, setVisibleColumns }} + gridStyle={euiDataGridStyle} + rowCount={data.length} + renderCellValue={renderCellValue} + sorting={{ columns: sortingColumns, onSort }} + toolbarVisibility={euiDataGridToolbarSettings} + pagination={{ + ...pagination, + pageSizeOptions: [5, 10, 25], + onChangeItemsPerPage, + onChangePage, + }} + /> + )} </div> ); } - - if (columnKeys.length === 0) { - return null; - } - - return ( - <div data-test-subj="transformPivotPreview loaded"> - <PreviewTitle previewRequest={previewRequest} /> - <div className="transform__progress"> - {status === PIVOT_PREVIEW_STATUS.LOADING && <EuiProgress size="xs" color="accent" />} - {status !== PIVOT_PREVIEW_STATUS.LOADING && ( - <EuiProgress size="xs" color="accent" max={1} value={0} /> - )} - </div> - {dataGridColumns.length > 0 && data.length > 0 && ( - <EuiDataGrid - aria-label="Source index preview" - columns={dataGridColumns} - columnVisibility={{ visibleColumns, setVisibleColumns }} - gridStyle={euiDataGridStyle} - rowCount={data.length} - renderCellValue={renderCellValue} - sorting={{ columns: sortingColumns, onSort }} - toolbarVisibility={euiDataGridToolbarSettings} - pagination={{ - ...pagination, - pageSizeOptions: [5, 10, 25], - onChangeItemsPerPage, - onChangePage, - }} - /> - )} - </div> - ); -}); +); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx index 44edd1340e8d6..f45ef7cfddbf9 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx @@ -4,40 +4,44 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; -import { KibanaContext } from '../../../../lib/kibana'; - +import { createPublicShim } from '../../../../../shim'; +import { getAppProviders } from '../../../../app_dependencies'; import { PivotAggsConfigDict, PivotGroupByConfigDict, PIVOT_SUPPORTED_AGGS, PIVOT_SUPPORTED_GROUP_BY_AGGS, } from '../../../../common'; -import { StepDefineForm, getAggNameConflictToastMessages } from './step_define_form'; +import { SearchItems } from '../../../../hooks/use_search_items'; -// workaround to make React.memo() work with enzyme -jest.mock('react', () => { - const r = jest.requireActual('react'); - return { ...r, memo: (x: any) => x }; -}); +import { StepDefineForm, getAggNameConflictToastMessages } from './step_define_form'; +jest.mock('ui/new_platform'); jest.mock('../../../../../shared_imports'); describe('Transform: <DefinePivotForm />', () => { test('Minimal initialization', () => { - // Using a wrapping <div> element because shallow() would fail - // with the Provider being the outer most component. - const wrapper = shallow( - <div> - <KibanaContext.Provider value={{ initialized: false }}> - <StepDefineForm onChange={() => {}} /> - </KibanaContext.Provider> - </div> + // Arrange + const searchItems = { + indexPattern: { + title: 'the-index-pattern-title', + fields: [] as any[], + } as SearchItems['indexPattern'], + }; + const Providers = getAppProviders(createPublicShim()); + const { getByLabelText } = render( + <Providers> + <StepDefineForm onChange={jest.fn()} searchItems={searchItems as SearchItems} /> + </Providers> ); - expect(wrapper).toMatchSnapshot(); + // Act + // Assert + expect(getByLabelText('Index pattern')).toBeInTheDocument(); }); }); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx index 9b96e4b1ee758..f61f54c38680e 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx @@ -26,6 +26,7 @@ import { EuiSwitch, } from '@elastic/eui'; +import { SavedSearchQuery, SearchItems } from '../../../../hooks/use_search_items'; import { useXJsonMode, xJsonMode } from '../../../../hooks/use_x_json_mode'; import { useDocumentationLinks, useToastNotifications } from '../../../../app_dependencies'; import { TransformPivotConfig } from '../../../../common'; @@ -38,12 +39,6 @@ import { PivotPreview } from './pivot_preview'; import { KqlFilterBar } from '../../../../../shared_imports'; import { SwitchModal } from './switch_modal'; -import { - useKibanaContext, - InitializedKibanaContextValue, - SavedSearchQuery, -} from '../../../../lib/kibana'; - import { getPivotQuery, getPreviewRequestBody, @@ -78,18 +73,14 @@ export interface StepDefineExposedState { const defaultSearch = '*'; const emptySearch = ''; -export function getDefaultStepDefineState( - kibanaContext: InitializedKibanaContextValue -): StepDefineExposedState { +export function getDefaultStepDefineState(searchItems: SearchItems): StepDefineExposedState { return { aggList: {} as PivotAggsConfigDict, groupByList: {} as PivotGroupByConfigDict, isAdvancedPivotEditorEnabled: false, isAdvancedSourceEditorEnabled: false, - searchString: - kibanaContext.currentSavedSearch !== undefined ? kibanaContext.combinedQuery : defaultSearch, - searchQuery: - kibanaContext.currentSavedSearch !== undefined ? kibanaContext.combinedQuery : defaultSearch, + searchString: searchItems.savedSearch !== undefined ? searchItems.combinedQuery : defaultSearch, + searchQuery: searchItems.savedSearch !== undefined ? searchItems.combinedQuery : defaultSearch, sourceConfigUpdated: false, valid: false, }; @@ -242,14 +233,14 @@ export function getAggNameConflictToastMessages( interface Props { overrides?: StepDefineExposedState; onChange(s: StepDefineExposedState): void; + searchItems: SearchItems; } -export const StepDefineForm: FC<Props> = React.memo(({ overrides = {}, onChange }) => { - const kibanaContext = useKibanaContext(); +export const StepDefineForm: FC<Props> = React.memo(({ overrides = {}, onChange, searchItems }) => { const toastNotifications = useToastNotifications(); const { esQueryDsl, esTransformPivot } = useDocumentationLinks(); - const defaults = { ...getDefaultStepDefineState(kibanaContext), ...overrides }; + const defaults = { ...getDefaultStepDefineState(searchItems), ...overrides }; // The search filter const [searchString, setSearchString] = useState(defaults.searchString); @@ -267,7 +258,7 @@ export const StepDefineForm: FC<Props> = React.memo(({ overrides = {}, onChange // The list of selected group by fields const [groupByList, setGroupByList] = useState(defaults.groupByList); - const indexPattern = kibanaContext.currentIndexPattern; + const { indexPattern } = searchItems; const { groupByOptions, @@ -568,7 +559,7 @@ export const StepDefineForm: FC<Props> = React.memo(({ overrides = {}, onChange <EuiFlexItem grow={false} className="transform__stepDefineFormLeftColumn"> <div data-test-subj="transformStepDefineForm"> <EuiForm> - {kibanaContext.currentSavedSearch === undefined && typeof searchString === 'string' && ( + {searchItems.savedSearch === undefined && typeof searchString === 'string' && ( <Fragment> <EuiFormRow label={i18n.translate('xpack.transform.stepDefineForm.indexPatternLabel', { @@ -669,7 +660,7 @@ export const StepDefineForm: FC<Props> = React.memo(({ overrides = {}, onChange </EuiFormRow> </Fragment> )} - {kibanaContext.currentSavedSearch === undefined && ( + {searchItems.savedSearch === undefined && ( <EuiFormRow> <EuiFlexGroup gutterSize="none"> <EuiFlexItem> @@ -720,16 +711,15 @@ export const StepDefineForm: FC<Props> = React.memo(({ overrides = {}, onChange </EuiFlexGroup> </EuiFormRow> )} - {kibanaContext.currentSavedSearch !== undefined && - kibanaContext.currentSavedSearch.id !== undefined && ( - <EuiFormRow - label={i18n.translate('xpack.transform.stepDefineForm.savedSearchLabel', { - defaultMessage: 'Saved search', - })} - > - <span>{kibanaContext.currentSavedSearch.title}</span> - </EuiFormRow> - )} + {searchItems.savedSearch !== undefined && searchItems.savedSearch.id !== undefined && ( + <EuiFormRow + label={i18n.translate('xpack.transform.stepDefineForm.savedSearchLabel', { + defaultMessage: 'Saved search', + })} + > + <span>{searchItems.savedSearch.title}</span> + </EuiFormRow> + )} {!isAdvancedPivotEditorEnabled && ( <Fragment> @@ -903,9 +893,14 @@ export const StepDefineForm: FC<Props> = React.memo(({ overrides = {}, onChange </EuiFlexItem> <EuiFlexItem grow={false} style={{ maxWidth: 'calc(100% - 468px)' }}> - <SourceIndexPreview query={pivotQuery} /> + <SourceIndexPreview indexPattern={searchItems.indexPattern} query={pivotQuery} /> <EuiHorizontalRule /> - <PivotPreview aggs={aggList} groupBy={groupByList} query={pivotQuery} /> + <PivotPreview + aggs={aggList} + groupBy={groupByList} + indexPattern={searchItems.indexPattern} + query={pivotQuery} + /> </EuiFlexItem> </EuiFlexGroup> ); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx index 78f6fc30f9191..0f7da50bbbade 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx @@ -4,30 +4,35 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; -import { KibanaContext } from '../../../../lib/kibana'; - +import { createPublicShim } from '../../../../../shim'; +import { getAppProviders } from '../../../../app_dependencies'; import { PivotAggsConfig, PivotGroupByConfig, PIVOT_SUPPORTED_AGGS, PIVOT_SUPPORTED_GROUP_BY_AGGS, } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; + import { StepDefineExposedState } from './step_define_form'; import { StepDefineSummary } from './step_define_summary'; -// workaround to make React.memo() work with enzyme -jest.mock('react', () => { - const r = jest.requireActual('react'); - return { ...r, memo: (x: any) => x }; -}); - +jest.mock('ui/new_platform'); jest.mock('../../../../../shared_imports'); describe('Transform: <DefinePivotSummary />', () => { test('Minimal initialization', () => { + // Arrange + const searchItems = { + indexPattern: { + title: 'the-index-pattern-title', + fields: [] as any[], + } as SearchItems['indexPattern'], + }; const groupBy: PivotGroupByConfig = { agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS, field: 'the-group-by-field', @@ -40,7 +45,7 @@ describe('Transform: <DefinePivotSummary />', () => { aggName: 'the-group-by-agg-name', dropDownName: 'the-group-by-drop-down-name', }; - const props: StepDefineExposedState = { + const formState: StepDefineExposedState = { aggList: { 'the-agg-name': agg }, groupByList: { 'the-group-by-name': groupBy }, isAdvancedPivotEditorEnabled: false, @@ -51,16 +56,16 @@ describe('Transform: <DefinePivotSummary />', () => { valid: true, }; - // Using a wrapping <div> element because shallow() would fail - // with the Provider being the outer most component. - const wrapper = shallow( - <div> - <KibanaContext.Provider value={{ initialized: false }}> - <StepDefineSummary {...props} /> - </KibanaContext.Provider> - </div> + const Providers = getAppProviders(createPublicShim()); + const { getByText } = render( + <Providers> + <StepDefineSummary formState={formState} searchItems={searchItems as SearchItems} /> + </Providers> ); - expect(wrapper).toMatchSnapshot(); + // Act + // Assert + expect(getByText('Group by')).toBeInTheDocument(); + expect(getByText('Aggregations')).toBeInTheDocument(); }); }); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx index 30c447f62c760..f8fb9db9bd686 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx @@ -17,26 +17,27 @@ import { EuiText, } from '@elastic/eui'; -import { useKibanaContext } from '../../../../lib/kibana'; +import { getPivotQuery } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; import { AggListSummary } from '../aggregation_list'; import { GroupByListSummary } from '../group_by_list'; -import { PivotPreview } from './pivot_preview'; -import { getPivotQuery } from '../../../../common'; +import { PivotPreview } from './pivot_preview'; import { StepDefineExposedState } from './step_define_form'; const defaultSearch = '*'; const emptySearch = ''; -export const StepDefineSummary: FC<StepDefineExposedState> = ({ - searchString, - searchQuery, - groupByList, - aggList, -}) => { - const kibanaContext = useKibanaContext(); +interface Props { + formState: StepDefineExposedState; + searchItems: SearchItems; +} +export const StepDefineSummary: FC<Props> = ({ + formState: { searchString, searchQuery, groupByList, aggList }, + searchItems, +}) => { const pivotQuery = getPivotQuery(searchQuery); let useCodeBlock = false; let displaySearch; @@ -55,8 +56,8 @@ export const StepDefineSummary: FC<StepDefineExposedState> = ({ <EuiFlexItem grow={false} style={{ minWidth: '420px' }}> <div data-test-subj="transformStepDefineSummary"> <EuiForm> - {kibanaContext.currentSavedSearch !== undefined && - kibanaContext.currentSavedSearch.id === undefined && + {searchItems.savedSearch !== undefined && + searchItems.savedSearch.id === undefined && typeof searchString === 'string' && ( <Fragment> <EuiFormRow @@ -64,7 +65,7 @@ export const StepDefineSummary: FC<StepDefineExposedState> = ({ defaultMessage: 'Index pattern', })} > - <span>{kibanaContext.currentIndexPattern.title}</span> + <span>{searchItems.indexPattern.title}</span> </EuiFormRow> {useCodeBlock === false && displaySearch !== emptySearch && ( <EuiFormRow @@ -99,16 +100,15 @@ export const StepDefineSummary: FC<StepDefineExposedState> = ({ </Fragment> )} - {kibanaContext.currentSavedSearch !== undefined && - kibanaContext.currentSavedSearch.id !== undefined && ( - <EuiFormRow - label={i18n.translate('xpack.transform.stepDefineSummary.savedSearchLabel', { - defaultMessage: 'Saved search', - })} - > - <span>{kibanaContext.currentSavedSearch.title}</span> - </EuiFormRow> - )} + {searchItems.savedSearch !== undefined && searchItems.savedSearch.id !== undefined && ( + <EuiFormRow + label={i18n.translate('xpack.transform.stepDefineSummary.savedSearchLabel', { + defaultMessage: 'Saved search', + })} + > + <span>{searchItems.savedSearch.title}</span> + </EuiFormRow> + )} <EuiFormRow label={i18n.translate('xpack.transform.stepDefineSummary.groupByLabel', { @@ -131,7 +131,12 @@ export const StepDefineSummary: FC<StepDefineExposedState> = ({ <EuiFlexItem> <EuiText> - <PivotPreview aggs={aggList} groupBy={groupByList} query={pivotQuery} /> + <PivotPreview + aggs={aggList} + groupBy={groupByList} + indexPattern={searchItems.indexPattern} + query={pivotQuery} + /> </EuiText> </EuiFlexItem> </EuiFlexGroup> diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx index 5ae2180bfe779..ea9483af49302 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx @@ -11,11 +11,15 @@ import { i18n } from '@kbn/i18n'; import { EuiLink, EuiSwitch, EuiFieldText, EuiForm, EuiFormRow, EuiSelect } from '@elastic/eui'; import { toMountPoint } from '../../../../../../../../../../src/plugins/kibana_react/public'; -import { useKibanaContext } from '../../../../lib/kibana'; import { isValidIndexName } from '../../../../../../common/utils/es_utils'; -import { useDocumentationLinks, useToastNotifications } from '../../../../app_dependencies'; +import { + useAppDependencies, + useDocumentationLinks, + useToastNotifications, +} from '../../../../app_dependencies'; import { ToastNotificationText } from '../../../../components'; +import { SearchItems } from '../../../../hooks/use_search_items'; import { useApi } from '../../../../hooks/use_api'; import { isTransformIdValid, TransformId, TransformPivotConfig } from '../../../../common'; @@ -67,109 +71,129 @@ export function applyTransformConfigToDetailsState( interface Props { overrides?: StepDetailsExposedState; onChange(s: StepDetailsExposedState): void; + searchItems: SearchItems; } -export const StepDetailsForm: FC<Props> = React.memo(({ overrides = {}, onChange }) => { - const kibanaContext = useKibanaContext(); - const toastNotifications = useToastNotifications(); - const { esIndicesCreateIndex } = useDocumentationLinks(); +export const StepDetailsForm: FC<Props> = React.memo( + ({ overrides = {}, onChange, searchItems }) => { + const deps = useAppDependencies(); + const toastNotifications = useToastNotifications(); + const { esIndicesCreateIndex } = useDocumentationLinks(); - const defaults = { ...getDefaultStepDetailsState(), ...overrides }; + const defaults = { ...getDefaultStepDetailsState(), ...overrides }; - const [transformId, setTransformId] = useState<TransformId>(defaults.transformId); - const [transformDescription, setTransformDescription] = useState<string>( - defaults.transformDescription - ); - const [destinationIndex, setDestinationIndex] = useState<EsIndexName>(defaults.destinationIndex); - const [transformIds, setTransformIds] = useState<TransformId[]>([]); - const [indexNames, setIndexNames] = useState<EsIndexName[]>([]); - const [indexPatternTitles, setIndexPatternTitles] = useState<IndexPatternTitle[]>([]); - const [createIndexPattern, setCreateIndexPattern] = useState(defaults.createIndexPattern); + const [transformId, setTransformId] = useState<TransformId>(defaults.transformId); + const [transformDescription, setTransformDescription] = useState<string>( + defaults.transformDescription + ); + const [destinationIndex, setDestinationIndex] = useState<EsIndexName>( + defaults.destinationIndex + ); + const [transformIds, setTransformIds] = useState<TransformId[]>([]); + const [indexNames, setIndexNames] = useState<EsIndexName[]>([]); + const [indexPatternTitles, setIndexPatternTitles] = useState<IndexPatternTitle[]>([]); + const [createIndexPattern, setCreateIndexPattern] = useState(defaults.createIndexPattern); - // Continuous mode state - const [isContinuousModeEnabled, setContinuousModeEnabled] = useState( - defaults.isContinuousModeEnabled - ); + // Continuous mode state + const [isContinuousModeEnabled, setContinuousModeEnabled] = useState( + defaults.isContinuousModeEnabled + ); - const api = useApi(); + const api = useApi(); - // fetch existing transform IDs and indices once for form validation - useEffect(() => { - // use an IIFE to avoid returning a Promise to useEffect. - (async function() { - try { - setTransformIds( - (await api.getTransforms()).transforms.map( - (transform: TransformPivotConfig) => transform.id - ) - ); - } catch (e) { - toastNotifications.addDanger({ - title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformList', { - defaultMessage: 'An error occurred getting the existing transform IDs:', - }), - text: toMountPoint(<ToastNotificationText text={e} />), - }); - } + // fetch existing transform IDs and indices once for form validation + useEffect(() => { + // use an IIFE to avoid returning a Promise to useEffect. + (async function() { + try { + setTransformIds( + (await api.getTransforms()).transforms.map( + (transform: TransformPivotConfig) => transform.id + ) + ); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformList', { + defaultMessage: 'An error occurred getting the existing transform IDs:', + }), + text: toMountPoint(<ToastNotificationText text={e} />), + }); + } - try { - setIndexNames((await api.getIndices()).map(index => index.name)); - } catch (e) { - toastNotifications.addDanger({ - title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingIndexNames', { - defaultMessage: 'An error occurred getting the existing index names:', - }), - text: toMountPoint(<ToastNotificationText text={e} />), - }); - } + try { + setIndexNames((await api.getIndices()).map(index => index.name)); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingIndexNames', { + defaultMessage: 'An error occurred getting the existing index names:', + }), + text: toMountPoint(<ToastNotificationText text={e} />), + }); + } - try { - setIndexPatternTitles(await kibanaContext.indexPatterns.getTitles()); - } catch (e) { - toastNotifications.addDanger({ - title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingIndexPatternTitles', { - defaultMessage: 'An error occurred getting the existing index pattern titles:', - }), - text: toMountPoint(<ToastNotificationText text={e} />), - }); - } - })(); - // custom comparison - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kibanaContext.initialized]); + try { + setIndexPatternTitles(await deps.plugins.data.indexPatterns.getTitles()); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.transform.stepDetailsForm.errorGettingIndexPatternTitles', + { + defaultMessage: 'An error occurred getting the existing index pattern titles:', + } + ), + text: toMountPoint(<ToastNotificationText text={e} />), + }); + } + })(); + // run once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const dateFieldNames = kibanaContext.currentIndexPattern.fields - .filter(f => f.type === 'date') - .map(f => f.name) - .sort(); - const isContinuousModeAvailable = dateFieldNames.length > 0; - const [continuousModeDateField, setContinuousModeDateField] = useState( - isContinuousModeAvailable ? dateFieldNames[0] : '' - ); - const [continuousModeDelay, setContinuousModeDelay] = useState(defaults.continuousModeDelay); - const isContinuousModeDelayValid = delayValidator(continuousModeDelay); + const dateFieldNames = searchItems.indexPattern.fields + .filter(f => f.type === 'date') + .map(f => f.name) + .sort(); + const isContinuousModeAvailable = dateFieldNames.length > 0; + const [continuousModeDateField, setContinuousModeDateField] = useState( + isContinuousModeAvailable ? dateFieldNames[0] : '' + ); + const [continuousModeDelay, setContinuousModeDelay] = useState(defaults.continuousModeDelay); + const isContinuousModeDelayValid = delayValidator(continuousModeDelay); - const transformIdExists = transformIds.some(id => transformId === id); - const transformIdEmpty = transformId === ''; - const transformIdValid = isTransformIdValid(transformId); + const transformIdExists = transformIds.some(id => transformId === id); + const transformIdEmpty = transformId === ''; + const transformIdValid = isTransformIdValid(transformId); - const indexNameExists = indexNames.some(name => destinationIndex === name); - const indexNameEmpty = destinationIndex === ''; - const indexNameValid = isValidIndexName(destinationIndex); - const indexPatternTitleExists = indexPatternTitles.some(name => destinationIndex === name); + const indexNameExists = indexNames.some(name => destinationIndex === name); + const indexNameEmpty = destinationIndex === ''; + const indexNameValid = isValidIndexName(destinationIndex); + const indexPatternTitleExists = indexPatternTitles.some(name => destinationIndex === name); - const valid = - !transformIdEmpty && - transformIdValid && - !transformIdExists && - !indexNameEmpty && - indexNameValid && - (!indexPatternTitleExists || !createIndexPattern) && - (!isContinuousModeAvailable || (isContinuousModeAvailable && isContinuousModeDelayValid)); + const valid = + !transformIdEmpty && + transformIdValid && + !transformIdExists && + !indexNameEmpty && + indexNameValid && + (!indexPatternTitleExists || !createIndexPattern) && + (!isContinuousModeAvailable || (isContinuousModeAvailable && isContinuousModeDelayValid)); - // expose state to wizard - useEffect(() => { - onChange({ + // expose state to wizard + useEffect(() => { + onChange({ + continuousModeDateField, + continuousModeDelay, + createIndexPattern, + isContinuousModeEnabled, + transformId, + transformDescription, + destinationIndex, + touched: true, + valid, + }); + // custom comparison + /* eslint-disable react-hooks/exhaustive-deps */ + }, [ continuousModeDateField, continuousModeDelay, createIndexPattern, @@ -177,232 +201,223 @@ export const StepDetailsForm: FC<Props> = React.memo(({ overrides = {}, onChange transformId, transformDescription, destinationIndex, - touched: true, valid, - }); - // custom comparison - /* eslint-disable react-hooks/exhaustive-deps */ - }, [ - continuousModeDateField, - continuousModeDelay, - createIndexPattern, - isContinuousModeEnabled, - transformId, - transformDescription, - destinationIndex, - valid, - /* eslint-enable react-hooks/exhaustive-deps */ - ]); + /* eslint-enable react-hooks/exhaustive-deps */ + ]); - return ( - <div data-test-subj="transformStepDetailsForm"> - <EuiForm> - <EuiFormRow - label={i18n.translate('xpack.transform.stepDetailsForm.transformIdLabel', { - defaultMessage: 'Transform ID', - })} - isInvalid={(!transformIdEmpty && !transformIdValid) || transformIdExists} - error={[ - ...(!transformIdEmpty && !transformIdValid - ? [ - i18n.translate('xpack.transform.stepDetailsForm.transformIdInvalidError', { - defaultMessage: - 'Must contain lowercase alphanumeric characters (a-z and 0-9), hyphens, and underscores only and must start and end with alphanumeric characters.', - }), - ] - : []), - ...(transformIdExists - ? [ - i18n.translate('xpack.transform.stepDetailsForm.transformIdExistsError', { - defaultMessage: 'A transform with this ID already exists.', - }), - ] - : []), - ]} - > - <EuiFieldText - placeholder="transform ID" - value={transformId} - onChange={e => setTransformId(e.target.value)} - aria-label={i18n.translate( - 'xpack.transform.stepDetailsForm.transformIdInputAriaLabel', - { - defaultMessage: 'Choose a unique transform ID.', - } - )} + return ( + <div data-test-subj="transformStepDetailsForm"> + <EuiForm> + <EuiFormRow + label={i18n.translate('xpack.transform.stepDetailsForm.transformIdLabel', { + defaultMessage: 'Transform ID', + })} isInvalid={(!transformIdEmpty && !transformIdValid) || transformIdExists} - data-test-subj="transformIdInput" - /> - </EuiFormRow> - <EuiFormRow - label={i18n.translate('xpack.transform.stepDetailsForm.transformDescriptionLabel', { - defaultMessage: 'Transform description', - })} - helpText={i18n.translate('xpack.transform.stepDetailsForm.transformDescriptionHelpText', { - defaultMessage: 'Optional descriptive text.', - })} - > - <EuiFieldText - placeholder="transform description" - value={transformDescription} - onChange={e => setTransformDescription(e.target.value)} - aria-label={i18n.translate( - 'xpack.transform.stepDetailsForm.transformDescriptionInputAriaLabel', - { - defaultMessage: 'Choose an optional transform description.', - } - )} - data-test-subj="transformDescriptionInput" - /> - </EuiFormRow> - <EuiFormRow - label={i18n.translate('xpack.transform.stepDetailsForm.destinationIndexLabel', { - defaultMessage: 'Destination index', - })} - isInvalid={!indexNameEmpty && !indexNameValid} - helpText={ - indexNameExists && - i18n.translate('xpack.transform.stepDetailsForm.destinationIndexHelpText', { - defaultMessage: - 'An index with this name already exists. Be aware that running this transform will modify this destination index.', - }) - } - error={ - !indexNameEmpty && - !indexNameValid && [ - <Fragment> - {i18n.translate('xpack.transform.stepDetailsForm.destinationIndexInvalidError', { - defaultMessage: 'Invalid destination index name.', - })} - <br /> - <EuiLink href={esIndicesCreateIndex} target="_blank"> - {i18n.translate( - 'xpack.transform.stepDetailsForm.destinationIndexInvalidErrorLink', - { - defaultMessage: 'Learn more about index name limitations.', - } - )} - </EuiLink> - </Fragment>, - ] - } - > - <EuiFieldText - placeholder="destination index" - value={destinationIndex} - onChange={e => setDestinationIndex(e.target.value)} - aria-label={i18n.translate( - 'xpack.transform.stepDetailsForm.destinationIndexInputAriaLabel', + error={[ + ...(!transformIdEmpty && !transformIdValid + ? [ + i18n.translate('xpack.transform.stepDetailsForm.transformIdInvalidError', { + defaultMessage: + 'Must contain lowercase alphanumeric characters (a-z and 0-9), hyphens, and underscores only and must start and end with alphanumeric characters.', + }), + ] + : []), + ...(transformIdExists + ? [ + i18n.translate('xpack.transform.stepDetailsForm.transformIdExistsError', { + defaultMessage: 'A transform with this ID already exists.', + }), + ] + : []), + ]} + > + <EuiFieldText + placeholder="transform ID" + value={transformId} + onChange={e => setTransformId(e.target.value)} + aria-label={i18n.translate( + 'xpack.transform.stepDetailsForm.transformIdInputAriaLabel', + { + defaultMessage: 'Choose a unique transform ID.', + } + )} + isInvalid={(!transformIdEmpty && !transformIdValid) || transformIdExists} + data-test-subj="transformIdInput" + /> + </EuiFormRow> + <EuiFormRow + label={i18n.translate('xpack.transform.stepDetailsForm.transformDescriptionLabel', { + defaultMessage: 'Transform description', + })} + helpText={i18n.translate( + 'xpack.transform.stepDetailsForm.transformDescriptionHelpText', { - defaultMessage: 'Choose a unique destination index name.', + defaultMessage: 'Optional descriptive text.', } )} - isInvalid={!indexNameEmpty && !indexNameValid} - data-test-subj="transformDestinationIndexInput" - /> - </EuiFormRow> - <EuiFormRow - isInvalid={createIndexPattern && indexPatternTitleExists} - error={ - createIndexPattern && - indexPatternTitleExists && [ - i18n.translate('xpack.transform.stepDetailsForm.indexPatternTitleError', { - defaultMessage: 'An index pattern with this title already exists.', - }), - ] - } - > - <EuiSwitch - name="transformCreateIndexPattern" - label={i18n.translate('xpack.transform.stepCreateForm.createIndexPatternLabel', { - defaultMessage: 'Create index pattern', - })} - checked={createIndexPattern === true} - onChange={() => setCreateIndexPattern(!createIndexPattern)} - data-test-subj="transformCreateIndexPatternSwitch" - /> - </EuiFormRow> - <EuiFormRow - helpText={ - isContinuousModeAvailable === false - ? i18n.translate('xpack.transform.stepDetailsForm.continuousModeError', { - defaultMessage: - 'Continuous mode is not available for indices without date fields.', - }) - : '' - } - > - <EuiSwitch - name="transformContinuousMode" - label={i18n.translate('xpack.transform.stepCreateForm.continuousModeLabel', { - defaultMessage: 'Continuous mode', - })} - checked={isContinuousModeEnabled === true} - onChange={() => setContinuousModeEnabled(!isContinuousModeEnabled)} - disabled={isContinuousModeAvailable === false} - data-test-subj="transformContinuousModeSwitch" - /> - </EuiFormRow> - {isContinuousModeEnabled && ( - <Fragment> - <EuiFormRow - label={i18n.translate( - 'xpack.transform.stepDetailsForm.continuousModeDateFieldLabel', + > + <EuiFieldText + placeholder="transform description" + value={transformDescription} + onChange={e => setTransformDescription(e.target.value)} + aria-label={i18n.translate( + 'xpack.transform.stepDetailsForm.transformDescriptionInputAriaLabel', { - defaultMessage: 'Date field', + defaultMessage: 'Choose an optional transform description.', } )} - helpText={i18n.translate( - 'xpack.transform.stepDetailsForm.continuousModeDateFieldHelpText', + data-test-subj="transformDescriptionInput" + /> + </EuiFormRow> + <EuiFormRow + label={i18n.translate('xpack.transform.stepDetailsForm.destinationIndexLabel', { + defaultMessage: 'Destination index', + })} + isInvalid={!indexNameEmpty && !indexNameValid} + helpText={ + indexNameExists && + i18n.translate('xpack.transform.stepDetailsForm.destinationIndexHelpText', { + defaultMessage: + 'An index with this name already exists. Be aware that running this transform will modify this destination index.', + }) + } + error={ + !indexNameEmpty && + !indexNameValid && [ + <Fragment> + {i18n.translate('xpack.transform.stepDetailsForm.destinationIndexInvalidError', { + defaultMessage: 'Invalid destination index name.', + })} + <br /> + <EuiLink href={esIndicesCreateIndex} target="_blank"> + {i18n.translate( + 'xpack.transform.stepDetailsForm.destinationIndexInvalidErrorLink', + { + defaultMessage: 'Learn more about index name limitations.', + } + )} + </EuiLink> + </Fragment>, + ] + } + > + <EuiFieldText + placeholder="destination index" + value={destinationIndex} + onChange={e => setDestinationIndex(e.target.value)} + aria-label={i18n.translate( + 'xpack.transform.stepDetailsForm.destinationIndexInputAriaLabel', { - defaultMessage: - 'Select the date field that can be used to identify new documents.', + defaultMessage: 'Choose a unique destination index name.', } )} - > - <EuiSelect - options={dateFieldNames.map(text => ({ text }))} - value={continuousModeDateField} - onChange={e => setContinuousModeDateField(e.target.value)} - data-test-subj="transformContinuousDateFieldSelect" - /> - </EuiFormRow> - <EuiFormRow - label={i18n.translate('xpack.transform.stepDetailsForm.continuousModeDelayLabel', { - defaultMessage: 'Delay', + isInvalid={!indexNameEmpty && !indexNameValid} + data-test-subj="transformDestinationIndexInput" + /> + </EuiFormRow> + <EuiFormRow + isInvalid={createIndexPattern && indexPatternTitleExists} + error={ + createIndexPattern && + indexPatternTitleExists && [ + i18n.translate('xpack.transform.stepDetailsForm.indexPatternTitleError', { + defaultMessage: 'An index pattern with this title already exists.', + }), + ] + } + > + <EuiSwitch + name="transformCreateIndexPattern" + label={i18n.translate('xpack.transform.stepCreateForm.createIndexPatternLabel', { + defaultMessage: 'Create index pattern', })} - isInvalid={!isContinuousModeDelayValid} - error={ - !isContinuousModeDelayValid && [ - i18n.translate('xpack.transform.stepDetailsForm.continuousModeDelayError', { - defaultMessage: 'Invalid delay format', - }), - ] - } - helpText={i18n.translate( - 'xpack.transform.stepDetailsForm.continuousModeDelayHelpText', - { - defaultMessage: 'Time delay between current time and latest input data time.', - } - )} - > - <EuiFieldText - placeholder="delay" - value={continuousModeDelay} - onChange={e => setContinuousModeDelay(e.target.value)} - aria-label={i18n.translate( - 'xpack.transform.stepDetailsForm.continuousModeAriaLabel', + checked={createIndexPattern === true} + onChange={() => setCreateIndexPattern(!createIndexPattern)} + data-test-subj="transformCreateIndexPatternSwitch" + /> + </EuiFormRow> + <EuiFormRow + helpText={ + isContinuousModeAvailable === false + ? i18n.translate('xpack.transform.stepDetailsForm.continuousModeError', { + defaultMessage: + 'Continuous mode is not available for indices without date fields.', + }) + : '' + } + > + <EuiSwitch + name="transformContinuousMode" + label={i18n.translate('xpack.transform.stepCreateForm.continuousModeLabel', { + defaultMessage: 'Continuous mode', + })} + checked={isContinuousModeEnabled === true} + onChange={() => setContinuousModeEnabled(!isContinuousModeEnabled)} + disabled={isContinuousModeAvailable === false} + data-test-subj="transformContinuousModeSwitch" + /> + </EuiFormRow> + {isContinuousModeEnabled && ( + <Fragment> + <EuiFormRow + label={i18n.translate( + 'xpack.transform.stepDetailsForm.continuousModeDateFieldLabel', { - defaultMessage: 'Choose a delay.', + defaultMessage: 'Date field', } )} + helpText={i18n.translate( + 'xpack.transform.stepDetailsForm.continuousModeDateFieldHelpText', + { + defaultMessage: + 'Select the date field that can be used to identify new documents.', + } + )} + > + <EuiSelect + options={dateFieldNames.map(text => ({ text }))} + value={continuousModeDateField} + onChange={e => setContinuousModeDateField(e.target.value)} + data-test-subj="transformContinuousDateFieldSelect" + /> + </EuiFormRow> + <EuiFormRow + label={i18n.translate('xpack.transform.stepDetailsForm.continuousModeDelayLabel', { + defaultMessage: 'Delay', + })} isInvalid={!isContinuousModeDelayValid} - data-test-subj="transformContinuousDelayInput" - /> - </EuiFormRow> - </Fragment> - )} - </EuiForm> - </div> - ); -}); + error={ + !isContinuousModeDelayValid && [ + i18n.translate('xpack.transform.stepDetailsForm.continuousModeDelayError', { + defaultMessage: 'Invalid delay format', + }), + ] + } + helpText={i18n.translate( + 'xpack.transform.stepDetailsForm.continuousModeDelayHelpText', + { + defaultMessage: 'Time delay between current time and latest input data time.', + } + )} + > + <EuiFieldText + placeholder="delay" + value={continuousModeDelay} + onChange={e => setContinuousModeDelay(e.target.value)} + aria-label={i18n.translate( + 'xpack.transform.stepDetailsForm.continuousModeAriaLabel', + { + defaultMessage: 'Choose a delay.', + } + )} + isInvalid={!isContinuousModeDelayValid} + data-test-subj="transformContinuousDelayInput" + /> + </EuiFormRow> + </Fragment> + )} + </EuiForm> + </div> + ); + } +); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index f1861755d9742..0773ecbb1d8d3 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -10,9 +10,8 @@ import { i18n } from '@kbn/i18n'; import { EuiSteps, EuiStepStatus } from '@elastic/eui'; -import { useKibanaContext } from '../../../../lib/kibana'; - import { getCreateRequestBody, TransformPivotConfig } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; import { applyTransformConfigToDefineState, @@ -46,6 +45,7 @@ interface DefinePivotStepProps { stepDefineState: StepDefineExposedState; setCurrentStep: React.Dispatch<React.SetStateAction<WIZARD_STEPS>>; setStepDefineState: React.Dispatch<React.SetStateAction<StepDefineExposedState>>; + searchItems: SearchItems; } const StepDefine: FC<DefinePivotStepProps> = ({ @@ -53,6 +53,7 @@ const StepDefine: FC<DefinePivotStepProps> = ({ stepDefineState, setCurrentStep, setStepDefineState, + searchItems, }) => { const definePivotRef = useRef(null); @@ -61,31 +62,36 @@ const StepDefine: FC<DefinePivotStepProps> = ({ <div ref={definePivotRef} /> {isCurrentStep && ( <Fragment> - <StepDefineForm onChange={setStepDefineState} overrides={{ ...stepDefineState }} /> + <StepDefineForm + onChange={setStepDefineState} + overrides={{ ...stepDefineState }} + searchItems={searchItems} + /> <WizardNav next={() => setCurrentStep(WIZARD_STEPS.DETAILS)} nextActive={stepDefineState.valid} /> </Fragment> )} - {!isCurrentStep && <StepDefineSummary {...stepDefineState} />} + {!isCurrentStep && ( + <StepDefineSummary formState={{ ...stepDefineState }} searchItems={searchItems} /> + )} </Fragment> ); }; interface WizardProps { cloneConfig?: TransformPivotConfig; + searchItems: SearchItems; } -export const Wizard: FC<WizardProps> = React.memo(({ cloneConfig }) => { - const kibanaContext = useKibanaContext(); - +export const Wizard: FC<WizardProps> = React.memo(({ cloneConfig, searchItems }) => { // The current WIZARD_STEP const [currentStep, setCurrentStep] = useState(WIZARD_STEPS.DEFINE); // The DEFINE state const [stepDefineState, setStepDefineState] = useState( - applyTransformConfigToDefineState(getDefaultStepDefineState(kibanaContext), cloneConfig) + applyTransformConfigToDefineState(getDefaultStepDefineState(searchItems), cloneConfig) ); // The DETAILS state @@ -95,7 +101,11 @@ export const Wizard: FC<WizardProps> = React.memo(({ cloneConfig }) => { const stepDetails = currentStep === WIZARD_STEPS.DETAILS ? ( - <StepDetailsForm onChange={setStepDetailsState} overrides={stepDetailsState} /> + <StepDetailsForm + onChange={setStepDetailsState} + overrides={stepDetailsState} + searchItems={searchItems} + /> ) : ( <StepDetailsSummary {...stepDetailsState} /> ); @@ -122,7 +132,7 @@ export const Wizard: FC<WizardProps> = React.memo(({ cloneConfig }) => { } }, []); - const indexPattern = kibanaContext.currentIndexPattern; + const { indexPattern } = searchItems; const transformConfig = getCreateRequestBody( indexPattern.title, @@ -154,6 +164,7 @@ export const Wizard: FC<WizardProps> = React.memo(({ cloneConfig }) => { stepDefineState={stepDefineState} setCurrentStep={setCurrentStep} setStepDefineState={setStepDefineState} + searchItems={searchItems} /> ), }, diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx index 5196f281adf0a..d09fc0913590e 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx @@ -22,9 +22,9 @@ import { import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; import { useDocumentationLinks } from '../../app_dependencies'; +import { useSearchItems } from '../../hooks/use_search_items'; import { breadcrumbService, docTitleService, BREADCRUMB_SECTION } from '../../services/navigation'; import { PrivilegesWrapper } from '../../lib/authorization'; -import { KibanaProvider, RenderOnlyWithInitializedKibanaContext } from '../../lib/kibana'; import { Wizard } from './components/wizard'; @@ -38,43 +38,41 @@ export const CreateTransformSection: FC<Props> = ({ match }) => { const { esTransform } = useDocumentationLinks(); + const { searchItems } = useSearchItems(match.params.savedObjectId); + return ( <PrivilegesWrapper privileges={APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES}> - <KibanaProvider savedObjectId={match.params.savedObjectId}> - <EuiPageContent data-test-subj="transformPageCreateTransform"> - <EuiTitle size="l"> - <EuiFlexGroup alignItems="center"> - <EuiFlexItem grow={true}> - <h1> - <FormattedMessage - id="xpack.transform.transformsWizard.createTransformTitle" - defaultMessage="Create transform" - /> - </h1> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButtonEmpty - href={esTransform} - target="_blank" - iconType="help" - data-test-subj="documentationLink" - > - <FormattedMessage - id="xpack.transform.transformsWizard.transformDocsLinkText" - defaultMessage="Transform docs" - /> - </EuiButtonEmpty> - </EuiFlexItem> - </EuiFlexGroup> - </EuiTitle> - <EuiPageContentBody> - <EuiSpacer size="l" /> - <RenderOnlyWithInitializedKibanaContext> - <Wizard /> - </RenderOnlyWithInitializedKibanaContext> - </EuiPageContentBody> - </EuiPageContent> - </KibanaProvider> + <EuiPageContent data-test-subj="transformPageCreateTransform"> + <EuiTitle size="l"> + <EuiFlexGroup alignItems="center"> + <EuiFlexItem grow={true}> + <h1> + <FormattedMessage + id="xpack.transform.transformsWizard.createTransformTitle" + defaultMessage="Create transform" + /> + </h1> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + href={esTransform} + target="_blank" + iconType="help" + data-test-subj="documentationLink" + > + <FormattedMessage + id="xpack.transform.transformsWizard.transformDocsLinkText" + defaultMessage="Transform docs" + /> + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + </EuiTitle> + <EuiPageContentBody> + <EuiSpacer size="l" /> + {searchItems !== undefined && <Wizard searchItems={searchItems} />} + </EuiPageContentBody> + </EuiPageContent> </PrivilegesWrapper> ); }; diff --git a/x-pack/legacy/plugins/transform/public/app/services/http_service.ts b/x-pack/legacy/plugins/transform/public/app/services/http_service.ts index fa4c8d1ba7844..b42301433145d 100644 --- a/x-pack/legacy/plugins/transform/public/app/services/http_service.ts +++ b/x-pack/legacy/plugins/transform/public/app/services/http_service.ts @@ -4,21 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { HttpSetup } from 'kibana/public'; // service for interacting with the server import { Dictionary } from '../../../common/types/common'; export type Http = (options: Dictionary<any>) => Promise<unknown>; -export function httpFactory(xsrfToken: string) { +export function httpFactory(httpSetup: HttpSetup) { return function http(options: Dictionary<any>) { return new Promise((resolve, reject) => { if (options && options.url) { let url = ''; url = url + (options.url || ''); const headers = { - 'kbn-system-request': true, 'Content-Type': 'application/json', - 'kbn-version': xsrfToken, ...options.headers, }; @@ -36,9 +35,10 @@ export function httpFactory(xsrfToken: string) { payload.body = body; } - fetch(url, payload) + httpSetup + .fetch(url, payload) .then(resp => { - resp.json().then(resp.ok === true ? resolve : reject); + resolve(resp); }) .catch(resp => { reject(resp); diff --git a/x-pack/legacy/plugins/transform/public/plugin.ts b/x-pack/legacy/plugins/transform/public/plugin.ts index 23fad00fb0786..7f461a3b03a8b 100644 --- a/x-pack/legacy/plugins/transform/public/plugin.ts +++ b/x-pack/legacy/plugins/transform/public/plugin.ts @@ -11,7 +11,6 @@ import { breadcrumbService } from './app/services/navigation'; import { docTitleService } from './app/services/navigation'; import { textService } from './app/services/text'; import { uiMetricService } from './app/services/ui_metric'; -import { createSavedSearchesLoader } from '../../../../../src/plugins/discover/public'; export class Plugin { public start(core: ShimCore, plugins: ShimPlugins): void { @@ -27,7 +26,7 @@ export class Plugin { savedObjects, overlays, } = core; - const { data, management, savedSearches: coreSavedSearches, uiMetric, xsrfToken } = plugins; + const { data, management, uiMetric } = plugins; // AppCore/AppPlugins to be passed on as React context const appDependencies = { @@ -46,8 +45,6 @@ export class Plugin { plugins: { data, management, - savedSearches: coreSavedSearches, - xsrfToken, }, }; @@ -61,14 +58,6 @@ export class Plugin { }), order: 3, mount(params) { - const savedSearches = createSavedSearchesLoader({ - savedObjectsClient: core.savedObjects.client, - indexPatterns: plugins.data.indexPatterns, - chrome: core.chrome, - overlays: core.overlays, - }); - coreSavedSearches.setClient(savedSearches); - breadcrumbService.setup(params.setBreadcrumbs); params.setBreadcrumbs([ { diff --git a/x-pack/legacy/plugins/transform/public/shared_imports.ts b/x-pack/legacy/plugins/transform/public/shared_imports.ts index b077cd8836c4b..1ca71f8c4aa77 100644 --- a/x-pack/legacy/plugins/transform/public/shared_imports.ts +++ b/x-pack/legacy/plugins/transform/public/shared_imports.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { createSavedSearchesLoader } from '../../../../../src/plugins/discover/public'; export { XJsonMode } from '../../../../plugins/es_ui_shared/console_lang/ace/modes/x_json'; export { collapseLiteralStrings, diff --git a/x-pack/legacy/plugins/transform/public/shim.ts b/x-pack/legacy/plugins/transform/public/shim.ts index 05f7626e25e9d..55e45ee3e12ce 100644 --- a/x-pack/legacy/plugins/transform/public/shim.ts +++ b/x-pack/legacy/plugins/transform/public/shim.ts @@ -6,14 +6,12 @@ import { npStart } from 'ui/new_platform'; -import chrome from 'ui/chrome'; import { docTitle } from 'ui/doc_title/doc_title'; // @ts-ignore: allow traversal to fail on x-pack build import { createUiStatsReporter } from '../../../../../src/legacy/core_plugins/ui_metric/public'; import { TRANSFORM_DOC_PATHS } from './app/constants'; -import { SavedSearchLoader } from '../../../../../src/plugins/discover/public'; export type NpCore = typeof npStart.core; export type NpPlugins = typeof npStart.plugins; @@ -33,7 +31,7 @@ export type AppCore = Pick< | 'overlays' | 'notifications' >; -export type AppPlugins = Pick<ShimPlugins, 'data' | 'management' | 'savedSearches' | 'xsrfToken'>; +export type AppPlugins = Pick<ShimPlugins, 'data' | 'management'>; export interface AppDependencies { core: AppCore; @@ -61,18 +59,9 @@ export interface ShimPlugins extends NpPlugins { uiMetric: { createUiStatsReporter: typeof createUiStatsReporter; }; - savedSearches: { - getClient(): any; - setClient(client: any): void; - }; - xsrfToken: string; } export function createPublicShim(): { core: ShimCore; plugins: ShimPlugins } { - // This is an Angular service, which is why we use this provider pattern - // to access it within our React app. - let savedSearches: SavedSearchLoader; - const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = npStart.core.docLinks; return { @@ -94,16 +83,9 @@ export function createPublicShim(): { core: ShimCore; plugins: ShimPlugins } { }, plugins: { ...npStart.plugins, - savedSearches: { - setClient: (client: any): void => { - savedSearches = client; - }, - getClient: (): any => savedSearches, - }, uiMetric: { createUiStatsReporter, }, - xsrfToken: chrome.getXsrfToken(), }, }; } diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/ping_list.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/ping_list.test.tsx index ba07d6c63b36c..7705c72fa14a0 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/ping_list.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/ping_list.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; import { PingResults, Ping } from '../../../../../common/graphql/types'; import { PingListComponent, AllLocationOption, toggleDetails } from '../ping_list'; -import { EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; import { ExpandedRowMap } from '../../monitor_list/types'; describe('PingList component', () => { @@ -205,7 +205,7 @@ describe('PingList component', () => { loading={false} data={{ allPings }} onPageCountChange={jest.fn()} - onSelectedLocationChange={(loc: EuiComboBoxOptionProps[]) => {}} + onSelectedLocationChange={(loc: EuiComboBoxOptionOption[]) => {}} onSelectedStatusChange={jest.fn()} pageSize={30} selectedOption="down" diff --git a/x-pack/package.json b/x-pack/package.json index 585d05b3c8a13..11068bcccf561 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -179,7 +179,7 @@ "@elastic/apm-rum-react": "^0.3.2", "@elastic/datemath": "5.0.2", "@elastic/ems-client": "7.6.0", - "@elastic/eui": "19.0.0", + "@elastic/eui": "20.0.2", "@elastic/filesaver": "1.1.2", "@elastic/maki": "6.1.0", "@elastic/node-crypto": "^1.0.0", diff --git a/x-pack/plugins/actions/server/routes/create.ts b/x-pack/plugins/actions/server/routes/create.ts index f8f9aff9323a0..2150dc4076449 100644 --- a/x-pack/plugins/actions/server/routes/create.ts +++ b/x-pack/plugins/actions/server/routes/create.ts @@ -41,6 +41,9 @@ export const createActionRoute = (router: IRouter, licenseState: LicenseState) = ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.actions) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for actions' }); + } const actionsClient = context.actions.getActionsClient(); const action = req.body; const actionRes: ActionResult = await actionsClient.create({ action }); diff --git a/x-pack/plugins/actions/server/routes/delete.ts b/x-pack/plugins/actions/server/routes/delete.ts index d96523997ad34..8508137b97750 100644 --- a/x-pack/plugins/actions/server/routes/delete.ts +++ b/x-pack/plugins/actions/server/routes/delete.ts @@ -41,6 +41,9 @@ export const deleteActionRoute = (router: IRouter, licenseState: LicenseState) = res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.actions) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for actions' }); + } const actionsClient = context.actions.getActionsClient(); const { id } = req.params; await actionsClient.delete({ id }); diff --git a/x-pack/plugins/actions/server/routes/find.ts b/x-pack/plugins/actions/server/routes/find.ts index e791aff4fb598..71d4274980fcc 100644 --- a/x-pack/plugins/actions/server/routes/find.ts +++ b/x-pack/plugins/actions/server/routes/find.ts @@ -57,6 +57,9 @@ export const findActionRoute = (router: IRouter, licenseState: LicenseState) => res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.actions) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for actions' }); + } const actionsClient = context.actions.getActionsClient(); const query = req.query; const options: FindOptions['options'] = { diff --git a/x-pack/plugins/actions/server/routes/get.ts b/x-pack/plugins/actions/server/routes/get.ts index 26aa74da5d36b..836f46bfe55fd 100644 --- a/x-pack/plugins/actions/server/routes/get.ts +++ b/x-pack/plugins/actions/server/routes/get.ts @@ -36,6 +36,9 @@ export const getActionRoute = (router: IRouter, licenseState: LicenseState) => { res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.actions) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for actions' }); + } const actionsClient = context.actions.getActionsClient(); const { id } = req.params; return res.ok({ diff --git a/x-pack/plugins/actions/server/routes/list_action_types.test.ts b/x-pack/plugins/actions/server/routes/list_action_types.test.ts index 87cc4dfee5336..e983b8d1f2f84 100644 --- a/x-pack/plugins/actions/server/routes/list_action_types.test.ts +++ b/x-pack/plugins/actions/server/routes/list_action_types.test.ts @@ -58,7 +58,7 @@ describe('listActionTypesRoute', () => { } `); - expect(context.actions.listTypes).toHaveBeenCalledTimes(1); + expect(context.actions!.listTypes).toHaveBeenCalledTimes(1); expect(res.ok).toHaveBeenCalledWith({ body: listTypes, diff --git a/x-pack/plugins/actions/server/routes/list_action_types.ts b/x-pack/plugins/actions/server/routes/list_action_types.ts index 0b9791eedb39c..46f62e3a9c8bb 100644 --- a/x-pack/plugins/actions/server/routes/list_action_types.ts +++ b/x-pack/plugins/actions/server/routes/list_action_types.ts @@ -29,6 +29,9 @@ export const listActionTypesRoute = (router: IRouter, licenseState: LicenseState res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.actions) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for actions' }); + } return res.ok({ body: context.actions.listTypes(), }); diff --git a/x-pack/plugins/actions/server/routes/update.ts b/x-pack/plugins/actions/server/routes/update.ts index 9c5f32e8b9119..315695382b2d9 100644 --- a/x-pack/plugins/actions/server/routes/update.ts +++ b/x-pack/plugins/actions/server/routes/update.ts @@ -43,6 +43,9 @@ export const updateActionRoute = (router: IRouter, licenseState: LicenseState) = res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.actions) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for actions' }); + } const actionsClient = context.actions.getActionsClient(); const { id } = req.params; const { name, config, secrets } = req.body; diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index 2358f499c9f98..635c0829e02c3 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -22,7 +22,7 @@ export interface Services { declare module 'src/core/server' { interface RequestHandlerContext { - actions: { + actions?: { getActionsClient: () => ActionsClient; listTypes: ActionTypeRegistry['list']; }; diff --git a/x-pack/plugins/alerting/server/routes/create.ts b/x-pack/plugins/alerting/server/routes/create.ts index 8d854e0df8467..af518499a9abb 100644 --- a/x-pack/plugins/alerting/server/routes/create.ts +++ b/x-pack/plugins/alerting/server/routes/create.ts @@ -57,6 +57,9 @@ export const createAlertRoute = (router: IRouter, licenseState: LicenseState) => ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } const alertsClient = context.alerting.getAlertsClient(); const alert = req.body; const alertRes: Alert = await alertsClient.create({ data: alert }); diff --git a/x-pack/plugins/alerting/server/routes/delete.ts b/x-pack/plugins/alerting/server/routes/delete.ts index 0556ef3d66982..fc36cf91fdad2 100644 --- a/x-pack/plugins/alerting/server/routes/delete.ts +++ b/x-pack/plugins/alerting/server/routes/delete.ts @@ -36,6 +36,9 @@ export const deleteAlertRoute = (router: IRouter, licenseState: LicenseState) => res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; await alertsClient.delete({ id }); diff --git a/x-pack/plugins/alerting/server/routes/disable.ts b/x-pack/plugins/alerting/server/routes/disable.ts index 5c6d977e62c38..da6562fb82af1 100644 --- a/x-pack/plugins/alerting/server/routes/disable.ts +++ b/x-pack/plugins/alerting/server/routes/disable.ts @@ -36,6 +36,9 @@ export const disableAlertRoute = (router: IRouter, licenseState: LicenseState) = res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; await alertsClient.disable({ id }); diff --git a/x-pack/plugins/alerting/server/routes/enable.ts b/x-pack/plugins/alerting/server/routes/enable.ts index f75344ad85998..1b995b7eb79b3 100644 --- a/x-pack/plugins/alerting/server/routes/enable.ts +++ b/x-pack/plugins/alerting/server/routes/enable.ts @@ -36,6 +36,9 @@ export const enableAlertRoute = (router: IRouter, licenseState: LicenseState) => res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; await alertsClient.enable({ id }); diff --git a/x-pack/plugins/alerting/server/routes/find.ts b/x-pack/plugins/alerting/server/routes/find.ts index 16f53aa218895..efc5c3ea97183 100644 --- a/x-pack/plugins/alerting/server/routes/find.ts +++ b/x-pack/plugins/alerting/server/routes/find.ts @@ -57,6 +57,9 @@ export const findAlertRoute = (router: IRouter, licenseState: LicenseState) => { res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } const alertsClient = context.alerting.getAlertsClient(); const query = req.query; const options: FindOptions['options'] = { diff --git a/x-pack/plugins/alerting/server/routes/get.ts b/x-pack/plugins/alerting/server/routes/get.ts index 407d80b0f87ab..3fa2040aabc1f 100644 --- a/x-pack/plugins/alerting/server/routes/get.ts +++ b/x-pack/plugins/alerting/server/routes/get.ts @@ -36,6 +36,9 @@ export const getAlertRoute = (router: IRouter, licenseState: LicenseState) => { res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; return res.ok({ diff --git a/x-pack/plugins/alerting/server/routes/get_alert_state.ts b/x-pack/plugins/alerting/server/routes/get_alert_state.ts index b419889eea422..725b9139b2837 100644 --- a/x-pack/plugins/alerting/server/routes/get_alert_state.ts +++ b/x-pack/plugins/alerting/server/routes/get_alert_state.ts @@ -36,6 +36,9 @@ export const getAlertStateRoute = (router: IRouter, licenseState: LicenseState) res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; const state = await alertsClient.getAlertState({ id }); diff --git a/x-pack/plugins/alerting/server/routes/list_alert_types.test.ts b/x-pack/plugins/alerting/server/routes/list_alert_types.test.ts index 96ee8c5717453..723fd86fca8b5 100644 --- a/x-pack/plugins/alerting/server/routes/list_alert_types.test.ts +++ b/x-pack/plugins/alerting/server/routes/list_alert_types.test.ts @@ -70,7 +70,7 @@ describe('listAlertTypesRoute', () => { } `); - expect(context.alerting.listTypes).toHaveBeenCalledTimes(1); + expect(context.alerting!.listTypes).toHaveBeenCalledTimes(1); expect(res.ok).toHaveBeenCalledWith({ body: listTypes, diff --git a/x-pack/plugins/alerting/server/routes/list_alert_types.ts b/x-pack/plugins/alerting/server/routes/list_alert_types.ts index e33bb9a010bf7..6e2b7ebb9014c 100644 --- a/x-pack/plugins/alerting/server/routes/list_alert_types.ts +++ b/x-pack/plugins/alerting/server/routes/list_alert_types.ts @@ -29,6 +29,9 @@ export const listAlertTypesRoute = (router: IRouter, licenseState: LicenseState) res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } return res.ok({ body: context.alerting.listTypes(), }); diff --git a/x-pack/plugins/alerting/server/routes/mute_all.ts b/x-pack/plugins/alerting/server/routes/mute_all.ts index 796efd457f478..224c7e3bf7ea9 100644 --- a/x-pack/plugins/alerting/server/routes/mute_all.ts +++ b/x-pack/plugins/alerting/server/routes/mute_all.ts @@ -36,6 +36,9 @@ export const muteAllAlertRoute = (router: IRouter, licenseState: LicenseState) = res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; await alertsClient.muteAll({ id }); diff --git a/x-pack/plugins/alerting/server/routes/mute_instance.ts b/x-pack/plugins/alerting/server/routes/mute_instance.ts index bae7b00548a26..c0d9f01a99e23 100644 --- a/x-pack/plugins/alerting/server/routes/mute_instance.ts +++ b/x-pack/plugins/alerting/server/routes/mute_instance.ts @@ -37,6 +37,9 @@ export const muteAlertInstanceRoute = (router: IRouter, licenseState: LicenseSta res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } const alertsClient = context.alerting.getAlertsClient(); const { alertId, alertInstanceId } = req.params; await alertsClient.muteInstance({ alertId, alertInstanceId }); diff --git a/x-pack/plugins/alerting/server/routes/unmute_all.ts b/x-pack/plugins/alerting/server/routes/unmute_all.ts index 5483f691b5462..4ab009b5722a9 100644 --- a/x-pack/plugins/alerting/server/routes/unmute_all.ts +++ b/x-pack/plugins/alerting/server/routes/unmute_all.ts @@ -36,6 +36,9 @@ export const unmuteAllAlertRoute = (router: IRouter, licenseState: LicenseState) res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; await alertsClient.unmuteAll({ id }); diff --git a/x-pack/plugins/alerting/server/routes/unmute_instance.ts b/x-pack/plugins/alerting/server/routes/unmute_instance.ts index fc24ea88ddb67..26439d47f430e 100644 --- a/x-pack/plugins/alerting/server/routes/unmute_instance.ts +++ b/x-pack/plugins/alerting/server/routes/unmute_instance.ts @@ -37,6 +37,9 @@ export const unmuteAlertInstanceRoute = (router: IRouter, licenseState: LicenseS res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } const alertsClient = context.alerting.getAlertsClient(); const { alertId, alertInstanceId } = req.params; await alertsClient.unmuteInstance({ alertId, alertInstanceId }); diff --git a/x-pack/plugins/alerting/server/routes/update.ts b/x-pack/plugins/alerting/server/routes/update.ts index a402d13c5fbab..76b864a51aec6 100644 --- a/x-pack/plugins/alerting/server/routes/update.ts +++ b/x-pack/plugins/alerting/server/routes/update.ts @@ -57,6 +57,9 @@ export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) => res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; const { name, actions, params, schedule, tags } = req.body; diff --git a/x-pack/plugins/alerting/server/routes/update_api_key.ts b/x-pack/plugins/alerting/server/routes/update_api_key.ts index 0951b6c7b939e..3c8a7d911b158 100644 --- a/x-pack/plugins/alerting/server/routes/update_api_key.ts +++ b/x-pack/plugins/alerting/server/routes/update_api_key.ts @@ -36,6 +36,9 @@ export const updateApiKeyRoute = (router: IRouter, licenseState: LicenseState) = res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; await alertsClient.updateApiKey({ id }); diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 90bc7996729a6..635cf0cbd1371 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -21,7 +21,7 @@ export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefine declare module 'src/core/server' { interface RequestHandlerContext { - alerting: { + alerting?: { getAlertsClient: () => AlertsClient; listTypes: AlertTypeRegistry['list']; }; diff --git a/x-pack/plugins/apm/server/lib/errors/distribution/__tests__/get_buckets.test.ts b/x-pack/plugins/apm/server/lib/errors/distribution/__tests__/get_buckets.test.ts index 3ac47004279b3..ef1934235807b 100644 --- a/x-pack/plugins/apm/server/lib/errors/distribution/__tests__/get_buckets.test.ts +++ b/x-pack/plugins/apm/server/lib/errors/distribution/__tests__/get_buckets.test.ts @@ -53,7 +53,8 @@ describe('timeseriesFetcher', () => { 'apm_oss.spanIndices': 'apm-*', 'apm_oss.transactionIndices': 'apm-*', 'apm_oss.metricsIndices': 'apm-*', - apmAgentConfigurationIndex: '.apm-agent-configuration' + apmAgentConfigurationIndex: '.apm-agent-configuration', + apmCustomLinkIndex: '.apm-custom-link' }, dynamicIndexPattern: null as any } diff --git a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts new file mode 100644 index 0000000000000..0a0da332e73ae --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IClusterClient, Logger } from 'src/core/server'; +import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; + +export type Mappings = + | { + dynamic?: boolean; + properties: Record<string, Mappings>; + } + | { + type: string; + ignore_above?: number; + scaling_factor?: number; + ignore_malformed?: boolean; + coerce?: boolean; + }; + +export async function createOrUpdateIndex({ + index, + mappings, + esClient, + logger +}: { + index: string; + mappings: Mappings; + esClient: IClusterClient; + logger: Logger; +}) { + try { + const { callAsInternalUser } = esClient; + const indexExists = await callAsInternalUser('indices.exists', { index }); + const result = indexExists + ? await updateExistingIndex({ + index, + callAsInternalUser, + mappings + }) + : await createNewIndex({ + index, + callAsInternalUser, + mappings + }); + + if (!result.acknowledged) { + const resultError = + result && result.error && JSON.stringify(result.error); + throw new Error(resultError); + } + } catch (e) { + logger.error(`Could not create APM index: '${index}'. Error: ${e.message}`); + } +} + +function createNewIndex({ + index, + callAsInternalUser, + mappings +}: { + index: string; + callAsInternalUser: CallCluster; + mappings: Mappings; +}) { + return callAsInternalUser('indices.create', { + index, + body: { + // auto_expand_replicas: Allows cluster to not have replicas for this index + settings: { 'index.auto_expand_replicas': '0-1' }, + mappings + } + }); +} + +function updateExistingIndex({ + index, + callAsInternalUser, + mappings +}: { + index: string; + callAsInternalUser: CallCluster; + mappings: Mappings; +}) { + return callAsInternalUser('indices.putMapping', { + index, + body: mappings + }); +} diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts index 8cfb7e7edb4c6..bc03138e0c247 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts @@ -5,8 +5,11 @@ */ import { IClusterClient, Logger } from 'src/core/server'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; import { APMConfig } from '../../..'; +import { + createOrUpdateIndex, + Mappings +} from '../../helpers/create_or_update_index'; import { getApmIndicesConfig } from '../apm_indices/get_apm_indices'; export async function createApmAgentConfigurationIndex({ @@ -18,87 +21,54 @@ export async function createApmAgentConfigurationIndex({ config: APMConfig; logger: Logger; }) { - try { - const index = getApmIndicesConfig(config).apmAgentConfigurationIndex; - const { callAsInternalUser } = esClient; - const indexExists = await callAsInternalUser('indices.exists', { index }); - const result = indexExists - ? await updateExistingIndex(index, callAsInternalUser) - : await createNewIndex(index, callAsInternalUser); - - if (!result.acknowledged) { - const resultError = - result && result.error && JSON.stringify(result.error); - throw new Error( - `Unable to create APM Agent Configuration index '${index}': ${resultError}` - ); - } - } catch (e) { - logger.error(`Could not create APM Agent configuration: ${e.message}`); - } + const index = getApmIndicesConfig(config).apmAgentConfigurationIndex; + return createOrUpdateIndex({ index, esClient, logger, mappings }); } -function createNewIndex(index: string, callWithInternalUser: CallCluster) { - return callWithInternalUser('indices.create', { - index, - body: { - settings: { 'index.auto_expand_replicas': '0-1' }, - mappings: { properties: mappingProperties } - } - }); -} - -// Necessary for migration reasons -// Added in 7.5: `capture_body`, `transaction_max_spans`, `applied_by_agent`, `agent_name` and `etag` -function updateExistingIndex(index: string, callWithInternalUser: CallCluster) { - return callWithInternalUser('indices.putMapping', { - index, - body: { properties: mappingProperties } - }); -} - -const mappingProperties = { - '@timestamp': { - type: 'date' - }, - service: { - properties: { - name: { - type: 'keyword', - ignore_above: 1024 - }, - environment: { - type: 'keyword', - ignore_above: 1024 +const mappings: Mappings = { + properties: { + '@timestamp': { + type: 'date' + }, + service: { + properties: { + name: { + type: 'keyword', + ignore_above: 1024 + }, + environment: { + type: 'keyword', + ignore_above: 1024 + } } - } - }, - settings: { - properties: { - transaction_sample_rate: { - type: 'scaled_float', - scaling_factor: 1000, - ignore_malformed: true, - coerce: false - }, - capture_body: { - type: 'keyword', - ignore_above: 1024 - }, - transaction_max_spans: { - type: 'short' + }, + settings: { + properties: { + transaction_sample_rate: { + type: 'scaled_float', + scaling_factor: 1000, + ignore_malformed: true, + coerce: false + }, + capture_body: { + type: 'keyword', + ignore_above: 1024 + }, + transaction_max_spans: { + type: 'short' + } } + }, + applied_by_agent: { + type: 'boolean' + }, + agent_name: { + type: 'keyword', + ignore_above: 1024 + }, + etag: { + type: 'keyword', + ignore_above: 1024 } - }, - applied_by_agent: { - type: 'boolean' - }, - agent_name: { - type: 'keyword', - ignore_above: 1024 - }, - etag: { - type: 'keyword', - ignore_above: 1024 } }; diff --git a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts index 00493e53f06dd..f338ee058842c 100644 --- a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts +++ b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts @@ -25,6 +25,7 @@ export interface ApmIndicesConfig { 'apm_oss.transactionIndices': string; 'apm_oss.metricsIndices': string; apmAgentConfigurationIndex: string; + apmCustomLinkIndex: string; } export type ApmIndicesName = keyof ApmIndicesConfig; @@ -52,7 +53,8 @@ export function getApmIndicesConfig(config: APMConfig): ApmIndicesConfig { 'apm_oss.transactionIndices': config['apm_oss.transactionIndices'], 'apm_oss.metricsIndices': config['apm_oss.metricsIndices'], // system indices, not configurable - apmAgentConfigurationIndex: '.apm-agent-configuration' + apmAgentConfigurationIndex: '.apm-agent-configuration', + apmCustomLinkIndex: '.apm-custom-link' }; } diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap new file mode 100644 index 0000000000000..b3819ace40d6c --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`List Custom Links fetches all custom links 1`] = ` +Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [], + }, + }, + }, + "index": "myIndex", + "size": 500, +} +`; + +exports[`List Custom Links filters custom links 1`] = ` +Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "service.name", + }, + }, + ], + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "term": Object { + "transaction.name": "bar", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "transaction.name", + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }, + "index": "myIndex", + "size": 500, +} +`; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/create_or_update_custom_link.test.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/create_or_update_custom_link.test.ts new file mode 100644 index 0000000000000..624f01c649322 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/create_or_update_custom_link.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createOrUpdateCustomLink } from '../create_or_update_custom_link'; +import { CustomLink } from '../custom_link_types'; +import { Setup } from '../../../helpers/setup_request'; +import { mockNow } from '../../../../../../../legacy/plugins/apm/public/utils/testHelpers'; + +describe('Create or Update Custom link', () => { + const internalClientIndexMock = jest.fn(); + const mockedSetup = ({ + internalClient: { + index: internalClientIndexMock + }, + indices: { + apmCustomLinkIndex: 'apmCustomLinkIndex' + } + } as unknown) as Setup; + + const customLink = ({ + label: 'foo', + url: 'http://elastic.com/{{trace.id}}', + 'service.name': 'opbeans-java', + 'transaction.type': 'Request' + } as unknown) as CustomLink; + afterEach(() => { + internalClientIndexMock.mockClear(); + }); + + beforeAll(() => { + mockNow(1570737000000); + }); + + it('creates a new custom link', () => { + createOrUpdateCustomLink({ customLink, setup: mockedSetup }); + expect(internalClientIndexMock).toHaveBeenCalledWith({ + refresh: true, + index: 'apmCustomLinkIndex', + body: { + '@timestamp': 1570737000000, + label: 'foo', + url: 'http://elastic.com/{{trace.id}}', + 'service.name': 'opbeans-java', + 'transaction.type': 'Request' + } + }); + }); + it('update a new custom link', () => { + createOrUpdateCustomLink({ + customLinkId: 'bar', + customLink, + setup: mockedSetup + }); + expect(internalClientIndexMock).toHaveBeenCalledWith({ + refresh: true, + index: 'apmCustomLinkIndex', + id: 'bar', + body: { + '@timestamp': 1570737000000, + label: 'foo', + url: 'http://elastic.com/{{trace.id}}', + 'service.name': 'opbeans-java', + 'transaction.type': 'Request' + } + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/list_custom_links.test.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/list_custom_links.test.ts new file mode 100644 index 0000000000000..5466225dc3211 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/list_custom_links.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { listCustomLinks } from '../list_custom_links'; +import { + inspectSearchParams, + SearchParamsMock +} from '../../../../../../../legacy/plugins/apm/public/utils/testHelpers'; +import { Setup } from '../../../helpers/setup_request'; +import { + SERVICE_NAME, + TRANSACTION_NAME +} from '../../../../../common/elasticsearch_fieldnames'; + +describe('List Custom Links', () => { + let mock: SearchParamsMock; + + it('fetches all custom links', async () => { + mock = await inspectSearchParams(setup => + listCustomLinks({ + setup: (setup as unknown) as Setup + }) + ); + + expect(mock.params).toMatchSnapshot(); + }); + + it('filters custom links', async () => { + const filters = { + [SERVICE_NAME]: 'foo', + [TRANSACTION_NAME]: 'bar' + }; + mock = await inspectSearchParams(setup => + listCustomLinks({ + filters, + setup: (setup as unknown) as Setup + }) + ); + + expect(mock.params).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts new file mode 100644 index 0000000000000..cdb3cff616030 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IClusterClient, Logger } from 'src/core/server'; +import { APMConfig } from '../../..'; +import { + createOrUpdateIndex, + Mappings +} from '../../helpers/create_or_update_index'; +import { getApmIndicesConfig } from '../apm_indices/get_apm_indices'; + +export const createApmCustomLinkIndex = async ({ + esClient, + config, + logger +}: { + esClient: IClusterClient; + config: APMConfig; + logger: Logger; +}) => { + const index = getApmIndicesConfig(config).apmCustomLinkIndex; + return createOrUpdateIndex({ index, esClient, logger, mappings }); +}; + +const mappings: Mappings = { + properties: { + '@timestamp': { + type: 'date' + }, + label: { + type: 'text' + }, + url: { + type: 'keyword' + }, + service: { + properties: { + name: { + type: 'keyword' + }, + environment: { + type: 'keyword' + } + } + }, + transaction: { + properties: { + name: { + type: 'keyword' + }, + type: { + type: 'keyword' + } + } + } + } +}; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts new file mode 100644 index 0000000000000..809fe2050a072 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pick } from 'lodash'; +import { filterOptions } from '../../../routes/settings/custom_link'; +import { APMIndexDocumentParams } from '../../helpers/es_client'; +import { Setup } from '../../helpers/setup_request'; +import { CustomLink } from './custom_link_types'; + +export async function createOrUpdateCustomLink({ + customLinkId, + customLink, + setup +}: { + customLinkId?: string; + customLink: Omit<CustomLink, '@timestamp'>; + setup: Setup; +}) { + const { internalClient, indices } = setup; + + const params: APMIndexDocumentParams<CustomLink> = { + refresh: true, + index: indices.apmCustomLinkIndex, + body: { + '@timestamp': Date.now(), + label: customLink.label, + url: customLink.url, + ...pick(customLink, filterOptions) + } + }; + + // by specifying an id elasticsearch will delete the previous doc and insert the updated doc + if (customLinkId) { + params.id = customLinkId; + } + + return internalClient.index(params); +} diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts new file mode 100644 index 0000000000000..60b97712713a9 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; +import { FilterOptions } from '../../../routes/settings/custom_link'; + +export type CustomLink = { + id?: string; + '@timestamp': number; + label: string; + url: string; +} & FilterOptions; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts new file mode 100644 index 0000000000000..2f3ea0940cb26 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Setup } from '../../helpers/setup_request'; + +export async function deleteCustomLink({ + customLinkId, + setup +}: { + customLinkId: string; + setup: Setup; +}) { + const { internalClient, indices } = setup; + + const params = { + refresh: 'wait_for', + index: indices.apmCustomLinkIndex, + id: customLinkId + }; + + return internalClient.delete(params); +} diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts new file mode 100644 index 0000000000000..e6052da73b0db --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Setup } from '../../helpers/setup_request'; +import { CustomLink } from './custom_link_types'; +import { FilterOptions } from '../../../routes/settings/custom_link'; + +export async function listCustomLinks({ + setup, + filters = {} +}: { + setup: Setup; + filters?: FilterOptions; +}) { + const { internalClient, indices } = setup; + + const esFilters = Object.entries(filters).map(([key, value]) => { + return { + bool: { + minimum_should_match: 1, + should: [ + { term: { [key]: value } }, + { bool: { must_not: [{ exists: { field: key } }] } } + ] + } + }; + }); + + const params = { + index: indices.apmCustomLinkIndex, + size: 500, + body: { + query: { + bool: { + filter: esFilters + } + } + } + }; + const resp = await internalClient.search<CustomLink>(params); + return resp.hits.hits.map(item => ({ + id: item._id, + ...item._source + })); +} diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts index c4a0be0f48c14..02bf60d3605bd 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts @@ -28,7 +28,8 @@ function getSetup() { 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }, dynamicIndexPattern: null as any }; diff --git a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.test.ts b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.test.ts index 9ab31be9f7219..5e443b92aa91a 100644 --- a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.test.ts +++ b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.test.ts @@ -17,7 +17,8 @@ const mockIndices = { 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }; function getMockSetup(esResponse: any) { diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts index cc8fabe33e63d..7a3277965ef8e 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts @@ -42,7 +42,8 @@ describe('getAnomalySeries', () => { 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }, dynamicIndexPattern: null as any } diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.test.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.test.ts index 1970e39a2752e..a87a277eb0c0e 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.test.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.test.ts @@ -41,7 +41,8 @@ describe('timeseriesFetcher', () => { 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }, dynamicIndexPattern: null as any } diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 773f0d4e6fac5..db14730f802a9 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -12,6 +12,7 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; import { makeApmUsageCollector } from './lib/apm_telemetry'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; +import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; import { createApmApi } from './routes/create_apm_api'; import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; import { APMConfig, mergeConfigs, APMXPackConfig } from '.'; @@ -66,6 +67,12 @@ export class APMPlugin implements Plugin<APMPluginContract> { config: currentConfig, logger }); + // create custom action index without blocking setup lifecycle + createApmCustomLinkIndex({ + esClient: core.elasticsearch.dataClient, + config: currentConfig, + logger + }); plugins.home.tutorials.registerTutorial( tutorialProvider({ diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 21392edbb2c48..34f0536a90b4d 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -59,6 +59,12 @@ import { import { createApi } from './create_api'; import { serviceMapRoute, serviceMapServiceNodeRoute } from './service_map'; import { indicesPrivilegesRoute } from './security'; +import { + createCustomLinkRoute, + updateCustomLinkRoute, + deleteCustomLinkRoute, + listCustomLinksRoute +} from './settings/custom_link'; const createApmApi = () => { const api = createApi() @@ -126,7 +132,13 @@ const createApmApi = () => { .add(serviceMapServiceNodeRoute) // security - .add(indicesPrivilegesRoute); + .add(indicesPrivilegesRoute) + + // Custom links + .add(createCustomLinkRoute) + .add(updateCustomLinkRoute) + .add(deleteCustomLinkRoute) + .add(listCustomLinksRoute); return api; }; diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link.ts b/x-pack/plugins/apm/server/routes/settings/custom_link.ts new file mode 100644 index 0000000000000..5988d7f85b186 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/settings/custom_link.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; +import { + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_NAME, + TRANSACTION_TYPE +} from '../../../common/elasticsearch_fieldnames'; +import { createRoute } from '../create_route'; +import { setupRequest } from '../../lib/helpers/setup_request'; +import { createOrUpdateCustomLink } from '../../lib/settings/custom_link/create_or_update_custom_link'; +import { deleteCustomLink } from '../../lib/settings/custom_link/delete_custom_link'; +import { listCustomLinks } from '../../lib/settings/custom_link/list_custom_links'; + +const FilterOptionsRt = t.partial({ + [SERVICE_NAME]: t.string, + [SERVICE_ENVIRONMENT]: t.string, + [TRANSACTION_NAME]: t.string, + [TRANSACTION_TYPE]: t.string +}); + +export type FilterOptions = t.TypeOf<typeof FilterOptionsRt>; + +export const filterOptions: Array<keyof FilterOptions> = [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, + TRANSACTION_NAME +]; + +export const listCustomLinksRoute = createRoute(core => ({ + path: '/api/apm/settings/custom_links', + params: { + query: FilterOptionsRt + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { params } = context; + return await listCustomLinks({ setup, filters: params.query }); + } +})); + +const payload = t.intersection([ + t.type({ + label: t.string, + url: t.string + }), + FilterOptionsRt +]); + +export const createCustomLinkRoute = createRoute(() => ({ + method: 'POST', + path: '/api/apm/settings/custom_links', + params: { + body: payload + }, + options: { + tags: ['access:apm', 'access:apm_write'] + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const customLink = context.params.body; + const res = await createOrUpdateCustomLink({ customLink, setup }); + return res; + } +})); + +export const updateCustomLinkRoute = createRoute(() => ({ + method: 'PUT', + path: '/api/apm/settings/custom_links/{id}', + params: { + path: t.type({ + id: t.string + }), + body: payload + }, + options: { + tags: ['access:apm', 'access:apm_write'] + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { id } = context.params.path; + const customLink = context.params.body; + const res = await createOrUpdateCustomLink({ + customLinkId: id, + customLink, + setup + }); + return res; + } +})); + +export const deleteCustomLinkRoute = createRoute(() => ({ + method: 'DELETE', + path: '/api/apm/settings/custom_links/{id}', + params: { + path: t.type({ + id: t.string + }) + }, + options: { + tags: ['access:apm', 'access:apm_write'] + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { id } = context.params.path; + const res = await deleteCustomLink({ + customLinkId: id, + setup + }); + return res; + } +})); diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts new file mode 100644 index 0000000000000..1bf39e6616480 --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { CommentResponseRt } from './comment'; +import { UserRT } from '../user'; + +const CaseBasicRt = rt.type({ + description: rt.string, + state: rt.union([rt.literal('open'), rt.literal('closed')]), + tags: rt.array(rt.string), + title: rt.string, +}); + +export const CaseAttributesRt = rt.intersection([ + CaseBasicRt, + rt.type({ + comment_ids: rt.array(rt.string), + created_at: rt.string, + created_by: UserRT, + updated_at: rt.union([rt.string, rt.null]), + updated_by: rt.union([UserRT, rt.null]), + }), +]); + +export const CaseRequestRt = CaseBasicRt; + +export const CaseResponseRt = rt.intersection([ + CaseAttributesRt, + rt.type({ + id: rt.string, + version: rt.string, + }), + rt.partial({ + comments: rt.array(CommentResponseRt), + }), +]); + +export const CasesResponseRt = rt.type({ + cases: rt.array(CaseResponseRt), + page: rt.number, + per_page: rt.number, + total: rt.number, +}); + +export const CasePatchRequestRt = rt.intersection([ + rt.partial(CaseRequestRt.props), + rt.type({ id: rt.string, version: rt.string }), +]); + +export type CaseAttributes = rt.TypeOf<typeof CaseAttributesRt>; +export type CaseRequest = rt.TypeOf<typeof CaseRequestRt>; +export type CaseResponse = rt.TypeOf<typeof CaseResponseRt>; +export type CasesResponse = rt.TypeOf<typeof CasesResponseRt>; +export type CasePatchRequest = rt.TypeOf<typeof CasePatchRequestRt>; diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts new file mode 100644 index 0000000000000..cebfa00425728 --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { UserRT } from '../user'; + +const CommentBasicRt = rt.type({ + comment: rt.string, +}); + +export const CommentAttributesRt = rt.intersection([ + CommentBasicRt, + rt.type({ + created_at: rt.string, + created_by: UserRT, + updated_at: rt.union([rt.string, rt.null]), + updated_by: rt.union([UserRT, rt.null]), + }), +]); + +export const CommentRequestRt = CommentBasicRt; + +export const CommentResponseRt = rt.intersection([ + CommentAttributesRt, + rt.type({ + id: rt.string, + version: rt.string, + }), +]); + +export const AllCommentsResponseRT = rt.array(CommentResponseRt); + +export const CommentPatchRequestRt = rt.intersection([ + rt.partial(CommentRequestRt.props), + rt.type({ id: rt.string, version: rt.string }), +]); + +export const CommentsResponseRt = rt.type({ + comments: rt.array(CommentResponseRt), + page: rt.number, + per_page: rt.number, + total: rt.number, +}); + +export const AllCommentsResponseRt = rt.array(CommentResponseRt); + +export type CommentAttributes = rt.TypeOf<typeof CommentAttributesRt>; +export type CommentRequest = rt.TypeOf<typeof CommentRequestRt>; +export type CommentResponse = rt.TypeOf<typeof CommentResponseRt>; +export type AllCommentsResponse = rt.TypeOf<typeof AllCommentsResponseRt>; +export type CommentsResponse = rt.TypeOf<typeof CommentsResponseRt>; +export type CommentPatchRequest = rt.TypeOf<typeof CommentPatchRequestRt>; diff --git a/x-pack/plugins/case/common/api/cases/index.ts b/x-pack/plugins/case/common/api/cases/index.ts new file mode 100644 index 0000000000000..83e249e3257c4 --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './case'; +export * from './comment'; diff --git a/x-pack/plugins/case/common/api/index.ts b/x-pack/plugins/case/common/api/index.ts new file mode 100644 index 0000000000000..3e94d91569ca5 --- /dev/null +++ b/x-pack/plugins/case/common/api/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './cases'; +export * from './runtime_types'; +export * from './saved_object'; diff --git a/x-pack/plugins/case/common/api/runtime_types.ts b/x-pack/plugins/case/common/api/runtime_types.ts new file mode 100644 index 0000000000000..d5b858df38def --- /dev/null +++ b/x-pack/plugins/case/common/api/runtime_types.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { Errors, Type } from 'io-ts'; +import { failure } from 'io-ts/lib/PathReporter'; + +type ErrorFactory = (message: string) => Error; + +export const createPlainError = (message: string) => new Error(message); + +export const throwErrors = (createError: ErrorFactory) => (errors: Errors) => { + throw createError(failure(errors).join('\n')); +}; + +export const decodeOrThrow = <A, O, I>( + runtimeType: Type<A, O, I>, + createError: ErrorFactory = createPlainError +) => (inputValue: I) => + pipe(runtimeType.decode(inputValue), fold(throwErrors(createError), identity)); diff --git a/x-pack/plugins/case/common/api/saved_object.ts b/x-pack/plugins/case/common/api/saved_object.ts new file mode 100644 index 0000000000000..0da859649a34e --- /dev/null +++ b/x-pack/plugins/case/common/api/saved_object.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { either } from 'fp-ts/lib/Either'; + +const NumberFromString = new rt.Type<number, string, unknown>( + 'NumberFromString', + rt.number.is, + (u, c) => + either.chain(rt.string.validate(u, c), s => { + const n = +s; + return isNaN(n) ? rt.failure(u, c, 'cannot parse to a number') : rt.success(n); + }), + String +); + +export const SavedObjectFindOptionsRt = rt.partial({ + defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + fields: rt.array(rt.string), + filter: rt.string, + page: NumberFromString, + perPage: NumberFromString, + search: rt.string, + searchFields: rt.array(rt.string), + sortField: rt.string, + sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), +}); + +export type SavedObjectFindOptions = rt.TypeOf<typeof SavedObjectFindOptionsRt>; diff --git a/x-pack/plugins/case/common/api/user.ts b/x-pack/plugins/case/common/api/user.ts new file mode 100644 index 0000000000000..bf5cde7af03f3 --- /dev/null +++ b/x-pack/plugins/case/common/api/user.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const UserRT = rt.type({ + full_name: rt.union([rt.undefined, rt.string, rt.null]), + username: rt.union([rt.string, rt.null]), +}); diff --git a/x-pack/plugins/case/kibana.json b/x-pack/plugins/case/kibana.json index 23e3cc789ad3b..4a0151546c8fb 100644 --- a/x-pack/plugins/case/kibana.json +++ b/x-pack/plugins/case/kibana.json @@ -3,6 +3,10 @@ "id": "case", "kibanaVersion": "kibana", "requiredPlugins": ["security"], + "optionalPlugins": [ + "spaces", + "security" + ], "server": true, "ui": false, "version": "8.0.0" diff --git a/x-pack/plugins/case/server/index.ts b/x-pack/plugins/case/server/index.ts index 990aef19b74f7..f924810baa912 100644 --- a/x-pack/plugins/case/server/index.ts +++ b/x-pack/plugins/case/server/index.ts @@ -7,7 +7,6 @@ import { PluginInitializerContext } from '../../../../src/core/server'; import { ConfigSchema } from './config'; import { CasePlugin } from './plugin'; -export { CaseAttributes, CommentAttributes } from './routes/api/types'; export const config = { schema: ConfigSchema }; export const plugin = (initializerContext: PluginInitializerContext) => diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 5ca640f0b25c3..7ce3a61f03779 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -5,11 +5,15 @@ */ import { first, map } from 'rxjs/operators'; -import { CoreSetup, Logger, PluginInitializerContext } from 'kibana/server'; +import { Logger, PluginInitializerContext } from 'kibana/server'; +import { CoreSetup } from 'src/core/server'; + +import { SecurityPluginSetup } from '../../security/server'; + import { ConfigType } from './config'; import { initCaseApi } from './routes/api'; +import { caseSavedObjectType, caseCommentSavedObjectType } from './saved_object_types'; import { CaseService } from './services'; -import { SecurityPluginSetup } from '../../security/server'; function createConfig$(context: PluginInitializerContext) { return context.config.create<ConfigType>().pipe(map(config => config)); @@ -35,6 +39,9 @@ export class CasePlugin { return; } + core.savedObjects.registerType(caseSavedObjectType); + core.savedObjects.registerType(caseCommentSavedObjectType); + const service = new CaseService(this.log); this.log.debug( diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts index eb9afb27a749e..7c97adc1b31bf 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -5,12 +5,26 @@ */ import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from 'src/core/server'; -import { CASE_COMMENT_SAVED_OBJECT } from '../../../constants'; -export const createMockSavedObjectsRepository = (savedObject: any[] = []) => { +import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../../saved_object_types'; + +export const createMockSavedObjectsRepository = ({ + caseSavedObject = [], + caseCommentSavedObject = [], +}: { + caseSavedObject?: any[]; + caseCommentSavedObject?: any[]; +}) => { const mockSavedObjectsClientContract = ({ get: jest.fn((type, id) => { - const result = savedObject.filter(s => s.id === id); + if (type === CASE_COMMENT_SAVED_OBJECT) { + const result = caseCommentSavedObject.filter(s => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return result[0]; + } + const result = caseSavedObject.filter(s => s.id === id); if (!result.length) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } @@ -20,11 +34,20 @@ export const createMockSavedObjectsRepository = (savedObject: any[] = []) => { if (findArgs.hasReference && findArgs.hasReference.id === 'bad-guy') { throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); } + + if (findArgs.type === CASE_COMMENT_SAVED_OBJECT) { + return { + page: 1, + per_page: 5, + total: caseCommentSavedObject.length, + saved_objects: caseCommentSavedObject, + }; + } return { page: 1, per_page: 5, - total: savedObject.length, - saved_objects: savedObject, + total: caseSavedObject.length, + saved_objects: caseSavedObject, }; }), create: jest.fn((type, attributes, references) => { @@ -51,9 +74,16 @@ export const createMockSavedObjectsRepository = (savedObject: any[] = []) => { }; }), update: jest.fn((type, id, attributes) => { - if (!savedObject.find(s => s.id === id)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + if (type === CASE_COMMENT_SAVED_OBJECT) { + if (!caseCommentSavedObject.find(s => s.id === id)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + } else if (type === CASE_SAVED_OBJECT) { + if (!caseSavedObject.find(s => s.id === id)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } } + return { id, type, @@ -63,13 +93,17 @@ export const createMockSavedObjectsRepository = (savedObject: any[] = []) => { }; }), delete: jest.fn((type: string, id: string) => { - const result = savedObject.filter(s => s.id === id); - if (!result.length) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + let result = caseSavedObject.filter(s => s.id === id); + if (type === CASE_COMMENT_SAVED_OBJECT) { + result = caseCommentSavedObject.filter(s => s.id === id); } - if (type === 'case-workflow-comment' && id === 'bad-guy') { + if (type === CASE_COMMENT_SAVED_OBJECT && id === 'bad-guy') { throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); } + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return {}; }), deleteByNamespace: jest.fn(), diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index ac9eddd6dd2cb..32348fecba1be 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -8,7 +8,7 @@ import { IRouter } from 'kibana/server'; import { loggingServiceMock, httpServiceMock } from '../../../../../../../src/core/server/mocks'; import { CaseService } from '../../../services'; import { authenticationMock } from '../__fixtures__'; -import { RouteDeps } from '../index'; +import { RouteDeps } from '../types'; export const createRoute = async ( api: (deps: RouteDeps) => void, diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index c7f6b6fad7d1a..3701e4f14e8b3 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -4,11 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -export const mockCases = [ +import { SavedObject } from 'kibana/server'; +import { CaseAttributes, CommentAttributes } from '../../../../common/api'; + +export const mockCases: Array<SavedObject<CaseAttributes>> = [ { - type: 'case-workflow', + type: 'cases', id: 'mock-id-1', attributes: { + comment_ids: ['mock-comment-1'], created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -19,15 +23,20 @@ export const mockCases = [ state: 'open', tags: ['defacement'], updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + username: 'elastic', + }, }, references: [], updated_at: '2019-11-25T21:54:48.952Z', version: 'WzAsMV0=', }, { - type: 'case-workflow', + type: 'cases', id: 'mock-id-2', attributes: { + comment_ids: [], created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', @@ -38,15 +47,20 @@ export const mockCases = [ state: 'open', tags: ['Data Destruction'], updated_at: '2019-11-25T22:32:00.900Z', + updated_by: { + full_name: 'elastic', + username: 'elastic', + }, }, references: [], updated_at: '2019-11-25T22:32:00.900Z', version: 'WzQsMV0=', }, { - type: 'case-workflow', + type: 'cases', id: 'mock-id-3', attributes: { + comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -57,6 +71,10 @@ export const mockCases = [ state: 'open', tags: ['LOLBins'], updated_at: '2019-11-25T22:32:17.947Z', + updated_by: { + full_name: 'elastic', + username: 'elastic', + }, }, references: [], updated_at: '2019-11-25T22:32:17.947Z', @@ -73,9 +91,9 @@ export const mockCasesErrorTriggerData = [ }, ]; -export const mockCaseComments = [ +export const mockCaseComments: Array<SavedObject<CommentAttributes>> = [ { - type: 'case-workflow-comment', + type: 'cases-comment', id: 'mock-comment-1', attributes: { comment: 'Wow, good luck catching that bad meanie!', @@ -85,11 +103,15 @@ export const mockCaseComments = [ username: 'elastic', }, updated_at: '2019-11-25T21:55:00.177Z', + updated_by: { + full_name: 'elastic', + username: 'elastic', + }, }, references: [ { - type: 'case-workflow', - name: 'associated-case-workflow', + type: 'cases', + name: 'associated-cases', id: 'mock-id-1', }, ], @@ -97,7 +119,7 @@ export const mockCaseComments = [ version: 'WzEsMV0=', }, { - type: 'case-workflow-comment', + type: 'cases-comment', id: 'mock-comment-2', attributes: { comment: 'Well I decided to update my comment. So what? Deal with it.', @@ -107,19 +129,24 @@ export const mockCaseComments = [ username: 'elastic', }, updated_at: '2019-11-25T21:55:14.633Z', + updated_by: { + full_name: 'elastic', + username: 'elastic', + }, }, references: [ { - type: 'case-workflow', - name: 'associated-case-workflow', + type: 'cases', + name: 'associated-cases', id: 'mock-id-1', }, ], updated_at: '2019-11-25T21:55:14.633Z', + version: 'WzMsMV0=', }, { - type: 'case-workflow-comment', + type: 'cases-comment', id: 'mock-comment-3', attributes: { comment: 'Wow, good luck catching that bad meanie!', @@ -129,15 +156,20 @@ export const mockCaseComments = [ username: 'elastic', }, updated_at: '2019-11-25T22:32:30.608Z', + updated_by: { + full_name: 'elastic', + username: 'elastic', + }, }, references: [ { - type: 'case-workflow', - name: 'associated-case-workflow', + type: 'cases', + name: 'associated-cases', id: 'mock-id-3', }, ], updated_at: '2019-11-25T22:32:30.608Z', + version: 'WzYsMV0=', }, ]; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts new file mode 100644 index 0000000000000..00d06bfdd2677 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; + +export function initDeleteAllCommentsApi({ caseService, router }: RouteDeps) { + router.delete( + { + path: '/api/cases/{case_id}/comments', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + + const comments = await caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + }); + await Promise.all( + comments.saved_objects.map(comment => + caseService.deleteComment({ + client, + commentId: comment.id, + }) + ) + ); + + const updateCase = { + comment_ids: [], + }; + await caseService.patchCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + updatedAttributes: { + ...updateCase, + }, + }); + + return response.ok({ body: 'true' }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.test.ts similarity index 61% rename from x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts rename to x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.test.ts index e50b3cbaa9c9a..8f05fbce391f8 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.test.ts @@ -4,50 +4,61 @@ * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCases, - mockCasesErrorTriggerData, -} from '../__fixtures__'; -import { initDeleteCommentApi } from '../delete_comment'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; + mockCaseComments, +} from '../../__fixtures__'; +import { initDeleteCommentApi } from './delete_comment'; describe('DELETE comment', () => { let routeHandler: RequestHandler<any, any, any>; beforeAll(async () => { routeHandler = await createRoute(initDeleteCommentApi, 'delete'); }); - it(`deletes the comment. responds with 204`, async () => { + it(`deletes the comment. responds with 200`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/comments/{comment_id}', + path: '/api/cases/{case_id}/comments/{comment_id}', method: 'delete', params: { - comment_id: 'mock-id-1', + case_id: 'mock-id-1', + comment_id: 'mock-comment-1', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); - expect(response.status).toEqual(204); + expect(response.status).toEqual(200); }); it(`returns an error when thrown from deleteComment service`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/comments/{comment_id}', + path: '/api/cases/{case_id}/comments/{comment_id}', method: 'delete', params: { + case_id: 'mock-id-1', comment_id: 'bad-guy', }, }); const theContext = createRouteContext( - createMockSavedObjectsRepository(mockCasesErrorTriggerData) + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) ); const response = await routeHandler(theContext, request, kibanaResponseFactory); - expect(response.status).toEqual(400); + expect(response.status).toEqual(404); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts new file mode 100644 index 0000000000000..85c4701f82e1d --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; + +export function initDeleteCommentApi({ caseService, router }: RouteDeps) { + router.delete( + { + path: '/api/cases/{case_id}/comments/{comment_id}', + validate: { + params: schema.object({ + case_id: schema.string(), + comment_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const myCase = await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + }); + + if (!myCase.attributes.comment_ids.includes(request.params.comment_id)) { + throw Boom.notFound( + `This comment ${request.params.comment_id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` + ); + } + + await caseService.deleteComment({ + client, + commentId: request.params.comment_id, + }); + + const updateCase = { + comment_ids: myCase.attributes.comment_ids.filter( + cId => cId !== request.params.comment_id + ), + }; + await caseService.patchCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + updatedAttributes: { + ...updateCase, + }, + }); + + return response.ok({ body: 'true' }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts new file mode 100644 index 0000000000000..dcf70d0d9819c --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { + CommentsResponseRt, + SavedObjectFindOptionsRt, + throwErrors, +} from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { escapeHatch, transformComments, wrapError } from '../../utils'; + +export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{case_id}/comments/_find', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + query: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const query = pipe( + SavedObjectFindOptionsRt.decode(request.query), + fold(throwErrors(Boom.badRequest), identity) + ); + + const args = query + ? { + client: context.core.savedObjects.client, + caseId: request.params.case_id, + options: { + ...query, + sortField: 'created_at', + }, + } + : { + client: context.core.savedObjects.client, + caseId: request.params.case_id, + }; + + const theComments = await caseService.getAllCaseComments(args); + return response.ok({ body: CommentsResponseRt.encode(transformComments(theComments)) }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts similarity index 51% rename from x-pack/plugins/case/server/routes/api/get_comment.ts rename to x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts index d892b4cfebc3b..65f2de7125236 100644 --- a/x-pack/plugins/case/server/routes/api/get_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts @@ -5,26 +5,30 @@ */ import { schema } from '@kbn/config-schema'; -import { RouteDeps } from '.'; -import { flattenCommentSavedObject, wrapError } from './utils'; -export function initGetCommentApi({ caseService, router }: RouteDeps) { +import { AllCommentsResponseRt } from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { flattenCommentSavedObjects, wrapError } from '../../utils'; + +export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { router.get( { - path: '/api/cases/comments/{id}', + path: '/api/cases/{case_id}/comments', validate: { params: schema.object({ - id: schema.string(), + case_id: schema.string(), }), }, }, async (context, request, response) => { try { - const theComment = await caseService.getComment({ + const comments = await caseService.getAllCaseComments({ client: context.core.savedObjects.client, - commentId: request.params.id, + caseId: request.params.case_id, + }); + return response.ok({ + body: AllCommentsResponseRt.encode(flattenCommentSavedObjects(comments.saved_objects)), }); - return response.ok({ body: flattenCommentSavedObject(theComment) }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.test.ts similarity index 53% rename from x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts rename to x-pack/plugins/case/server/routes/api/cases/comments/get_comment.test.ts index 3add93acc641f..9c8d0e5254df0 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.test.ts @@ -3,18 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCaseComments, -} from '../__fixtures__'; -import { initGetCommentApi } from '../get_comment'; -import { kibanaResponseFactory, RequestHandler, SavedObject } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; -import { flattenCommentSavedObject } from '../utils'; -import { CommentAttributes } from '../types'; + mockCases, +} from '../../__fixtures__'; +import { flattenCommentSavedObject } from '../../utils'; +import { initGetCommentApi } from './get_comment'; describe('GET comment', () => { let routeHandler: RequestHandler<any, any, any>; @@ -23,33 +23,44 @@ describe('GET comment', () => { }); it(`returns the comment`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/comments/{id}', + path: '/api/cases/{case_id}/comments/{comment_id}', method: 'get', params: { - id: 'mock-comment-1', + case_id: 'mock-id-1', + comment_id: 'mock-comment-1', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual( - flattenCommentSavedObject( - mockCaseComments.find(s => s.id === 'mock-comment-1') as SavedObject<CommentAttributes> - ) - ); + const myPayload = mockCaseComments.find(s => s.id === 'mock-comment-1'); + expect(myPayload).not.toBeUndefined(); + if (myPayload != null) { + expect(response.payload).toEqual(flattenCommentSavedObject(myPayload)); + } }); it(`returns an error when getComment throws`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/comments/{id}', + path: '/api/cases/{case_id}/comments/{comment_id}', method: 'get', params: { - id: 'not-real', + case_id: 'mock-id-1', + comment_id: 'not-real', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(404); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts new file mode 100644 index 0000000000000..06619abae8487 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; + +import { CommentResponseRt } from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { flattenCommentSavedObject, wrapError } from '../../utils'; + +export function initGetCommentApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{case_id}/comments/{comment_id}', + validate: { + params: schema.object({ + case_id: schema.string(), + comment_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const myCase = await caseService.getCase({ + client, + caseId: request.params.case_id, + }); + + if (!myCase.attributes.comment_ids.includes(request.params.comment_id)) { + throw Boom.notFound( + `This comment ${request.params.comment_id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` + ); + } + + const comment = await caseService.getComment({ + client, + commentId: request.params.comment_id, + }); + return response.ok({ + body: CommentResponseRt.encode(flattenCommentSavedObject(comment)), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts similarity index 64% rename from x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts rename to x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts index 6b4e3c194eb82..4e7e266f326a2 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts @@ -3,72 +3,93 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCaseComments, -} from '../__fixtures__'; -import { initUpdateCommentApi } from '../update_comment'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; + mockCases, +} from '../../__fixtures__'; +import { initPatchCommentApi } from './patch_comment'; -describe('UPDATE comment', () => { +describe('PATCH comment', () => { let routeHandler: RequestHandler<any, any, any>; beforeAll(async () => { - routeHandler = await createRoute(initUpdateCommentApi, 'patch'); + routeHandler = await createRoute(initPatchCommentApi, 'patch'); }); - it(`Updates a comment`, async () => { + it(`Patch a comment`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/comment/{id}', + path: '/api/cases/{case_id}/comments', method: 'patch', params: { - id: 'mock-comment-1', + case_id: 'mock-id-1', }, body: { comment: 'Update my comment', + id: 'mock-comment-1', version: 'WzEsMV0=', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.comment).toEqual('Update my comment'); }); + it(`Fails with 409 if version does not match`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/comment/{id}', + path: '/api/cases/{case_id}/comments', method: 'patch', params: { - id: 'mock-comment-1', + case_id: 'mock-id-1', }, body: { + id: 'mock-comment-1', comment: 'Update my comment', version: 'badv=', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(409); }); it(`Returns an error if updateComment throws`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/comment/{id}', + path: '/api/cases/{case_id}/comments', method: 'patch', params: { - id: 'mock-comment-does-not-exist', + case_id: 'mock-id-1', }, body: { comment: 'Update my comment', + id: 'mock-comment-does-not-exist', + version: 'WzEsMV0=', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(404); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts new file mode 100644 index 0000000000000..f1568f22c6c99 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { CommentPatchRequestRt, CommentResponseRt, throwErrors } from '../../../../../common/api'; + +import { RouteDeps } from '../../types'; +import { escapeHatch, wrapError, flattenCommentSavedObject } from '../../utils'; + +export function initPatchCommentApi({ caseService, router }: RouteDeps) { + router.patch( + { + path: '/api/cases/{case_id}/comments', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const query = pipe( + CommentPatchRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + const myCase = await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + }); + + if (!myCase.attributes.comment_ids.includes(query.id)) { + throw Boom.notFound( + `This comment ${query.id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` + ); + } + + const myComment = await caseService.getComment({ + client: context.core.savedObjects.client, + commentId: query.id, + }); + + if (query.version !== myComment.version) { + throw Boom.conflict( + 'This case has been updated. Please refresh before saving additional updates.' + ); + } + + const updatedBy = await caseService.getUser({ request, response }); + const { full_name, username } = updatedBy; + const updatedComment = await caseService.patchComment({ + client: context.core.savedObjects.client, + commentId: query.id, + updatedAttributes: { + ...query, + updated_at: new Date().toISOString(), + updated_by: { full_name, username }, + }, + }); + + return response.ok({ + body: CommentResponseRt.encode( + flattenCommentSavedObject({ + ...updatedComment, + attributes: { ...myComment.attributes, ...updatedComment.attributes }, + references: myComment.references, + }) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts similarity index 66% rename from x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts rename to x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts index 653140af2a7cf..e51ec7c894d08 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts @@ -4,15 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCases, -} from '../__fixtures__'; -import { initPostCommentApi } from '../post_comment'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; + mockCaseComments, +} from '../../__fixtures__'; +import { initPostCommentApi } from './post_comment'; describe('POST comment', () => { let routeHandler: RequestHandler<any, any, any>; @@ -21,35 +23,45 @@ describe('POST comment', () => { }); it(`Posts a new comment`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}/comment', + path: '/api/cases/{case_id}/comments', method: 'post', params: { - id: 'mock-id-1', + case_id: 'mock-id-1', }, body: { comment: 'Wow, good luck catching that bad meanie!', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comment_id).toEqual('mock-comment'); + expect(response.payload.id).toEqual('mock-comment'); }); it(`Returns an error if the case does not exist`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}/comment', + path: '/api/cases/{case_id}/comments', method: 'post', params: { - id: 'this-is-not-real', + case_id: 'this-is-not-real', }, body: { comment: 'Wow, good luck catching that bad meanie!', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(404); @@ -57,17 +69,22 @@ describe('POST comment', () => { }); it(`Returns an error if postNewCase throws`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}/comment', + path: '/api/cases/{case_id}/comments', method: 'post', params: { - id: 'mock-id-1', + case_id: 'mock-id-1', }, body: { comment: 'Throw an error', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(400); @@ -77,17 +94,22 @@ describe('POST comment', () => { routeHandler = await createRoute(initPostCommentApi, 'post', true); const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}/comment', + path: '/api/cases/{case_id}/comments', method: 'post', params: { - id: 'mock-id-1', + case_id: 'mock-id-1', }, body: { comment: 'Wow, good luck catching that bad meanie!', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(500); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts new file mode 100644 index 0000000000000..9e82a8ffaaec7 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { CommentRequestRt, CommentResponseRt, throwErrors } from '../../../../../common/api'; +import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { + escapeHatch, + transformNewComment, + wrapError, + flattenCommentSavedObject, +} from '../../utils'; +import { RouteDeps } from '../../types'; + +export function initPostCommentApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases/{case_id}/comments', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const query = pipe( + CommentRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + const myCase = await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + }); + + const createdBy = await caseService.getUser({ request, response }); + const createdDate = new Date().toISOString(); + + const newComment = await caseService.postNewComment({ + client: context.core.savedObjects.client, + attributes: transformNewComment({ + createdDate, + ...query, + ...createdBy, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: myCase.id, + }, + ], + }); + + const updateCase = { + comment_ids: [...myCase.attributes.comment_ids, newComment.id], + }; + + await caseService.patchCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + updatedAttributes: { + ...updateCase, + }, + }); + + return response.ok({ + body: CommentResponseRt.encode(flattenCommentSavedObject(newComment)), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts similarity index 60% rename from x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts rename to x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts index 9ea42ba42406b..cee705694f21d 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts @@ -4,61 +4,76 @@ * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCases, mockCasesErrorTriggerData, + mockCaseComments, } from '../__fixtures__'; -import { initDeleteCaseApi } from '../delete_case'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; +import { initDeleteCasesApi } from './delete_cases'; describe('DELETE case', () => { let routeHandler: RequestHandler<any, any, any>; beforeAll(async () => { - routeHandler = await createRoute(initDeleteCaseApi, 'delete'); + routeHandler = await createRoute(initDeleteCasesApi, 'delete'); }); - it(`deletes the case. responds with 204`, async () => { + it(`deletes the case. responds with 200`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'delete', - params: { - id: 'mock-id-1', + query: { + ids: ['mock-id-1'], }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); - expect(response.status).toEqual(204); + expect(response.status).toEqual(200); }); it(`returns an error when thrown from deleteCase service`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'delete', - params: { - id: 'not-real', + query: { + ids: ['not-real'], }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(404); }); it(`returns an error when thrown from getAllCaseComments service`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'delete', - params: { - id: 'bad-guy', + query: { + ids: ['bad-guy'], }, }); const theContext = createRouteContext( - createMockSavedObjectsRepository(mockCasesErrorTriggerData) + createMockSavedObjectsRepository({ + caseSavedObject: mockCasesErrorTriggerData, + caseCommentSavedObject: mockCaseComments, + }) ); const response = await routeHandler(theContext, request, kibanaResponseFactory); @@ -66,15 +81,18 @@ describe('DELETE case', () => { }); it(`returns an error when thrown from deleteComment service`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'delete', - params: { - id: 'valid-id', + query: { + ids: ['valid-id'], }, }); const theContext = createRouteContext( - createMockSavedObjectsRepository(mockCasesErrorTriggerData) + createMockSavedObjectsRepository({ + caseSavedObject: mockCasesErrorTriggerData, + caseCommentSavedObject: mockCasesErrorTriggerData, + }) ); const response = await routeHandler(theContext, request, kibanaResponseFactory); diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts new file mode 100644 index 0000000000000..559a477a83a6c --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '../types'; +import { wrapError } from '../utils'; + +export function initDeleteCasesApi({ caseService, router }: RouteDeps) { + router.delete( + { + path: '/api/cases', + validate: { + query: schema.object({ + ids: schema.arrayOf(schema.string()), + }), + }, + }, + async (context, request, response) => { + try { + await Promise.all( + request.query.ids.map(id => + caseService.deleteCase({ + client: context.core.savedObjects.client, + caseId: id, + }) + ) + ); + const comments = await Promise.all( + request.query.ids.map(id => + caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: id, + }) + ) + ); + + if (comments.some(c => c.saved_objects.length > 0)) { + await Promise.all( + comments.map(c => + Promise.all( + c.saved_objects.map(({ id }) => + caseService.deleteComment({ + client: context.core.savedObjects.client, + commentId: id, + }) + ) + ) + ) + ); + } + return response.ok({ body: 'true' }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/get_all_cases.test.ts similarity index 84% rename from x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts rename to x-pack/plugins/case/server/routes/api/cases/get_all_cases.test.ts index 96c411a746d49..ec56c32f91745 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_all_cases.test.ts @@ -4,15 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCases, } from '../__fixtures__'; -import { initGetAllCasesApi } from '../get_all_cases'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; +import { initGetAllCasesApi } from './get_all_cases'; describe('GET all cases', () => { let routeHandler: RequestHandler<any, any, any>; @@ -25,7 +26,11 @@ describe('GET all cases', () => { method: 'get', }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); diff --git a/x-pack/plugins/case/server/routes/api/get_all_cases.ts b/x-pack/plugins/case/server/routes/api/cases/get_all_cases.ts similarity index 52% rename from x-pack/plugins/case/server/routes/api/get_all_cases.ts rename to x-pack/plugins/case/server/routes/api/cases/get_all_cases.ts index ba26a07dc2394..96b8e8c110c01 100644 --- a/x-pack/plugins/case/server/routes/api/get_all_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_all_cases.ts @@ -4,37 +4,45 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; -import { RouteDeps } from '.'; -import { formatAllCases, sortToSnake, wrapError } from './utils'; -import { SavedObjectsFindOptionsSchema } from './schema'; -import { AllCases } from './types'; +import Boom from 'boom'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { CasesResponseRt, SavedObjectFindOptionsRt, throwErrors } from '../../../../common/api'; +import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; +import { RouteDeps } from '../types'; export function initGetAllCasesApi({ caseService, router }: RouteDeps) { router.get( { - path: '/api/cases', + path: '/api/cases/_find', validate: { - query: schema.nullable(SavedObjectsFindOptionsSchema), + query: escapeHatch, }, }, async (context, request, response) => { try { - const args = request.query + const query = pipe( + SavedObjectFindOptionsRt.decode(request.query), + fold(throwErrors(Boom.badRequest), identity) + ); + + const args = query ? { client: context.core.savedObjects.client, options: { - ...request.query, - sortField: sortToSnake(request.query.sortField ?? ''), + ...query, + sortField: sortToSnake(query.sortField ?? ''), }, } : { client: context.core.savedObjects.client, }; const cases = await caseService.getAllCases(args); - const body: AllCases = formatAllCases(cases); return response.ok({ - body, + body: CasesResponseRt.encode(transformCases(cases)), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts similarity index 74% rename from x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts rename to x-pack/plugins/case/server/routes/api/cases/get_case.test.ts index 60becf1228a0c..5912df2c40aa3 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts @@ -4,18 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler, SavedObject } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +import { CaseAttributes } from '../../../../common/api'; import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCases, mockCasesErrorTriggerData, + mockCaseComments, } from '../__fixtures__'; -import { initGetCaseApi } from '../get_case'; -import { kibanaResponseFactory, RequestHandler, SavedObject } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; import { flattenCaseSavedObject } from '../utils'; -import { CaseAttributes } from '../types'; +import { initGetCaseApi } from './get_case'; describe('GET case', () => { let routeHandler: RequestHandler<any, any, any>; @@ -24,17 +26,21 @@ describe('GET case', () => { }); it(`returns the case with empty case comments when includeComments is false`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases/{case_id}', + method: 'get', params: { - id: 'mock-id-1', + case_id: 'mock-id-1', }, - method: 'get', query: { includeComments: false, }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); @@ -49,17 +55,21 @@ describe('GET case', () => { }); it(`returns an error when thrown from getCase`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases/{case_id}', + method: 'get', params: { - id: 'abcdefg', + case_id: 'abcdefg', }, - method: 'get', query: { includeComments: false, }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); @@ -68,17 +78,22 @@ describe('GET case', () => { }); it(`returns the case with case comments when includeComments is true`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases/{case_id}', + method: 'get', params: { - id: 'mock-id-1', + case_id: 'mock-id-1', }, - method: 'get', query: { includeComments: true, }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); @@ -87,18 +102,20 @@ describe('GET case', () => { }); it(`returns an error when thrown from getAllCaseComments`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases/{case_id}', + method: 'get', params: { - id: 'bad-guy', + case_id: 'bad-guy', }, - method: 'get', query: { includeComments: true, }, }); const theContext = createRouteContext( - createMockSavedObjectsRepository(mockCasesErrorTriggerData) + createMockSavedObjectsRepository({ + caseSavedObject: mockCasesErrorTriggerData, + }) ); const response = await routeHandler(theContext, request, kibanaResponseFactory); diff --git a/x-pack/plugins/case/server/routes/api/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts similarity index 58% rename from x-pack/plugins/case/server/routes/api/get_case.ts rename to x-pack/plugins/case/server/routes/api/cases/get_case.ts index 2481197000beb..1415513bca346 100644 --- a/x-pack/plugins/case/server/routes/api/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -5,16 +5,18 @@ */ import { schema } from '@kbn/config-schema'; -import { RouteDeps } from '.'; -import { flattenCaseSavedObject, wrapError } from './utils'; + +import { CaseResponseRt } from '../../../../common/api'; +import { RouteDeps } from '../types'; +import { flattenCaseSavedObject, wrapError } from '../utils'; export function initGetCaseApi({ caseService, router }: RouteDeps) { router.get( { - path: '/api/cases/{id}', + path: '/api/cases/{case_id}', validate: { params: schema.object({ - id: schema.string(), + case_id: schema.string(), }), query: schema.object({ includeComments: schema.string({ defaultValue: 'true' }), @@ -22,26 +24,25 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { }, }, async (context, request, response) => { - let theCase; - const includeComments = JSON.parse(request.query.includeComments); try { - theCase = await caseService.getCase({ + const includeComments = JSON.parse(request.query.includeComments); + + const theCase = await caseService.getCase({ client: context.core.savedObjects.client, - caseId: request.params.id, + caseId: request.params.case_id, }); - } catch (error) { - return response.customError(wrapError(error)); - } - if (!includeComments) { - return response.ok({ body: flattenCaseSavedObject(theCase, []) }); - } - try { + + if (!includeComments) { + return response.ok({ body: CaseResponseRt.encode(flattenCaseSavedObject(theCase, [])) }); + } + const theComments = await caseService.getAllCaseComments({ client: context.core.savedObjects.client, - caseId: request.params.id, + caseId: request.params.case_id, }); + return response.ok({ - body: { ...flattenCaseSavedObject(theCase, theComments.saved_objects) }, + body: CaseResponseRt.encode(flattenCaseSavedObject(theCase, theComments.saved_objects)), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_case.test.ts similarity index 69% rename from x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts rename to x-pack/plugins/case/server/routes/api/cases/patch_case.test.ts index 25d5cafb4bb06..42fe9967ad0a0 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_case.test.ts @@ -4,35 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCases, + mockCaseComments, } from '../__fixtures__'; -import { initUpdateCaseApi } from '../update_case'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; +import { initPatchCaseApi } from './patch_case'; -describe('UPDATE case', () => { +describe('PATCH case', () => { let routeHandler: RequestHandler<any, any, any>; beforeAll(async () => { - routeHandler = await createRoute(initUpdateCaseApi, 'patch'); + routeHandler = await createRoute(initPatchCaseApi, 'patch'); }); - it(`Updates a case`, async () => { + it(`Patch a case`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'patch', - params: { - id: 'mock-id-1', - }, body: { - case: { state: 'closed' }, + id: 'mock-id-1', + state: 'closed', version: 'WzAsMV0=', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); @@ -41,53 +45,61 @@ describe('UPDATE case', () => { }); it(`Fails with 409 if version does not match`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'patch', - params: { - id: 'mock-id-1', - }, body: { + id: 'mock-id-1', case: { state: 'closed' }, version: 'badv=', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(409); }); it(`Fails with 406 if updated field is unchanged`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'patch', - params: { - id: 'mock-id-1', - }, body: { + id: 'mock-id-1', case: { state: 'open' }, version: 'WzAsMV0=', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(406); }); it(`Returns an error if updateCase throws`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'patch', - params: { - id: 'mock-id-does-not-exist', - }, body: { + id: 'mock-id-does-not-exist', state: 'closed', + version: 'WzAsMV0=', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(404); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_case.ts b/x-pack/plugins/case/server/routes/api/cases/patch_case.ts new file mode 100644 index 0000000000000..eccede372c688 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/patch_case.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { difference, get } from 'lodash'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { + CaseAttributes, + CasePatchRequestRt, + throwErrors, + CaseResponseRt, +} from '../../../../common/api'; +import { escapeHatch, wrapError, flattenCaseSavedObject } from '../utils'; +import { RouteDeps } from '../types'; + +export function initPatchCaseApi({ caseService, router }: RouteDeps) { + router.patch( + { + path: '/api/cases', + validate: { + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const query = pipe( + CasePatchRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + const myCase = await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: query.id, + }); + + if (query.version !== myCase.version) { + throw Boom.conflict( + 'This case has been updated. Please refresh before saving additional updates.' + ); + } + const currentCase: CaseAttributes = myCase.attributes; + const updateCase: Partial<CaseAttributes> = Object.entries(query).reduce( + (acc, [key, value]) => { + const currentValue = get(currentCase, key); + if ( + currentValue != null && + Array.isArray(value) && + Array.isArray(currentValue) && + difference(value, currentValue).length !== 0 + ) { + return { + ...acc, + [key]: value, + }; + } else if (currentValue != null && value !== currentValue) { + return { + ...acc, + [key]: value, + }; + } + return acc; + }, + {} + ); + if (Object.keys(updateCase).length > 0) { + const updatedBy = await caseService.getUser({ request, response }); + const { full_name, username } = updatedBy; + const updatedCase = await caseService.patchCase({ + client: context.core.savedObjects.client, + caseId: query.id, + updatedAttributes: { + ...updateCase, + updated_at: new Date().toISOString(), + updated_by: { full_name, username }, + }, + }); + return response.ok({ + body: CaseResponseRt.encode( + flattenCaseSavedObject({ + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase.attributes }, + references: myCase.references, + }) + ), + }); + } + throw Boom.notAcceptable('All update fields are identical to current version.'); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts similarity index 82% rename from x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts rename to x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 32c7c5a015af0..0d14a659d2c42 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -4,15 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCases, } from '../__fixtures__'; -import { initPostCaseApi } from '../post_case'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; +import { initPostCaseApi } from './post_case'; describe('POST cases', () => { let routeHandler: RequestHandler<any, any, any>; @@ -31,11 +32,15 @@ describe('POST cases', () => { }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.case_id).toEqual('mock-it'); + expect(response.payload.id).toEqual('mock-it'); expect(response.payload.created_by.username).toEqual('awesome'); }); it(`Returns an error if postNewCase throws`, async () => { @@ -50,7 +55,11 @@ describe('POST cases', () => { }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(400); @@ -70,7 +79,11 @@ describe('POST cases', () => { }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(500); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts new file mode 100644 index 0000000000000..9e854c3178e1e --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { flattenCaseSavedObject, transformNewCase, wrapError, escapeHatch } from '../utils'; + +import { CaseRequestRt, throwErrors, CaseResponseRt } from '../../../../common/api'; +import { RouteDeps } from '../types'; + +export function initPostCaseApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases', + validate: { + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const query = pipe( + CaseRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + const createdBy = await caseService.getUser({ request, response }); + const createdDate = new Date().toISOString(); + const newCase = await caseService.postNewCase({ + client: context.core.savedObjects.client, + attributes: transformNewCase({ + createdDate, + newCase: query, + ...createdBy, + }), + }); + return response.ok({ body: CaseResponseRt.encode(flattenCaseSavedObject(newCase, [])) }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_tags.ts b/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts similarity index 89% rename from x-pack/plugins/case/server/routes/api/get_tags.ts rename to x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts index 1d714db4c0c28..b1a2f10dd6f95 100644 --- a/x-pack/plugins/case/server/routes/api/get_tags.ts +++ b/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RouteDeps } from './index'; -import { wrapError } from './utils'; +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; export function initGetTagsApi({ caseService, router }: RouteDeps) { router.get( diff --git a/x-pack/plugins/case/server/routes/api/delete_case.ts b/x-pack/plugins/case/server/routes/api/delete_case.ts deleted file mode 100644 index a5ae72b8b46ff..0000000000000 --- a/x-pack/plugins/case/server/routes/api/delete_case.ts +++ /dev/null @@ -1,56 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { RouteDeps } from '.'; -import { wrapError } from './utils'; - -export function initDeleteCaseApi({ caseService, router }: RouteDeps) { - router.delete( - { - path: '/api/cases/{id}', - validate: { - params: schema.object({ - id: schema.string(), - }), - }, - }, - async (context, request, response) => { - let allCaseComments; - try { - await caseService.deleteCase({ - client: context.core.savedObjects.client, - caseId: request.params.id, - }); - } catch (error) { - return response.customError(wrapError(error)); - } - try { - allCaseComments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, - caseId: request.params.id, - }); - } catch (error) { - return response.customError(wrapError(error)); - } - try { - if (allCaseComments.saved_objects.length > 0) { - await Promise.all( - allCaseComments.saved_objects.map(({ id }) => - caseService.deleteComment({ - client: context.core.savedObjects.client, - commentId: id, - }) - ) - ); - } - return response.noContent(); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/delete_comment.ts b/x-pack/plugins/case/server/routes/api/delete_comment.ts deleted file mode 100644 index 4a540dd9fd69f..0000000000000 --- a/x-pack/plugins/case/server/routes/api/delete_comment.ts +++ /dev/null @@ -1,34 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { RouteDeps } from '.'; -import { wrapError } from './utils'; - -export function initDeleteCommentApi({ caseService, router }: RouteDeps) { - router.delete( - { - path: '/api/cases/comments/{comment_id}', - validate: { - params: schema.object({ - comment_id: schema.string(), - }), - }, - }, - async (context, request, response) => { - const client = context.core.savedObjects.client; - try { - await caseService.deleteComment({ - client, - commentId: request.params.comment_id, - }); - return response.noContent(); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts b/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts deleted file mode 100644 index b74227fa8d983..0000000000000 --- a/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { RouteDeps } from '.'; -import { formatAllComments, wrapError } from './utils'; - -export function initGetAllCaseCommentsApi({ caseService, router }: RouteDeps) { - router.get( - { - path: '/api/cases/{id}/comments', - validate: { - params: schema.object({ - id: schema.string(), - }), - }, - }, - async (context, request, response) => { - try { - const theComments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, - caseId: request.params.id, - }); - return response.ok({ body: formatAllComments(theComments) }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 32dfd6a78d1c2..f4dca6a64c8d2 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -4,35 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IRouter } from 'src/core/server'; -import { CaseServiceSetup } from '../../services'; -import { initDeleteCaseApi } from './delete_case'; -import { initDeleteCommentApi } from './delete_comment'; -import { initGetAllCaseCommentsApi } from './get_all_case_comments'; -import { initGetAllCasesApi } from './get_all_cases'; -import { initGetCaseApi } from './get_case'; -import { initGetCommentApi } from './get_comment'; -import { initGetTagsApi } from './get_tags'; -import { initPostCaseApi } from './post_case'; -import { initPostCommentApi } from './post_comment'; -import { initUpdateCaseApi } from './update_case'; -import { initUpdateCommentApi } from './update_comment'; +import { initDeleteCasesApi } from './cases/delete_cases'; +import { initGetAllCasesApi } from './cases/get_all_cases'; +import { initGetCaseApi } from './cases/get_case'; +import { initPatchCaseApi } from './cases/patch_case'; +import { initPostCaseApi } from './cases/post_case'; -export interface RouteDeps { - caseService: CaseServiceSetup; - router: IRouter; -} +import { initDeleteCommentApi } from './cases/comments/delete_comment'; +import { initDeleteAllCommentsApi } from './cases/comments/delete_all_comments'; +import { initFindCaseCommentsApi } from './cases/comments/find_comments'; +import { initGetAllCommentsApi } from './cases/comments/get_all_comment'; +import { initGetCommentApi } from './cases/comments/get_comment'; +import { initPatchCommentApi } from './cases/comments/patch_comment'; +import { initPostCommentApi } from './cases/comments/post_comment'; + +import { initGetTagsApi } from './cases/tags/get_tags'; + +import { RouteDeps } from './types'; export function initCaseApi(deps: RouteDeps) { - initDeleteCaseApi(deps); + initDeleteCasesApi(deps); initDeleteCommentApi(deps); - initGetAllCaseCommentsApi(deps); + initDeleteAllCommentsApi(deps); + initFindCaseCommentsApi(deps); initGetAllCasesApi(deps); initGetCaseApi(deps); initGetCommentApi(deps); + initGetAllCommentsApi(deps); initGetTagsApi(deps); initPostCaseApi(deps); initPostCommentApi(deps); - initUpdateCaseApi(deps); - initUpdateCommentApi(deps); + initPatchCaseApi(deps); + initPatchCommentApi(deps); } diff --git a/x-pack/plugins/case/server/routes/api/post_case.ts b/x-pack/plugins/case/server/routes/api/post_case.ts deleted file mode 100644 index 948bf02d5b3c1..0000000000000 --- a/x-pack/plugins/case/server/routes/api/post_case.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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { flattenCaseSavedObject, formatNewCase, wrapError } from './utils'; -import { NewCaseSchema } from './schema'; -import { RouteDeps } from '.'; - -export function initPostCaseApi({ caseService, router }: RouteDeps) { - router.post( - { - path: '/api/cases', - validate: { - body: NewCaseSchema, - }, - }, - async (context, request, response) => { - let createdBy; - try { - createdBy = await caseService.getUser({ request, response }); - } catch (error) { - return response.customError(wrapError(error)); - } - - try { - const newCase = await caseService.postNewCase({ - client: context.core.savedObjects.client, - attributes: formatNewCase(request.body, { - ...createdBy, - }), - }); - return response.ok({ body: flattenCaseSavedObject(newCase, []) }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/post_comment.ts b/x-pack/plugins/case/server/routes/api/post_comment.ts deleted file mode 100644 index f3f21becddfad..0000000000000 --- a/x-pack/plugins/case/server/routes/api/post_comment.ts +++ /dev/null @@ -1,62 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { flattenCommentSavedObject, formatNewComment, wrapError } from './utils'; -import { NewCommentSchema } from './schema'; -import { RouteDeps } from '.'; -import { CASE_SAVED_OBJECT } from '../../constants'; - -export function initPostCommentApi({ caseService, router }: RouteDeps) { - router.post( - { - path: '/api/cases/{id}/comment', - validate: { - params: schema.object({ - id: schema.string(), - }), - body: NewCommentSchema, - }, - }, - async (context, request, response) => { - let createdBy; - let newComment; - try { - await caseService.getCase({ - client: context.core.savedObjects.client, - caseId: request.params.id, - }); - } catch (error) { - return response.customError(wrapError(error)); - } - try { - createdBy = await caseService.getUser({ request, response }); - } catch (error) { - return response.customError(wrapError(error)); - } - try { - newComment = await caseService.postNewComment({ - client: context.core.savedObjects.client, - attributes: formatNewComment({ - newComment: request.body, - ...createdBy, - }), - references: [ - { - type: CASE_SAVED_OBJECT, - name: `associated-${CASE_SAVED_OBJECT}`, - id: request.params.id, - }, - ], - }); - - return response.ok({ body: flattenCommentSavedObject(newComment) }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index 5f1c207bf9829..1252fd19cda02 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -3,74 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { IRouter } from 'src/core/server'; +import { CaseServiceSetup } from '../../services'; -import { TypeOf } from '@kbn/config-schema'; -import { - CommentSchema, - NewCaseSchema, - NewCommentSchema, - SavedObjectsFindOptionsSchema, - UpdatedCaseSchema, - UpdatedCommentSchema, - UserSchema, -} from './schema'; -import { SavedObjectAttributes } from '../../../../../../src/core/types'; - -export type NewCaseType = TypeOf<typeof NewCaseSchema>; -export type CommentAttributes = TypeOf<typeof CommentSchema> & SavedObjectAttributes; -export type NewCommentType = TypeOf<typeof NewCommentSchema>; -export type SavedObjectsFindOptionsType = TypeOf<typeof SavedObjectsFindOptionsSchema>; -export type UpdatedCaseTyped = TypeOf<typeof UpdatedCaseSchema>; -export type UpdatedCommentType = TypeOf<typeof UpdatedCommentSchema>; -export type UserType = TypeOf<typeof UserSchema>; - -export interface CaseAttributes extends NewCaseType, SavedObjectAttributes { - created_at: string; - created_by: UserType; - updated_at: string; -} - -export type FlattenedCaseSavedObject = CaseAttributes & { - case_id: string; - version: string; - comments: FlattenedCommentSavedObject[]; -}; - -export type FlattenedCasesSavedObject = Array< - CaseAttributes & { - case_id: string; - version: string; - // TO DO it is partial because we need to add it the commentCount - commentCount?: number; - } ->; - -export interface AllCases { - cases: FlattenedCasesSavedObject; - page: number; - per_page: number; - total: number; -} - -export type FlattenedCommentSavedObject = CommentAttributes & { - comment_id: string; - version: string; - // TO DO We might want to add the case_id where this comment is related too -}; - -export interface AllComments { - comments: FlattenedCommentSavedObject[]; - page: number; - per_page: number; - total: number; -} - -export interface UpdatedCaseType { - description?: UpdatedCaseTyped['description']; - state?: UpdatedCaseTyped['state']; - tags?: UpdatedCaseTyped['tags']; - title?: UpdatedCaseTyped['title']; - updated_at: string; +export interface RouteDeps { + caseService: CaseServiceSetup; + router: IRouter; } export enum SortFieldCase { @@ -78,7 +16,3 @@ export enum SortFieldCase { state = 'state', updatedAt = 'updated_at', } - -export type Writable<T> = { - -readonly [K in keyof T]: T[K]; -}; diff --git a/x-pack/plugins/case/server/routes/api/update_case.ts b/x-pack/plugins/case/server/routes/api/update_case.ts deleted file mode 100644 index 1c1a56dfe9b3a..0000000000000 --- a/x-pack/plugins/case/server/routes/api/update_case.ts +++ /dev/null @@ -1,94 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { SavedObject } from 'kibana/server'; -import Boom from 'boom'; -import { difference } from 'lodash'; -import { wrapError } from './utils'; -import { RouteDeps } from '.'; -import { UpdateCaseArguments } from './schema'; -import { CaseAttributes, UpdatedCaseTyped, Writable } from './types'; - -interface UpdateCase extends Writable<UpdatedCaseTyped> { - [key: string]: any; -} - -export function initUpdateCaseApi({ caseService, router }: RouteDeps) { - router.patch( - { - path: '/api/cases/{id}', - validate: { - params: schema.object({ - id: schema.string(), - }), - body: UpdateCaseArguments, - }, - }, - async (context, request, response) => { - let theCase: SavedObject<CaseAttributes>; - try { - theCase = await caseService.getCase({ - client: context.core.savedObjects.client, - caseId: request.params.id, - }); - } catch (error) { - return response.customError(wrapError(error)); - } - - if (request.body.version !== theCase.version) { - return response.customError( - wrapError( - Boom.conflict( - 'This case has been updated. Please refresh before saving additional updates.' - ) - ) - ); - } - const currentCase = theCase.attributes; - const updateCase: Partial<UpdateCase> = Object.entries(request.body.case).reduce( - (acc, [key, value]) => { - const currentValue = currentCase[key]; - if ( - Array.isArray(value) && - Array.isArray(currentValue) && - difference(value, currentValue).length !== 0 - ) { - return { - ...acc, - [key]: value, - }; - } else if (value !== currentCase[key]) { - return { - ...acc, - [key]: value, - }; - } - return acc; - }, - {} - ); - if (Object.keys(updateCase).length > 0) { - try { - const updatedCase = await caseService.updateCase({ - client: context.core.savedObjects.client, - caseId: request.params.id, - updatedAttributes: { - ...updateCase, - updated_at: new Date().toISOString(), - }, - }); - return response.ok({ body: { ...updatedCase.attributes, version: updatedCase.version } }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - return response.customError( - wrapError(Boom.notAcceptable('All update fields are identical to current version.')) - ); - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/update_comment.ts b/x-pack/plugins/case/server/routes/api/update_comment.ts deleted file mode 100644 index 9f99253f76629..0000000000000 --- a/x-pack/plugins/case/server/routes/api/update_comment.ts +++ /dev/null @@ -1,67 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { SavedObject } from 'kibana/server'; -import Boom from 'boom'; -import { wrapError } from './utils'; -import { UpdateCommentArguments } from './schema'; -import { RouteDeps } from '.'; -import { CommentAttributes } from './types'; - -export function initUpdateCommentApi({ caseService, router }: RouteDeps) { - router.patch( - { - path: '/api/cases/comment/{id}', - validate: { - params: schema.object({ - id: schema.string(), - }), - body: UpdateCommentArguments, - }, - }, - async (context, request, response) => { - let theComment: SavedObject<CommentAttributes>; - try { - theComment = await caseService.getComment({ - client: context.core.savedObjects.client, - commentId: request.params.id, - }); - } catch (error) { - return response.customError(wrapError(error)); - } - if (request.body.version !== theComment.version) { - return response.customError( - wrapError( - Boom.conflict( - 'This comment has been updated. Please refresh before saving additional updates.' - ) - ) - ); - } - if (request.body.comment === theComment.attributes.comment) { - return response.customError( - wrapError(Boom.notAcceptable('Comment is identical to current version.')) - ); - } - try { - const updatedComment = await caseService.updateComment({ - client: context.core.savedObjects.client, - commentId: request.params.id, - updatedAttributes: { - comment: request.body.comment, - updated_at: new Date().toISOString(), - }, - }); - return response.ok({ - body: { ...updatedComment.attributes, version: updatedComment.version }, - }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 32de41e1c01c5..920c53f404456 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { schema } from '@kbn/config-schema'; import { boomify, isBoom } from 'boom'; import { CustomHttpResponseOptions, @@ -12,42 +13,53 @@ import { SavedObjectsFindResponse, } from 'kibana/server'; import { - AllComments, + CaseRequest, + CaseResponse, + CasesResponse, CaseAttributes, + CommentResponse, + CommentsResponse, CommentAttributes, - FlattenedCaseSavedObject, - FlattenedCommentSavedObject, - AllCases, - NewCaseType, - NewCommentType, - SortFieldCase, - UserType, -} from './types'; +} from '../../../common/api'; -export const formatNewCase = ( - newCase: NewCaseType, - { full_name, username }: { full_name?: string; username: string } -): CaseAttributes => ({ - created_at: new Date().toISOString(), +import { SortFieldCase } from './types'; + +export const transformNewCase = ({ + createdDate, + newCase, + full_name, + username, +}: { + createdDate: string; + newCase: CaseRequest; + full_name?: string | null; + username: string | null; +}): CaseAttributes => ({ + comment_ids: [], + created_at: createdDate, created_by: { full_name, username }, - updated_at: new Date().toISOString(), + updated_at: null, + updated_by: null, ...newCase, }); interface NewCommentArgs { - newComment: NewCommentType; - full_name?: UserType['full_name']; - username: UserType['username']; + comment: string; + createdDate: string; + full_name?: string | null; + username: string | null; } -export const formatNewComment = ({ - newComment, +export const transformNewComment = ({ + comment, + createdDate, full_name, username, }: NewCommentArgs): CommentAttributes => ({ - ...newComment, - created_at: new Date().toISOString(), + comment, + created_at: createdDate, created_by: { full_name, username }, - updated_at: new Date().toISOString(), + updated_at: null, + updated_by: null, }); export function wrapError(error: any): CustomHttpResponseOptions<ResponseError> { @@ -59,7 +71,7 @@ export function wrapError(error: any): CustomHttpResponseOptions<ResponseError> }; } -export const formatAllCases = (cases: SavedObjectsFindResponse<CaseAttributes>): AllCases => ({ +export const transformCases = (cases: SavedObjectsFindResponse<CaseAttributes>): CasesResponse => ({ page: cases.page, per_page: cases.per_page, total: cases.total, @@ -68,27 +80,24 @@ export const formatAllCases = (cases: SavedObjectsFindResponse<CaseAttributes>): export const flattenCaseSavedObjects = ( savedObjects: SavedObjectsFindResponse<CaseAttributes>['saved_objects'] -): FlattenedCaseSavedObject[] => - savedObjects.reduce( - (acc: FlattenedCaseSavedObject[], savedObject: SavedObject<CaseAttributes>) => { - return [...acc, flattenCaseSavedObject(savedObject, [])]; - }, - [] - ); +): CaseResponse[] => + savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject<CaseAttributes>) => { + return [...acc, flattenCaseSavedObject(savedObject, [])]; + }, []); export const flattenCaseSavedObject = ( savedObject: SavedObject<CaseAttributes>, - comments: Array<SavedObject<CommentAttributes>> -): FlattenedCaseSavedObject => ({ - case_id: savedObject.id, - version: savedObject.version ? savedObject.version : '0', + comments: Array<SavedObject<CommentAttributes>> = [] +): CaseResponse => ({ + id: savedObject.id, + version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), ...savedObject.attributes, }); -export const formatAllComments = ( +export const transformComments = ( comments: SavedObjectsFindResponse<CommentAttributes> -): AllComments => ({ +): CommentsResponse => ({ page: comments.page, per_page: comments.per_page, total: comments.total, @@ -97,19 +106,16 @@ export const formatAllComments = ( export const flattenCommentSavedObjects = ( savedObjects: SavedObjectsFindResponse<CommentAttributes>['saved_objects'] -): FlattenedCommentSavedObject[] => - savedObjects.reduce( - (acc: FlattenedCommentSavedObject[], savedObject: SavedObject<CommentAttributes>) => { - return [...acc, flattenCommentSavedObject(savedObject)]; - }, - [] - ); +): CommentResponse[] => + savedObjects.reduce((acc: CommentResponse[], savedObject: SavedObject<CommentAttributes>) => { + return [...acc, flattenCommentSavedObject(savedObject)]; + }, []); export const flattenCommentSavedObject = ( savedObject: SavedObject<CommentAttributes> -): FlattenedCommentSavedObject => ({ - comment_id: savedObject.id, - version: savedObject.version ? savedObject.version : '0', +): CommentResponse => ({ + id: savedObject.id, + version: savedObject.version ?? '0', ...savedObject.attributes, }); @@ -127,3 +133,5 @@ export const sortToSnake = (sortField: string): SortFieldCase => { return SortFieldCase.createdAt; } }; + +export const escapeHatch = schema.object({}, { allowUnknowns: true }); diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts new file mode 100644 index 0000000000000..faed0a3100a42 --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsType } from 'src/core/server'; + +export const CASE_SAVED_OBJECT = 'cases'; + +export const caseSavedObjectType: SavedObjectsType = { + name: CASE_SAVED_OBJECT, + hidden: false, + namespaceAgnostic: false, + mappings: { + properties: { + comment_ids: { + type: 'keyword', + }, + created_at: { + type: 'date', + }, + created_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + description: { + type: 'text', + }, + title: { + type: 'keyword', + }, + state: { + type: 'keyword', + }, + tags: { + type: 'keyword', + }, + updated_at: { + type: 'date', + }, + updated_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts new file mode 100644 index 0000000000000..51c31421fec2f --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsType } from 'src/core/server'; + +export const CASE_COMMENT_SAVED_OBJECT = 'cases-comments'; + +export const caseCommentSavedObjectType: SavedObjectsType = { + name: CASE_COMMENT_SAVED_OBJECT, + hidden: false, + namespaceAgnostic: false, + mappings: { + properties: { + comment: { + type: 'text', + }, + created_at: { + type: 'date', + }, + created_by: { + properties: { + full_name: { + type: 'keyword', + }, + username: { + type: 'keyword', + }, + }, + }, + updated_at: { + type: 'date', + }, + updated_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/case/server/saved_object_types/index.ts b/x-pack/plugins/case/server/saved_object_types/index.ts new file mode 100644 index 0000000000000..1e29b9dd98ead --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { caseSavedObjectType, CASE_SAVED_OBJECT } from './cases'; +export { caseCommentSavedObjectType, CASE_COMMENT_SAVED_OBJECT } from './comments'; diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index e6416e268e30b..61b696d45d030 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -14,15 +14,10 @@ import { SavedObjectsUpdateResponse, SavedObjectReference, } from 'kibana/server'; -import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../constants'; -import { - CaseAttributes, - CommentAttributes, - SavedObjectsFindOptionsType, - UpdatedCaseType, - UpdatedCommentType, -} from '../routes/api/types'; + import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; +import { CaseAttributes, CommentAttributes, SavedObjectFindOptions } from '../../common/api'; +import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../saved_object_types'; import { readTags } from './tags/read_tags'; interface ClientArgs { @@ -33,8 +28,12 @@ interface GetCaseArgs extends ClientArgs { caseId: string; } +interface GetCommentsArgs extends GetCaseArgs { + options?: SavedObjectFindOptions; +} + interface GetCasesArgs extends ClientArgs { - options?: SavedObjectsFindOptionsType; + options?: SavedObjectFindOptions; } interface GetCommentArgs extends ClientArgs { commentId: string; @@ -47,13 +46,13 @@ interface PostCommentArgs extends ClientArgs { attributes: CommentAttributes; references: SavedObjectReference[]; } -interface UpdateCaseArgs extends ClientArgs { +interface PatchCaseArgs extends ClientArgs { caseId: string; - updatedAttributes: UpdatedCaseType; + updatedAttributes: Partial<CaseAttributes>; } interface UpdateCommentArgs extends ClientArgs { commentId: string; - updatedAttributes: UpdatedCommentType; + updatedAttributes: Partial<CommentAttributes>; } interface GetUserArgs { @@ -68,15 +67,15 @@ export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; getAllCases(args: GetCasesArgs): Promise<SavedObjectsFindResponse<CaseAttributes>>; - getAllCaseComments(args: GetCaseArgs): Promise<SavedObjectsFindResponse<CommentAttributes>>; + getAllCaseComments(args: GetCommentsArgs): Promise<SavedObjectsFindResponse<CommentAttributes>>; getCase(args: GetCaseArgs): Promise<SavedObject<CaseAttributes>>; getComment(args: GetCommentArgs): Promise<SavedObject<CommentAttributes>>; getTags(args: ClientArgs): Promise<string[]>; getUser(args: GetUserArgs): Promise<AuthenticatedUser>; postNewCase(args: PostCaseArgs): Promise<SavedObject<CaseAttributes>>; postNewComment(args: PostCommentArgs): Promise<SavedObject<CommentAttributes>>; - updateCase(args: UpdateCaseArgs): Promise<SavedObjectsUpdateResponse<CaseAttributes>>; - updateComment(args: UpdateCommentArgs): Promise<SavedObjectsUpdateResponse<CommentAttributes>>; + patchCase(args: PatchCaseArgs): Promise<SavedObjectsUpdateResponse<CaseAttributes>>; + patchComment(args: UpdateCommentArgs): Promise<SavedObjectsUpdateResponse<CommentAttributes>>; } export class CaseService { @@ -127,10 +126,11 @@ export class CaseService { throw error; } }, - getAllCaseComments: async ({ client, caseId }: GetCaseArgs) => { + getAllCaseComments: async ({ client, caseId, options }: GetCommentsArgs) => { try { this.log.debug(`Attempting to GET all comments for case ${caseId}`); return await client.find({ + ...options, type: CASE_COMMENT_SAVED_OBJECT, hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, }); @@ -175,7 +175,7 @@ export class CaseService { throw error; } }, - updateCase: async ({ client, caseId, updatedAttributes }: UpdateCaseArgs) => { + patchCase: async ({ client, caseId, updatedAttributes }: PatchCaseArgs) => { try { this.log.debug(`Attempting to UPDATE case ${caseId}`); return await client.update(CASE_SAVED_OBJECT, caseId, { ...updatedAttributes }); @@ -184,7 +184,7 @@ export class CaseService { throw error; } }, - updateComment: async ({ client, commentId, updatedAttributes }: UpdateCommentArgs) => { + patchComment: async ({ client, commentId, updatedAttributes }: UpdateCommentArgs) => { try { this.log.debug(`Attempting to UPDATE comment ${commentId}`); return await client.update(CASE_COMMENT_SAVED_OBJECT, commentId, { diff --git a/x-pack/plugins/case/server/services/tags/read_tags.ts b/x-pack/plugins/case/server/services/tags/read_tags.ts index da5905fe4ea35..ddb79507b5fef 100644 --- a/x-pack/plugins/case/server/services/tags/read_tags.ts +++ b/x-pack/plugins/case/server/services/tags/read_tags.ts @@ -5,8 +5,9 @@ */ import { SavedObject, SavedObjectsClientContract } from 'kibana/server'; -import { CASE_SAVED_OBJECT } from '../../constants'; -import { CaseAttributes } from '../..'; + +import { CaseAttributes } from '../../../common/api'; +import { CASE_SAVED_OBJECT } from '../../saved_object_types'; const DEFAULT_PER_PAGE: number = 1000; @@ -23,7 +24,7 @@ export const convertTagsToSet = (tagObjects: Array<SavedObject<CaseAttributes>>) return new Set(convertToTags(tagObjects)); }; -// Note: This is doing an in-memory aggregation of the tags by calling each of the alerting +// Note: This is doing an in-memory aggregation of the tags by calling each of the case // records in batches of this const setting and uses the fields to try to get the least // amount of data per record back. If saved objects at some point supports aggregations // then this should be replaced with a an aggregation call. diff --git a/x-pack/plugins/data_enhanced/common/index.ts b/x-pack/plugins/data_enhanced/common/index.ts new file mode 100644 index 0000000000000..0d5e353b0e83b --- /dev/null +++ b/x-pack/plugins/data_enhanced/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EnhancedSearchParams, IEnhancedEsSearchRequest } from './search'; diff --git a/x-pack/plugins/data_enhanced/common/search/index.ts b/x-pack/plugins/data_enhanced/common/search/index.ts new file mode 100644 index 0000000000000..3fe4fd029b940 --- /dev/null +++ b/x-pack/plugins/data_enhanced/common/search/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EnhancedSearchParams, IEnhancedEsSearchRequest } from './types'; diff --git a/x-pack/plugins/data_enhanced/common/search/types.ts b/x-pack/plugins/data_enhanced/common/search/types.ts new file mode 100644 index 0000000000000..59ce9f0b36f20 --- /dev/null +++ b/x-pack/plugins/data_enhanced/common/search/types.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchParams } from 'elasticsearch'; +import { IEsSearchRequest } from '../../../../../src/plugins/data/common'; + +export interface EnhancedSearchParams extends SearchParams { + ignoreThrottled: boolean; +} + +export interface IEnhancedEsSearchRequest extends IEsSearchRequest { + /** + * Used to determine whether to use the _rollups_search or a regular search endpoint. + */ + isRollup?: boolean; +} diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json index 4dbfe958eff68..b2d5f42d9e468 100644 --- a/x-pack/plugins/data_enhanced/kibana.json +++ b/x-pack/plugins/data_enhanced/kibana.json @@ -1,5 +1,5 @@ { - "id": "data_enhanced", + "id": "dataEnhanced", "version": "8.0.0", "kibanaVersion": "kibana", "configPath": [ @@ -8,6 +8,6 @@ "requiredPlugins": [ "data" ], - "server": false, + "server": true, "ui": true } diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 4fe27d400f45f..6316d87c50519 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -5,10 +5,18 @@ */ import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { + DataPublicPluginSetup, + DataPublicPluginStart, + ES_SEARCH_STRATEGY, +} from '../../../../src/plugins/data/public'; import { setAutocompleteService } from './services'; import { setupKqlQuerySuggestionProvider, KUERY_LANGUAGE_NAME } from './autocomplete'; -import { ASYNC_SEARCH_STRATEGY, asyncSearchStrategyProvider } from './search'; +import { + ASYNC_SEARCH_STRATEGY, + asyncSearchStrategyProvider, + enhancedEsSearchStrategyProvider, +} from './search'; export interface DataEnhancedSetupDependencies { data: DataPublicPluginSetup; @@ -29,6 +37,10 @@ export class DataEnhancedPlugin implements Plugin { setupKqlQuerySuggestionProvider(core) ); data.search.registerSearchStrategyProvider(ASYNC_SEARCH_STRATEGY, asyncSearchStrategyProvider); + data.search.registerSearchStrategyProvider( + ES_SEARCH_STRATEGY, + enhancedEsSearchStrategyProvider + ); } public start(core: CoreStart, plugins: DataEnhancedStartDependencies) { diff --git a/x-pack/plugins/data_enhanced/public/search/es_search_strategy.ts b/x-pack/plugins/data_enhanced/public/search/es_search_strategy.ts new file mode 100644 index 0000000000000..25c6a789cca93 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/es_search_strategy.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { ES_SEARCH_STRATEGY, IEsSearchResponse } from '../../../../../src/plugins/data/common'; +import { + TSearchStrategyProvider, + ISearchContext, + ISearch, + SYNC_SEARCH_STRATEGY, + getEsPreference, +} from '../../../../../src/plugins/data/public'; +import { IEnhancedEsSearchRequest, EnhancedSearchParams } from '../../common'; + +export const enhancedEsSearchStrategyProvider: TSearchStrategyProvider<typeof ES_SEARCH_STRATEGY> = ( + context: ISearchContext +) => { + const syncStrategyProvider = context.getSearchStrategy(SYNC_SEARCH_STRATEGY); + const { search: syncSearch } = syncStrategyProvider(context); + + const search: ISearch<typeof ES_SEARCH_STRATEGY> = ( + request: IEnhancedEsSearchRequest, + options + ) => { + const params: EnhancedSearchParams = { + ignoreThrottled: !context.core.uiSettings.get<boolean>('search:includeFrozen'), + preference: getEsPreference(context.core.uiSettings), + ...request.params, + }; + request.params = params; + + return syncSearch({ ...request, serverStrategy: ES_SEARCH_STRATEGY }, options) as Observable< + IEsSearchResponse + >; + }; + + return { search }; +}; diff --git a/x-pack/plugins/data_enhanced/public/search/index.ts b/x-pack/plugins/data_enhanced/public/search/index.ts index a7729aeea5647..e39c1b6a1dd61 100644 --- a/x-pack/plugins/data_enhanced/public/search/index.ts +++ b/x-pack/plugins/data_enhanced/public/search/index.ts @@ -5,4 +5,5 @@ */ export { ASYNC_SEARCH_STRATEGY, asyncSearchStrategyProvider } from './async_search_strategy'; +export { enhancedEsSearchStrategyProvider } from './es_search_strategy'; export { IAsyncSearchRequest, IAsyncSearchOptions } from './types'; diff --git a/x-pack/plugins/data_enhanced/server/index.ts b/x-pack/plugins/data_enhanced/server/index.ts new file mode 100644 index 0000000000000..fbe1ecc10d632 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'kibana/server'; +import { EnhancedDataServerPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new EnhancedDataServerPlugin(initializerContext); +} + +export { EnhancedDataServerPlugin as Plugin }; diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts new file mode 100644 index 0000000000000..a27a73431574b --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, +} from '../../../../src/core/server'; +import { ES_SEARCH_STRATEGY } from '../../../../src/plugins/data/common'; +import { PluginSetup as DataPluginSetup } from '../../../../src/plugins/data/server'; +import { enhancedEsSearchStrategyProvider } from './search'; + +interface SetupDependencies { + data: DataPluginSetup; +} + +export class EnhancedDataServerPlugin implements Plugin<void, void, SetupDependencies> { + constructor(private initializerContext: PluginInitializerContext) {} + + public setup(core: CoreSetup, deps: SetupDependencies) { + deps.data.search.registerSearchStrategyProvider( + this.initializerContext.opaqueId, + ES_SEARCH_STRATEGY, + enhancedEsSearchStrategyProvider + ); + } + + public start(core: CoreStart) {} + + public stop() {} +} + +export { EnhancedDataServerPlugin as Plugin }; diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts new file mode 100644 index 0000000000000..6e12ffb6404c6 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { first } from 'rxjs/operators'; +import { mapKeys, snakeCase } from 'lodash'; +import { SearchResponse } from 'elasticsearch'; +import { APICaller } from '../../../../../src/core/server'; +import { ES_SEARCH_STRATEGY } from '../../../../../src/plugins/data/common'; +import { + ISearchContext, + TSearchStrategyProvider, + ISearch, + ISearchOptions, + getDefaultSearchParams, +} from '../../../../../src/plugins/data/server'; +import { IEnhancedEsSearchRequest } from '../../common'; + +export const enhancedEsSearchStrategyProvider: TSearchStrategyProvider<typeof ES_SEARCH_STRATEGY> = ( + context: ISearchContext, + caller: APICaller +) => { + const search: ISearch<typeof ES_SEARCH_STRATEGY> = async ( + request: IEnhancedEsSearchRequest, + options + ) => { + const config = await context.config$.pipe(first()).toPromise(); + const defaultParams = getDefaultSearchParams(config); + const params = { ...defaultParams, ...request.params }; + + const rawResponse = (await (request.isRollup + ? rollupSearch(caller, { ...request, params }, options) + : caller('search', params, options))) as SearchResponse<any>; + + const { total, failed, successful } = rawResponse._shards; + const loaded = failed + successful; + return { total, loaded, rawResponse }; + }; + + return { search }; +}; + +function rollupSearch( + caller: APICaller, + request: IEnhancedEsSearchRequest, + options?: ISearchOptions +) { + const method = 'POST'; + const path = `${request.params.index}/_rollup_search`; + const { body, ...params } = request.params; + const query = toSnakeCase(params); + return caller('transport.request', { method, path, body, query }, options); +} + +function toSnakeCase(obj: Record<string, any>) { + return mapKeys(obj, (value, key) => snakeCase(key)); +} diff --git a/x-pack/plugins/data_enhanced/server/search/index.ts b/x-pack/plugins/data_enhanced/server/search/index.ts new file mode 100644 index 0000000000000..f914326f30d32 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/search/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { enhancedEsSearchStrategyProvider } from './es_search_strategy'; diff --git a/x-pack/plugins/infra/kibana.json b/x-pack/plugins/infra/kibana.json index f12bbd6cf7723..bb40d65d311e8 100644 --- a/x-pack/plugins/infra/kibana.json +++ b/x-pack/plugins/infra/kibana.json @@ -9,7 +9,7 @@ "spaces", "home", "data", - "data_enhanced", + "dataEnhanced", "metrics", "alerting" ], diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index 9c1a1bb5962e4..566cc7ec09336 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -5,7 +5,7 @@ */ // import { darken, transparentize } from 'polished'; -import React, { useState, useCallback, useMemo } from 'react'; +import React, { memo, useState, useCallback, useMemo } from 'react'; import { euiStyled } from '../../../../../observability/public'; import { @@ -41,155 +41,157 @@ interface LogEntryRowProps { wrap: boolean; } -export const LogEntryRow = ({ - boundingBoxRef, - columnConfigurations, - columnWidths, - highlights, - isActiveHighlight, - isHighlighted, - logEntry, - openFlyoutWithItem, - scale, - wrap, -}: LogEntryRowProps) => { - const [isHovered, setIsHovered] = useState(false); - - const setItemIsHovered = useCallback(() => { - setIsHovered(true); - }, []); - - const setItemIsNotHovered = useCallback(() => { - setIsHovered(false); - }, []); - - const openFlyout = useCallback(() => openFlyoutWithItem(logEntry.gid), [ +export const LogEntryRow = memo( + ({ + boundingBoxRef, + columnConfigurations, + columnWidths, + highlights, + isActiveHighlight, + isHighlighted, + logEntry, openFlyoutWithItem, - logEntry.gid, - ]); - - const logEntryColumnsById = useMemo( - () => - logEntry.columns.reduce<{ - [columnId: string]: LogEntry['columns'][0]; - }>( - (columnsById, column) => ({ - ...columnsById, - [column.columnId]: column, - }), - {} - ), - [logEntry.columns] - ); - - const highlightsByColumnId = useMemo( - () => - highlights.reduce<{ - [columnId: string]: LogEntryHighlightColumn[]; - }>( - (columnsById, highlight) => - highlight.columns.reduce( - (innerColumnsById, column) => ({ - ...innerColumnsById, - [column.columnId]: [...(innerColumnsById[column.columnId] || []), column], - }), - columnsById - ), - {} - ), - [highlights] - ); - - return ( - <LogEntryRowWrapper - data-test-subj="streamEntry logTextStreamEntry" - ref={ - /* Workaround for missing RefObject support in styled-components */ - boundingBoxRef as any - } - onMouseEnter={setItemIsHovered} - onMouseLeave={setItemIsNotHovered} - scale={scale} - > - {columnConfigurations.map(columnConfiguration => { - if (isTimestampLogColumnConfiguration(columnConfiguration)) { - const column = logEntryColumnsById[columnConfiguration.timestampColumn.id]; - const columnWidth = columnWidths[columnConfiguration.timestampColumn.id]; - - return ( - <LogEntryColumn - data-test-subj="logColumn timestampLogColumn" - key={columnConfiguration.timestampColumn.id} - {...columnWidth} - > - {isTimestampColumn(column) ? ( - <LogEntryTimestampColumn - isHighlighted={isHighlighted} - isHovered={isHovered} - time={column.timestamp} - /> - ) : null} - </LogEntryColumn> - ); - } else if (isMessageLogColumnConfiguration(columnConfiguration)) { - const column = logEntryColumnsById[columnConfiguration.messageColumn.id]; - const columnWidth = columnWidths[columnConfiguration.messageColumn.id]; - - return ( - <LogEntryColumn - data-test-subj="logColumn messageLogColumn" - key={columnConfiguration.messageColumn.id} - {...columnWidth} - > - {column ? ( - <LogEntryMessageColumn - columnValue={column} - highlights={highlightsByColumnId[column.columnId] || []} - isHighlighted={isHighlighted} - isActiveHighlight={isActiveHighlight} - isHovered={isHovered} - isWrapped={wrap} - /> - ) : null} - </LogEntryColumn> - ); - } else if (isFieldLogColumnConfiguration(columnConfiguration)) { - const column = logEntryColumnsById[columnConfiguration.fieldColumn.id]; - const columnWidth = columnWidths[columnConfiguration.fieldColumn.id]; - - return ( - <LogEntryColumn - data-test-subj={`logColumn fieldLogColumn fieldLogColumn:${columnConfiguration.fieldColumn.field}`} - key={columnConfiguration.fieldColumn.id} - {...columnWidth} - > - {column ? ( - <LogEntryFieldColumn - columnValue={column} - highlights={highlightsByColumnId[column.columnId] || []} - isActiveHighlight={isActiveHighlight} - isHighlighted={isHighlighted} - isHovered={isHovered} - isWrapped={wrap} - /> - ) : null} - </LogEntryColumn> - ); + scale, + wrap, + }: LogEntryRowProps) => { + const [isHovered, setIsHovered] = useState(false); + + const setItemIsHovered = useCallback(() => { + setIsHovered(true); + }, []); + + const setItemIsNotHovered = useCallback(() => { + setIsHovered(false); + }, []); + + const openFlyout = useCallback(() => openFlyoutWithItem(logEntry.gid), [ + openFlyoutWithItem, + logEntry.gid, + ]); + + const logEntryColumnsById = useMemo( + () => + logEntry.columns.reduce<{ + [columnId: string]: LogEntry['columns'][0]; + }>( + (columnsById, column) => ({ + ...columnsById, + [column.columnId]: column, + }), + {} + ), + [logEntry.columns] + ); + + const highlightsByColumnId = useMemo( + () => + highlights.reduce<{ + [columnId: string]: LogEntryHighlightColumn[]; + }>( + (columnsById, highlight) => + highlight.columns.reduce( + (innerColumnsById, column) => ({ + ...innerColumnsById, + [column.columnId]: [...(innerColumnsById[column.columnId] || []), column], + }), + columnsById + ), + {} + ), + [highlights] + ); + + return ( + <LogEntryRowWrapper + data-test-subj="streamEntry logTextStreamEntry" + ref={ + /* Workaround for missing RefObject support in styled-components */ + boundingBoxRef as any } - })} - <LogEntryColumn - key="logColumn iconLogColumn iconLogColumn:details" - {...columnWidths[iconColumnId]} + onMouseEnter={setItemIsHovered} + onMouseLeave={setItemIsNotHovered} + scale={scale} > - <LogEntryDetailsIconColumn - isHighlighted={isHighlighted} - isHovered={isHovered} - openFlyout={openFlyout} - /> - </LogEntryColumn> - </LogEntryRowWrapper> - ); -}; + {columnConfigurations.map(columnConfiguration => { + if (isTimestampLogColumnConfiguration(columnConfiguration)) { + const column = logEntryColumnsById[columnConfiguration.timestampColumn.id]; + const columnWidth = columnWidths[columnConfiguration.timestampColumn.id]; + + return ( + <LogEntryColumn + data-test-subj="logColumn timestampLogColumn" + key={columnConfiguration.timestampColumn.id} + {...columnWidth} + > + {isTimestampColumn(column) ? ( + <LogEntryTimestampColumn + isHighlighted={isHighlighted} + isHovered={isHovered} + time={column.timestamp} + /> + ) : null} + </LogEntryColumn> + ); + } else if (isMessageLogColumnConfiguration(columnConfiguration)) { + const column = logEntryColumnsById[columnConfiguration.messageColumn.id]; + const columnWidth = columnWidths[columnConfiguration.messageColumn.id]; + + return ( + <LogEntryColumn + data-test-subj="logColumn messageLogColumn" + key={columnConfiguration.messageColumn.id} + {...columnWidth} + > + {column ? ( + <LogEntryMessageColumn + columnValue={column} + highlights={highlightsByColumnId[column.columnId] || []} + isHighlighted={isHighlighted} + isActiveHighlight={isActiveHighlight} + isHovered={isHovered} + isWrapped={wrap} + /> + ) : null} + </LogEntryColumn> + ); + } else if (isFieldLogColumnConfiguration(columnConfiguration)) { + const column = logEntryColumnsById[columnConfiguration.fieldColumn.id]; + const columnWidth = columnWidths[columnConfiguration.fieldColumn.id]; + + return ( + <LogEntryColumn + data-test-subj={`logColumn fieldLogColumn fieldLogColumn:${columnConfiguration.fieldColumn.field}`} + key={columnConfiguration.fieldColumn.id} + {...columnWidth} + > + {column ? ( + <LogEntryFieldColumn + columnValue={column} + highlights={highlightsByColumnId[column.columnId] || []} + isActiveHighlight={isActiveHighlight} + isHighlighted={isHighlighted} + isHovered={isHovered} + isWrapped={wrap} + /> + ) : null} + </LogEntryColumn> + ); + } + })} + <LogEntryColumn + key="logColumn iconLogColumn iconLogColumn:details" + {...columnWidths[iconColumnId]} + > + <LogEntryDetailsIconColumn + isHighlighted={isHighlighted} + isHovered={isHovered} + openFlyout={openFlyout} + /> + </LogEntryColumn> + </LogEntryRowWrapper> + ); + } +); interface LogEntryRowWrapperProps { scale: TextScale; diff --git a/x-pack/plugins/infra/public/components/source_configuration/add_log_column_popover.tsx b/x-pack/plugins/infra/public/components/source_configuration/add_log_column_popover.tsx index 0835a904585ed..3c96d505dce4d 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/add_log_column_popover.tsx +++ b/x-pack/plugins/infra/public/components/source_configuration/add_log_column_popover.tsx @@ -5,7 +5,7 @@ */ import { EuiBadge, EuiButton, EuiPopover, EuiPopoverTitle, EuiSelectable } from '@elastic/eui'; -import { Option } from '@elastic/eui/src/components/selectable/types'; +import { EuiSelectableOption } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback, useMemo } from 'react'; import { v4 as uuidv4 } from 'uuid'; @@ -15,7 +15,7 @@ import { useVisibilityState } from '../../utils/use_visibility_state'; import { euiStyled } from '../../../../observability/public'; interface SelectableColumnOption { - optionProps: Option; + optionProps: EuiSelectableOption; columnConfiguration: LogColumnConfiguration; } @@ -78,13 +78,13 @@ export const AddLogColumnButtonAndPopover: React.FunctionComponent<{ [availableFields] ); - const availableOptions = useMemo<Option[]>( + const availableOptions = useMemo<EuiSelectableOption[]>( () => availableColumnOptions.map(availableColumnOption => availableColumnOption.optionProps), [availableColumnOptions] ); const handleColumnSelection = useCallback( - (selectedOptions: Option[]) => { + (selectedOptions: EuiSelectableOption[]) => { closePopover(); const selectedOptionIndex = selectedOptions.findIndex( diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx index 9c22caa4b3465..c2087e9032f59 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo } from 'react'; import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; -type DatasetOptionProps = EuiComboBoxOptionProps<string>; +type DatasetOptionProps = EuiComboBoxOptionOption<string>; export const DatasetsSelector: React.FunctionComponent<{ availableDatasets: string[]; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index e944af6821c0b..3bdf859731438 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -3,7 +3,8 @@ "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["ml"], - "requiredPlugins": ["cloud", "features", "home", "licensing", "security", "spaces", "usageCollection"], + "requiredPlugins": ["cloud", "features", "home", "licensing", "usageCollection"], + "optionalPlugins": ["security", "spaces"], "server": true, "ui": false } diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index a3f5733738432..547d3f8ab06cb 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -117,7 +117,7 @@ export class MlServerPlugin { resultsServiceRoutes(routeInit); jobValidationRoutes(routeInit, this.version); systemRoutes(routeInit, { - spacesPlugin: plugins.spaces, + spaces: plugins.spaces, cloud: plugins.cloud, }); initMlServerLog({ log: this.log }); diff --git a/x-pack/plugins/ml/server/routes/annotations.ts b/x-pack/plugins/ml/server/routes/annotations.ts index 16483bf8b887e..c481fb8698855 100644 --- a/x-pack/plugins/ml/server/routes/annotations.ts +++ b/x-pack/plugins/ml/server/routes/annotations.ts @@ -36,7 +36,7 @@ function getAnnotationsFeatureUnavailableErrorMessage() { */ export function annotationRoutes( { router, mlLicense }: RouteInitialization, - securityPlugin: SecurityPluginSetup + securityPlugin?: SecurityPluginSetup ) { /** * @apiGroup Annotations @@ -101,9 +101,12 @@ export function annotationRoutes( } const { indexAnnotation } = annotationServiceProvider(context); - const user = securityPlugin.authc.getCurrentUser(request) || {}; + + const currentUser = + securityPlugin !== undefined ? securityPlugin.authc.getCurrentUser(request) : {}; // @ts-ignore username doesn't exist on {} - const resp = await indexAnnotation(request.body, user.username || ANNOTATION_USER_UNKNOWN); + const username = currentUser?.username ?? ANNOTATION_USER_UNKNOWN; + const resp = await indexAnnotation(request.body, username); return response.ok({ body: resp, diff --git a/x-pack/plugins/ml/server/routes/system.ts b/x-pack/plugins/ml/server/routes/system.ts index a0d7d312c04d4..2a0a760e94f79 100644 --- a/x-pack/plugins/ml/server/routes/system.ts +++ b/x-pack/plugins/ml/server/routes/system.ts @@ -19,7 +19,7 @@ import { RouteInitialization, SystemRouteDeps } from '../types'; */ export function systemRoutes( { router, mlLicense }: RouteInitialization, - { spacesPlugin, cloud }: SystemRouteDeps + { spaces, cloud }: SystemRouteDeps ) { async function getNodeCount(context: RequestHandlerContext) { const filterPath = 'nodes.*.attributes'; @@ -120,8 +120,8 @@ export function systemRoutes( const ignoreSpaces = request.query && request.query.ignoreSpaces === 'true'; // if spaces is disabled force isMlEnabledInSpace to be true const { isMlEnabledInSpace } = - spacesPlugin !== undefined - ? spacesUtilsProvider(spacesPlugin, (request as unknown) as Request) + spaces !== undefined + ? spacesUtilsProvider(spaces, (request as unknown) as Request) : { isMlEnabledInSpace: async () => true }; const { getPrivileges } = privilegesProvider( diff --git a/x-pack/plugins/ml/server/types.ts b/x-pack/plugins/ml/server/types.ts index aeb4c505ec55e..def8a1e5fa649 100644 --- a/x-pack/plugins/ml/server/types.ts +++ b/x-pack/plugins/ml/server/types.ts @@ -25,7 +25,7 @@ export interface LicenseCheckResult { export interface SystemRouteDeps { cloud: CloudSetup; - spacesPlugin: SpacesPluginSetup; + spaces?: SpacesPluginSetup; } export interface PluginsSetup { @@ -33,8 +33,8 @@ export interface PluginsSetup { features: FeaturesPluginSetup; home: HomeServerPluginSetup; licensing: LicensingPluginSetup; - security: SecurityPluginSetup; - spaces: SpacesPluginSetup; + security?: SecurityPluginSetup; + spaces?: SpacesPluginSetup; usageCollection: UsageCollectionSetup; } diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap index 45751997eb0d5..590ea27617adf 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap @@ -165,6 +165,7 @@ Array [ style="font-size:14px;display:inline-block" > <input + aria-controls="" data-test-subj="comboBoxSearchInput" id="mockId" role="textbox" @@ -448,7 +449,7 @@ Array [ aria-describedby="mockId-help mockId-error-0" aria-expanded="false" aria-haspopup="listbox" - class="euiComboBox euiComboBox-isInvalid euiComboBox--fullWidth" + class="euiComboBox euiComboBox--fullWidth euiComboBox-isInvalid" data-test-subj="remoteClusterFormSeedsInput" role="combobox" > @@ -473,6 +474,7 @@ Array [ style="font-size: 14px; display: inline-block;" > <input + aria-controls="" data-test-subj="comboBoxSearchInput" id="mockId" role="textbox" diff --git a/x-pack/plugins/security/public/management/role_combo_box/role_combo_box_option.tsx b/x-pack/plugins/security/public/management/role_combo_box/role_combo_box_option.tsx index 126a3151adf01..ae9b79c796275 100644 --- a/x-pack/plugins/security/public/management/role_combo_box/role_combo_box_option.tsx +++ b/x-pack/plugins/security/public/management/role_combo_box/role_combo_box_option.tsx @@ -8,10 +8,10 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiComboBoxOptionProps, EuiText } from '@elastic/eui'; +import { EuiComboBoxOptionOption, EuiText } from '@elastic/eui'; interface Props { - option: EuiComboBoxOptionProps<{ isDeprecated: boolean }>; + option: EuiComboBoxOptionOption<{ isDeprecated: boolean }>; } export const RoleComboBoxOption = ({ option }: Props) => { diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/json_rule_editor.test.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/json_rule_editor.test.tsx index 43f6c50ea1172..c5b3ea433adaa 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/json_rule_editor.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/json_rule_editor.test.tsx @@ -55,7 +55,7 @@ describe('JSONRuleEditor', () => { const wrapper = mountWithIntl(<JSONRuleEditor {...props} />); const { value } = wrapper.find(EuiCodeEditor).props(); - expect(JSON.parse(value)).toEqual({ + expect(JSON.parse(value as string)).toEqual({ all: [ { any: [{ field: { username: '*' } }], @@ -90,10 +90,7 @@ describe('JSONRuleEditor', () => { const allRule = JSON.stringify(new AllRule().toRaw()); act(() => { - wrapper - .find(EuiCodeEditor) - .props() - .onChange(allRule + ', this makes invalid JSON'); + wrapper.find(EuiCodeEditor).props().onChange!(allRule + ', this makes invalid JSON'); }); expect(props.onValidityChange).toHaveBeenCalledTimes(1); @@ -121,10 +118,7 @@ describe('JSONRuleEditor', () => { }); act(() => { - wrapper - .find(EuiCodeEditor) - .props() - .onChange(invalidRule); + wrapper.find(EuiCodeEditor).props().onChange!(invalidRule); }); expect(props.onValidityChange).toHaveBeenCalledTimes(1); @@ -143,10 +137,7 @@ describe('JSONRuleEditor', () => { const allRule = JSON.stringify(new AllRule().toRaw()); act(() => { - wrapper - .find(EuiCodeEditor) - .props() - .onChange(allRule + ', this makes invalid JSON'); + wrapper.find(EuiCodeEditor).props().onChange!(allRule + ', this makes invalid JSON'); }); expect(props.onValidityChange).toHaveBeenCalledTimes(1); @@ -156,10 +147,7 @@ describe('JSONRuleEditor', () => { props.onValidityChange.mockReset(); act(() => { - wrapper - .find(EuiCodeEditor) - .props() - .onChange(allRule); + wrapper.find(EuiCodeEditor).props().onChange!(allRule); }); expect(props.onValidityChange).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/cluster_privileges.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/cluster_privileges.test.tsx.snap index b38b7e6634ada..a52438ca93638 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/cluster_privileges.test.tsx.snap +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/cluster_privileges.test.tsx.snap @@ -6,6 +6,7 @@ exports[`it renders without crashing 1`] = ` key="clusterPrivs" > <EuiComboBox + async={false} compressed={false} data-test-subj="cluster-privileges-combobox" fullWidth={false} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap index 2a00c7ca5c347..b306de5b84093 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap @@ -108,6 +108,7 @@ exports[`it renders without crashing 1`] = ` labelType="label" > <EuiComboBox + async={false} compressed={false} fullWidth={false} isClearable={true} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/index_privilege_form.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/index_privilege_form.test.tsx.snap index d99d2e592f585..bbf90d0f64bd2 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/index_privilege_form.test.tsx.snap +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/index_privilege_form.test.tsx.snap @@ -26,6 +26,7 @@ exports[`it renders without crashing 1`] = ` labelType="label" > <EuiComboBox + async={false} compressed={false} data-test-subj="indicesInput0" fullWidth={false} @@ -56,6 +57,7 @@ exports[`it renders without crashing 1`] = ` labelType="label" > <EuiComboBox + async={false} compressed={false} data-test-subj="privilegesInput0" fullWidth={false} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.tsx index b5d0a2c91d1be..32e8a558ecbc6 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.tsx @@ -6,7 +6,7 @@ import { EuiButtonIcon, EuiComboBox, - EuiComboBoxOptionProps, + EuiComboBoxOptionOption, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -378,14 +378,14 @@ export class IndexPrivilegeForm extends Component<Props, State> { }); }; - private onIndexPatternsChange = (newPatterns: EuiComboBoxOptionProps[]) => { + private onIndexPatternsChange = (newPatterns: EuiComboBoxOptionOption[]) => { this.props.onChange({ ...this.props.indexPrivilege, names: newPatterns.map(fromOption), }); }; - private onPrivilegeChange = (newPrivileges: EuiComboBoxOptionProps[]) => { + private onPrivilegeChange = (newPrivileges: EuiComboBoxOptionOption[]) => { this.props.onChange({ ...this.props.indexPrivilege, privileges: newPrivileges.map(fromOption), @@ -418,7 +418,7 @@ export class IndexPrivilegeForm extends Component<Props, State> { }); }; - private onGrantedFieldsChange = (grantedFields: EuiComboBoxOptionProps[]) => { + private onGrantedFieldsChange = (grantedFields: EuiComboBoxOptionOption[]) => { this.props.onChange({ ...this.props.indexPrivilege, field_security: { @@ -447,7 +447,7 @@ export class IndexPrivilegeForm extends Component<Props, State> { }); }; - private onDeniedFieldsChange = (deniedFields: EuiComboBoxOptionProps[]) => { + private onDeniedFieldsChange = (deniedFields: EuiComboBoxOptionOption[]) => { this.props.onChange({ ...this.props.indexPrivilege, field_security: { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx index 3e5ea9f146876..1e42a926c51f7 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiComboBox, EuiComboBoxOptionProps, EuiHealth, EuiHighlight } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiHealth, EuiHighlight } from '@elastic/eui'; import { InjectedIntl } from '@kbn/i18n/react'; import React, { Component } from 'react'; import { Space, getSpaceColor } from '../../../../../../../../spaces/public'; @@ -65,7 +65,7 @@ export class SpaceSelector extends Component<Props, {}> { ); } - private onChange = (selectedSpaces: EuiComboBoxOptionProps[]) => { + private onChange = (selectedSpaces: EuiComboBoxOptionOption[]) => { this.props.onChange(selectedSpaces.map(s => (s.id as string).split('spaceOption_')[1])); }; @@ -81,12 +81,12 @@ export class SpaceSelector extends Component<Props, {}> { ) ); - return options.filter(Boolean) as EuiComboBoxOptionProps[]; + return options.filter(Boolean) as EuiComboBoxOptionOption[]; }; private getSelectedOptions = () => { const options = this.props.selectedSpaceIds.map(spaceIdToOption(this.props.spaces)); - return options.filter(Boolean) as EuiComboBoxOptionProps[]; + return options.filter(Boolean) as EuiComboBoxOptionOption[]; }; } diff --git a/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_settings.tsx b/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_settings.tsx index 45eea10a28311..fc743767e9f70 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_settings.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_settings.tsx @@ -20,7 +20,7 @@ import { EuiComboBox, EuiToolTip, } from '@elastic/eui'; -import { Option } from '@elastic/eui/src/components/selectable/types'; +import { EuiSelectableOption } from '@elastic/eui'; import { SlmPolicyPayload, SnapshotConfig } from '../../../../../common/types'; import { documentationLinksService } from '../../../services/documentation'; import { useServices } from '../../../app_context'; @@ -45,9 +45,9 @@ export const PolicyStepSettings: React.FunctionComponent<StepProps> = ({ // States for choosing all indices, or a subset, including caching previously chosen subset list const [isAllIndices, setIsAllIndices] = useState<boolean>(!Boolean(config.indices)); const [indicesSelection, setIndicesSelection] = useState<SnapshotConfig['indices']>([...indices]); - const [indicesOptions, setIndicesOptions] = useState<Option[]>( + const [indicesOptions, setIndicesOptions] = useState<EuiSelectableOption[]>( indices.map( - (index): Option => ({ + (index): EuiSelectableOption => ({ label: index, checked: isAllIndices || @@ -210,7 +210,7 @@ export const PolicyStepSettings: React.FunctionComponent<StepProps> = ({ data-test-subj="deselectIndicesLink" onClick={() => { // TODO: Change this to setIndicesOptions() when https://github.com/elastic/eui/issues/2071 is fixed - indicesOptions.forEach((option: Option) => { + indicesOptions.forEach((option: EuiSelectableOption) => { option.checked = undefined; }); updatePolicyConfig({ indices: [] }); @@ -226,7 +226,7 @@ export const PolicyStepSettings: React.FunctionComponent<StepProps> = ({ <EuiLink onClick={() => { // TODO: Change this to setIndicesOptions() when https://github.com/elastic/eui/issues/2071 is fixed - indicesOptions.forEach((option: Option) => { + indicesOptions.forEach((option: EuiSelectableOption) => { option.checked = 'on'; }); updatePolicyConfig({ indices: [...indices] }); diff --git a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx index c504cccf0ac4b..6d936f41206cc 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx @@ -5,6 +5,7 @@ */ import React, { Fragment, useState } from 'react'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCode, @@ -391,15 +392,13 @@ export const HDFSSettings: React.FunctionComponent<Props> = ({ }} showGutter={false} minLines={6} - aria-label={ - <FormattedMessage - id="xpack.snapshotRestore.repositoryForm.typeHDFS.configurationAriaLabel" - defaultMessage="Additional configuration for HDFS repository '{name}'" - values={{ - name, - }} - /> - } + aria-label={i18n.translate( + 'xpack.snapshotRestore.repositoryForm.typeHDFS.configurationAriaLabel', + { + defaultMessage: `Additional configuration for HDFS repository '{name}'`, + values: { name }, + } + )} onChange={(value: string) => { setAdditionalConf(value); try { diff --git a/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_logistics.tsx b/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_logistics.tsx index 6780ab4bc664e..0896b283a6762 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_logistics.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_logistics.tsx @@ -20,7 +20,7 @@ import { EuiTitle, EuiComboBox, } from '@elastic/eui'; -import { Option } from '@elastic/eui/src/components/selectable/types'; +import { EuiSelectableOption } from '@elastic/eui'; import { RestoreSettings } from '../../../../../common/types'; import { documentationLinksService } from '../../../services/documentation'; import { useServices } from '../../../app_context'; @@ -48,9 +48,9 @@ export const RestoreSnapshotStepLogistics: React.FunctionComponent<StepProps> = // States for choosing all indices, or a subset, including caching previously chosen subset list const [isAllIndices, setIsAllIndices] = useState<boolean>(!Boolean(restoreIndices)); - const [indicesOptions, setIndicesOptions] = useState<Option[]>( + const [indicesOptions, setIndicesOptions] = useState<EuiSelectableOption[]>( snapshotIndices.map( - (index): Option => ({ + (index): EuiSelectableOption => ({ label: index, checked: isAllIndices || @@ -230,7 +230,7 @@ export const RestoreSnapshotStepLogistics: React.FunctionComponent<StepProps> = <EuiLink onClick={() => { // TODO: Change this to setIndicesOptions() when https://github.com/elastic/eui/issues/2071 is fixed - indicesOptions.forEach((option: Option) => { + indicesOptions.forEach((option: EuiSelectableOption) => { option.checked = undefined; }); updateRestoreSettings({ indices: [] }); @@ -249,7 +249,7 @@ export const RestoreSnapshotStepLogistics: React.FunctionComponent<StepProps> = <EuiLink onClick={() => { // TODO: Change this to setIndicesOptions() when https://github.com/elastic/eui/issues/2071 is fixed - indicesOptions.forEach((option: Option) => { + indicesOptions.forEach((option: EuiSelectableOption) => { option.checked = 'on'; }); updateRestoreSettings({ indices: [...snapshotIndices] }); diff --git a/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_review.tsx b/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_review.tsx index 3f7daea361f7f..52d162d0963f3 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_review.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_review.tsx @@ -282,12 +282,10 @@ export const RestoreSnapshotStepReview: React.FunctionComponent<StepProps> = ({ setOptions={{ maxLines: Infinity }} value={JSON.stringify(serializedRestoreSettings, null, 2)} editorProps={{ $blockScrolling: Infinity }} - aria-label={ - <FormattedMessage - id="xpack.snapshotRestore.restoreForm.stepReview.jsonTab.jsonAriaLabel" - defaultMessage="Restore settings to be executed" - /> - } + aria-label={i18n.translate( + 'xpack.snapshotRestore.restoreForm.stepReview.jsonTab.jsonAriaLabel', + { defaultMessage: 'Restore settings to be executed' } + )} /> </Fragment> ); diff --git a/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_settings.tsx b/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_settings.tsx index fd29fc3105f90..d9a5a06d862d6 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_settings.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_settings.tsx @@ -183,12 +183,10 @@ export const RestoreSnapshotStepSettings: React.FunctionComponent<StepProps> = ( showGutter={false} minLines={6} maxLines={15} - aria-label={ - <FormattedMessage - id="xpack.snapshotRestore.restoreForm.stepSettings.indexSettingsAriaLabel" - defaultMessage="Index settings to modify" - /> - } + aria-label={i18n.translate( + 'xpack.snapshotRestore.restoreForm.stepSettings.indexSettingsAriaLabel', + { defaultMessage: 'Index settings to modify' } + )} onChange={(value: string) => { updateRestoreSettings({ indexSettings: value, diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/tabs/tab_history.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/tabs/tab_history.tsx index 708042359d088..22c37241348e7 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/tabs/tab_history.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/tabs/tab_history.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCodeEditor, @@ -155,15 +156,13 @@ export const TabHistory: React.FunctionComponent<Props> = ({ policy }) => { maxLines={12} wrapEnabled={true} showGutter={false} - aria-label={ - <FormattedMessage - id="xpack.snapshotRestore.policyDetails.lastFailure.detailsAriaLabel" - defaultMessage="Last failure details for policy '{name}'" - values={{ - name, - }} - /> - } + aria-label={i18n.translate( + 'xpack.snapshotRestore.policyDetails.lastFailure.detailsAriaLabel', + { + defaultMessage: `Last failure details for policy '{name}'`, + values: { name }, + } + )} /> </EuiDescriptionListDescription> </EuiFlexItem> diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_details/type_details/default_details.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_details/type_details/default_details.tsx index 6b99628863e77..80bf9fdee24e1 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_details/type_details/default_details.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_details/type_details/default_details.tsx @@ -6,6 +6,7 @@ import 'brace/theme/textmate'; import React, { Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCodeEditor, EuiSpacer, EuiTitle } from '@elastic/eui'; @@ -47,15 +48,15 @@ export const DefaultDetails: React.FunctionComponent<Props> = ({ }} showGutter={false} minLines={6} - aria-label={ - <FormattedMessage - id="xpack.snapshotRestore.repositoryDetails.genericSettingsDescription" - defaultMessage="Readonly settings for repository '{name}'" - values={{ + aria-label={i18n.translate( + 'xpack.snapshotRestore.repositoryDetails.genericSettingsDescription', + { + defaultMessage: `Readonly settings for repository '{name}'`, + values: { name, - }} - /> - } + }, + } + )} /> </Fragment> ); diff --git a/x-pack/plugins/spaces/public/management/edit_space/customize_space/__snapshots__/customize_space_avatar.test.tsx.snap b/x-pack/plugins/spaces/public/management/edit_space/customize_space/__snapshots__/customize_space_avatar.test.tsx.snap index 562641d8fca51..269b2b6908183 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/customize_space/__snapshots__/customize_space_avatar.test.tsx.snap +++ b/x-pack/plugins/spaces/public/management/edit_space/customize_space/__snapshots__/customize_space_avatar.test.tsx.snap @@ -53,14 +53,7 @@ exports[`renders without crashing 1`] = ` labelType="label" > <EuiFilePicker - accept={ - Array [ - "image/svg+xml", - "image/jpeg", - "image/png", - "image/gif", - ] - } + accept="image/svg+xml,image/jpeg,image/png,image/gif" compressed={false} display="default" initialPromptText="Select image file" diff --git a/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space_avatar.tsx b/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space_avatar.tsx index 55fea3671645b..0b349b45c3068 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space_avatar.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space_avatar.tsx @@ -91,8 +91,9 @@ export class CustomizeSpaceAvatar extends Component<Props, State> { image.src = imgUrl; }; - private onFileUpload = (files: File[]) => { - const [file] = files; + private onFileUpload = (files: FileList | null) => { + if (files == null) return; + const file = files[0]; if (imageTypes.indexOf(file.type) > -1) { encode(file).then((dataurl: string) => this.handleImageUpload(dataurl)); } @@ -169,7 +170,7 @@ export class CustomizeSpaceAvatar extends Component<Props, State> { } )} onChange={this.onFileUpload} - accept={imageTypes} + accept={imageTypes.join(',')} /> </EuiFormRow> ); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 43772f62bc19f..568108aff7503 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -299,7 +299,6 @@ "data.search.aggs.metrics.averageBucketTitle": "平均バケット", "data.search.aggs.metrics.averageLabel": "平均 {field}", "data.search.aggs.metrics.averageTitle": "平均", - "data.search.aggs.metrics.bucketAggTitle": "バケット集約", "data.search.aggs.metrics.countLabel": "カウント", "data.search.aggs.metrics.countTitle": "カウント", "data.search.aggs.metrics.cumulativeSumLabel": "累積合計", @@ -316,7 +315,6 @@ "data.search.aggs.metrics.medianLabel": "中央 {field}", "data.search.aggs.metrics.medianTitle": "中央", "data.search.aggs.metrics.metricAggregationsSubtypeTitle": "メトリック集約", - "data.search.aggs.metrics.metricAggTitle": "メトリック集約", "data.search.aggs.metrics.minBucketTitle": "最低バケット", "data.search.aggs.metrics.minLabel": "最低 {field}", "data.search.aggs.metrics.minTitle": "最低", @@ -7259,9 +7257,6 @@ "xpack.maps.source.esGrid.showasFieldLabel": "表示形式", "xpack.maps.source.esGridDescription": "それぞれのグリッド付きセルのメトリックでグリッドにグループ分けされた地理空間データです。", "xpack.maps.source.esGridTitle": "グリッド集約", - "xpack.maps.source.esJoin.joinDescription": "{description} の Elasticsearch 用語集約リクエストです", - "xpack.maps.source.esJoin.joinLeftDescription": "{leftSourceName}:{leftFieldName} を次と結合:", - "xpack.maps.source.esJoin.joinMetricsDescription": "メトリック {metrics} の", "xpack.maps.source.esSearch.convertToGeoJsonErrorMsg": "検索への応答を geoJson 機能コレクションに変換できません。エラー: {errorMsg}", "xpack.maps.source.esSearch.disableFilterByMapBoundsExplainMsg": "インデックス「{indexPatternTitle}」はドキュメント数が少なく、ダイナミックフィルターが必要ありません。", "xpack.maps.source.esSearch.disableFilterByMapBoundsTitle": "ダイナミックデータフィルターは無効です", @@ -10156,7 +10151,6 @@ "xpack.reporting.exportTypes.printablePdf.documentStreamIsNotgeneratedErrorMessage": "ドキュメントストリームが生成されていません。", "xpack.reporting.exportTypes.printablePdf.logoDescription": "Elastic 提供", "xpack.reporting.exportTypes.printablePdf.pagingDescription": "{pageCount} ページ中 {currentPage} ページ目", - "xpack.reporting.exportTypes.printablePdf.screenshots.unexpectedErrorMessage": "ページで予期せぬメッセージが発生しました: {toastHeaderText}", "xpack.reporting.jobStatuses.cancelledText": "キャンセル済み", "xpack.reporting.jobStatuses.completedText": "完了", "xpack.reporting.jobStatuses.failedText": "失敗", @@ -10174,9 +10168,6 @@ "xpack.reporting.listing.tableColumns.createdAtTitle": "作成日時:", "xpack.reporting.listing.tableColumns.reportTitle": "レポート", "xpack.reporting.listing.tableColumns.statusTitle": "ステータス", - "xpack.reporting.listing.tableValue.createdAtDetail.maxSizeReachedText": " - 最大サイズに達成", - "xpack.reporting.listing.tableValue.createdAtDetail.pendingStatusReachedText": "保留中 - ジョブの処理持ち", - "xpack.reporting.listing.tableValue.createdAtDetail.statusTimestampText": "{statusTimestamp} 時点で {statusLabel}", "xpack.reporting.management.reportingTitle": "レポート", "xpack.reporting.panelContent.copyUrlButtonLabel": "POST URL をコピー", "xpack.reporting.panelContent.generateButtonLabel": "{reportingType} を生成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 16ee94d33fbf6..a91f55960e34f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -299,7 +299,6 @@ "data.search.aggs.metrics.averageBucketTitle": "平均存储桶", "data.search.aggs.metrics.averageLabel": "{field}平均值", "data.search.aggs.metrics.averageTitle": "平均值", - "data.search.aggs.metrics.bucketAggTitle": "存储桶聚合", "data.search.aggs.metrics.countLabel": "计数", "data.search.aggs.metrics.countTitle": "计数", "data.search.aggs.metrics.cumulativeSumLabel": "累计和", @@ -316,7 +315,6 @@ "data.search.aggs.metrics.medianLabel": "{field}中值", "data.search.aggs.metrics.medianTitle": "中值", "data.search.aggs.metrics.metricAggregationsSubtypeTitle": "指标聚合", - "data.search.aggs.metrics.metricAggTitle": "指标聚合", "data.search.aggs.metrics.minBucketTitle": "最小存储桶", "data.search.aggs.metrics.minLabel": "{field}最小值", "data.search.aggs.metrics.minTitle": "最小值", @@ -7259,9 +7257,6 @@ "xpack.maps.source.esGrid.showasFieldLabel": "显示为", "xpack.maps.source.esGridDescription": "地理空间数据在网格中进行分组,每个网格单元格都具有指标", "xpack.maps.source.esGridTitle": "网格聚合", - "xpack.maps.source.esJoin.joinDescription": "{description} 的 Elasticsearch 词聚合请求", - "xpack.maps.source.esJoin.joinLeftDescription": "将 {leftSourceName}:{leftFieldName} 联接到", - "xpack.maps.source.esJoin.joinMetricsDescription": "以获取指标 {metrics}", "xpack.maps.source.esSearch.convertToGeoJsonErrorMsg": "无法将搜索响应转换成 geoJson 功能集合,错误:{errorMsg}", "xpack.maps.source.esSearch.disableFilterByMapBoundsExplainMsg": "索引“{indexPatternTitle}”具有很少数量的文档,不需要动态筛选。", "xpack.maps.source.esSearch.disableFilterByMapBoundsTitle": "动态数据筛选已禁用", @@ -10156,7 +10151,6 @@ "xpack.reporting.exportTypes.printablePdf.documentStreamIsNotgeneratedErrorMessage": "尚未生成文档流", "xpack.reporting.exportTypes.printablePdf.logoDescription": "由 Elastic 提供支持", "xpack.reporting.exportTypes.printablePdf.pagingDescription": "第 {currentPage} 页,共 {pageCount} 页", - "xpack.reporting.exportTypes.printablePdf.screenshots.unexpectedErrorMessage": "在页面上出现意外消息:{toastHeaderText}", "xpack.reporting.jobStatuses.cancelledText": "已取消", "xpack.reporting.jobStatuses.completedText": "已完成", "xpack.reporting.jobStatuses.failedText": "失败", @@ -10174,9 +10168,6 @@ "xpack.reporting.listing.tableColumns.createdAtTitle": "创建于", "xpack.reporting.listing.tableColumns.reportTitle": "报告", "xpack.reporting.listing.tableColumns.statusTitle": "状态", - "xpack.reporting.listing.tableValue.createdAtDetail.maxSizeReachedText": " - 最大大小已达到", - "xpack.reporting.listing.tableValue.createdAtDetail.pendingStatusReachedText": "待处理 - 正在等候处理作业", - "xpack.reporting.listing.tableValue.createdAtDetail.statusTimestampText": "{statusTimestamp} 时为 {statusLabel}", "xpack.reporting.management.reportingTitle": "报告", "xpack.reporting.panelContent.copyUrlButtonLabel": "复制 POST URL", "xpack.reporting.panelContent.generateButtonLabel": "生成 {reportingType}", diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index c6a7808356b86..0d667f477f936 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -43,6 +43,9 @@ Table of Contents - [Action type model definition](#action-type-model-definition) - [Register action type model](#register-action-type-model) - [Create and register new action type UI example](#reate-and-register-new-action-type-ui-example) + - [Embed the Alert Actions form within any Kibana plugin](#embed-the-alert-actions-form-within-any-kibana-plugin) + - [Embed the Create Connector flyout within any Kibana plugin](#embed-the-create-connector-flyout-within-any-kibana-plugin) + - [Embed the Edit Connector flyout within any Kibana plugin](#embed-the-edit-connector-flyout-within-any-kibana-plugin) ## Built-in Alert Types @@ -69,7 +72,7 @@ AlertTypeModel: ``` export function getAlertType(): AlertTypeModel { return { - id: 'threshold', + id: '.index-threshold', name: 'Index Threshold', iconClass: 'alert', alertParamsExpression: IndexThresholdAlertTypeExpression, @@ -658,8 +661,6 @@ const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState<boolean>(false); // in render section of component <AlertsContextProvider value={{ - addFlyoutVisible: alertFlyoutVisible, - setAddFlyoutVisibility: setAlertFlyoutVisibility, http, actionTypeRegistry: triggers_actions_ui.actionTypeRegistry, alertTypeRegistry: triggers_actions_ui.alertTypeRegistry, @@ -667,9 +668,11 @@ const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState<boolean>(false); uiSettings, charts, dataFieldsFormats, + metadata: { test: 'some value', fields: ['test'] }, }} > - <AlertAdd consumer={'watcher'} /> + <AlertAdd consumer={'watcher'} addFlyoutVisible={alertFlyoutVisible} + setAddFlyoutVisibility={setAlertFlyoutVisibility} /> </AlertsContextProvider> ``` @@ -677,6 +680,8 @@ AlertAdd Props definition: ``` interface AlertAddProps { consumer: string; + addFlyoutVisible: boolean; + setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; alertTypeId?: string; canChangeTrigger?: boolean; } @@ -685,40 +690,40 @@ interface AlertAddProps { |Property|Description| |---|---| |consumer|Name of the plugin that creates an alert.| +|addFlyoutVisible|Visibility state of the Create Alert flyout.| +|setAddFlyoutVisibility|Function for changing visibility state of the Create Alert flyout.| |alertTypeId|Optional property to preselect alert type.| |canChangeTrigger|Optional property, that hides change alert type possibility.| AlertsContextProvider value options: ``` -export interface AlertsContextValue { - addFlyoutVisible: boolean; - setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; +export interface AlertsContextValue<MetaData = Record<string, any>> { reloadAlerts?: () => Promise<void>; http: HttpSetup; alertTypeRegistry: TypeRegistry<AlertTypeModel>; actionTypeRegistry: TypeRegistry<ActionTypeModel>; uiSettings?: IUiSettingsClient; - toastNotifications?: Pick< + toastNotifications: Pick< ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' >; charts?: ChartsPluginSetup; dataFieldsFormats?: Pick<FieldFormatsRegistry, 'register'>; + metadata?: MetaData; } ``` |Property|Description| |---|---| -|addFlyoutVisible|Visibility state of the Create Alert flyout.| -|setAddFlyoutVisibility|Function for changing visibility state of the Create Alert flyout.| |reloadAlerts|Optional function, which will be executed if alert was saved sucsessfuly.| |http|HttpSetup needed for executing API calls.| |alertTypeRegistry|Registry for alert types.| |actionTypeRegistry|Registry for action types.| |uiSettings|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| -|toastNotifications|Optional toast messages.| +|toastNotifications|Toast messages.| |charts|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| |dataFieldsFormats|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| +|metadata|Optional generic property, which allows to define component specific metadata. This metadata can be used for passing down preloaded data for Alert type expression component.| ## Build and register Action Types @@ -1198,3 +1203,358 @@ Clicking on the select card for `Example Action Type` will open the action type or create a new connector: ![Example Action Type with empty connectors list](https://i.imgur.com/EamA9Xv.png) + +## Embed the Alert Actions form within any Kibana plugin + +Follow the instructions bellow to embed the Alert Actions form within any Kibana plugin: +1. Add TriggersAndActionsUIPublicPluginSetup and TriggersAndActionsUIPublicPluginStart to Kibana plugin setup dependencies: + +``` +import { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, + } from '../../../../../x-pack/plugins/triggers_actions_ui/public'; + +triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; +... + +triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; +``` +Then this dependencies will be used to embed Actions form or register your own action type. + +2. Add Actions form to React component: + +``` + import React, { useCallback } from 'react'; + import { ActionForm } from '../../../../../../../../../plugins/triggers_actions_ui/public'; + import { AlertAction } from '../../../../../../../../../plugins/triggers_actions_ui/public/types'; + + const ALOWED_BY_PLUGIN_ACTION_TYPES = [ + { id: '.email', name: 'Email', enabled: true }, + { id: '.index', name: 'Index', enabled: false }, + { id: '.example-action', name: 'Example Action', enabled: false }, + ]; + + export const ComponentWithActionsForm: () => { + const { http, triggers_actions_ui, toastNotifications } = useKibana().services; + const actionTypeRegistry = triggers_actions_ui.actionTypeRegistry; + const initialAlert = ({ + name: 'test', + params: {}, + consumer: 'alerting', + alertTypeId: '.index-threshold', + schedule: { + interval: '1m', + }, + actions: [ + { + group: 'default', + id: 'test', + actionTypeId: '.index', + params: { + message: '', + }, + }, + ], + tags: [], + muteAll: false, + enabled: false, + mutedInstanceIds: [], + } as unknown) as Alert; + + return ( + <ActionForm + actions={initialAlert.actions} + messageVariables={['test var1', 'test var2']} + defaultActionGroupId={'default'} + setActionIdByIndex={(id: string, index: number) => { + initialAlert.actions[index].id = id; + }} + setAlertProperty={(_updatedActions: AlertAction[]) => {}} + setActionParamsProperty={(key: string, value: any, index: number) => + (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) + } + http={http} + actionTypeRegistry={actionTypeRegistry} + defaultActionMessage={'Alert [{{ctx.metadata.name}}] has exceeded the threshold'} + actionTypes={ALOWED_BY_PLUGIN_ACTION_TYPES} + toastNotifications={toastNotifications} + /> + ); + }; +``` + +ActionForm Props definition: +``` +interface ActionAccordionFormProps { + actions: AlertAction[]; + defaultActionGroupId: string; + setActionIdByIndex: (id: string, index: number) => void; + setAlertProperty: (actions: AlertAction[]) => void; + setActionParamsProperty: (key: string, value: any, index: number) => void; + http: HttpSetup; + actionTypeRegistry: TypeRegistry<ActionTypeModel>; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + actionTypes?: ActionType[]; + messageVariables?: string[]; + defaultActionMessage?: string; +} + +``` + +|Property|Description| +|---|---| +|actions|List of actions comes from alert.actions property.| +|defaultActionGroupId|Default action group id to which each new action will belong to.| +|setActionIdByIndex|Function for changing action 'id' by the proper index in alert.actions array.| +|setAlertProperty|Function for changing alert property 'actions'. Used when deleting action from the array to reset it.| +|setActionParamsProperty|Function for changing action key/value property by index in alert.actions array.| +|http|HttpSetup needed for executing API calls.| +|actionTypeRegistry|Registry for action types.| +|toastNotifications|Toast messages.| +|actionTypes|Optional property, which allowes to define a list of available actions specific for a current plugin.| +|actionTypes|Optional property, which allowes to define a list of variables for action 'message' property.| +|defaultActionMessage|Optional property, which allowes to define a message value for action with 'message' property.| + + +AlertsContextProvider value options: +``` +export interface AlertsContextValue { + reloadAlerts?: () => Promise<void>; + http: HttpSetup; + alertTypeRegistry: TypeRegistry<AlertTypeModel>; + actionTypeRegistry: TypeRegistry<ActionTypeModel>; + uiSettings?: IUiSettingsClient; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + charts?: ChartsPluginSetup; + dataFieldsFormats?: Pick<FieldFormatsRegistry, 'register'>; +} +``` + +|Property|Description| +|---|---| +|reloadAlerts|Optional function, which will be executed if alert was saved sucsessfuly.| +|http|HttpSetup needed for executing API calls.| +|alertTypeRegistry|Registry for alert types.| +|actionTypeRegistry|Registry for action types.| +|uiSettings|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| +|toastNotifications|Toast messages.| +|charts|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| +|dataFieldsFormats|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| + +## Embed the Create Connector flyout within any Kibana plugin + +Follow the instructions bellow to embed the Create Connector flyout within any Kibana plugin: +1. Add TriggersAndActionsUIPublicPluginSetup and TriggersAndActionsUIPublicPluginStart to Kibana plugin setup dependencies: + +``` +import { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, + } from '../../../../../x-pack/plugins/triggers_actions_ui/public'; + +triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; +... + +triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; +``` +Then this dependency will be used to embed Create Connector flyout or register new action type. + +2. Add Create Connector flyout to React component: +``` +// import section +import { ActionsConnectorsContextProvider, ConnectorAddFlyout } from '../../../../../../../triggers_actions_ui/public'; + +// in the component state definition section +const [addFlyoutVisible, setAddFlyoutVisibility] = useState<boolean>(false); + +// load required dependancied +const { http, triggers_actions_ui, toastNotifications, capabilities } = useKibana().services; + +const connector = { + secrets: {}, + id: 'test', + actionTypeId: '.index', + actionType: 'Index', + name: 'action-connector', + referencedByCount: 0, + config: {}, + }; + +// UI control item for open flyout +<EuiButton + fill + iconType="plusInCircle" + iconSide="left" + onClick={() => setAddFlyoutVisibility(true)} +> + <FormattedMessage + id="emptyButton" + defaultMessage="Create connector" + /> +</EuiButton> + +// in render section of component +<ActionsConnectorsContextProvider + value={{ + http: http, + toastNotifications: toastNotifications, + actionTypeRegistry: triggers_actions_ui.actionTypeRegistry, + capabilities: capabilities, + }} + > + <ConnectorAddFlyout + addFlyoutVisible={addFlyoutVisible} + setAddFlyoutVisibility={setAddFlyoutVisibility} + actionTypes={[ + { + id: '.index', + enabled: true, + name: 'Index', + }, + ]} + /> +</ActionsConnectorsContextProvider> +``` + +ConnectorAddFlyout Props definition: +``` +export interface ConnectorAddFlyoutProps { + addFlyoutVisible: boolean; + setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; + actionTypes?: ActionType[]; +} +``` + +|Property|Description| +|---|---| +|addFlyoutVisible|Visibility state of the Create Connector flyout.| +|setAddFlyoutVisibility|Function for changing visibility state of the Create Connector flyout.| +|actionTypes|Optional property, that allows to define only specific action types list which is available for a current plugin.| + +ActionsConnectorsContextValue options: +``` +export interface ActionsConnectorsContextValue { + http: HttpSetup; + actionTypeRegistry: TypeRegistry<ActionTypeModel>; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + capabilities: ApplicationStart['capabilities']; + reloadConnectors?: () => Promise<void>; +} +``` + +|Property|Description| +|---|---| +|http|HttpSetup needed for executing API calls.| +|actionTypeRegistry|Registry for action types.| +|capabilities|Property, which is defining action current user usage capabilities like canSave or canDelete.| +|toastNotifications|Toast messages.| +|reloadConnectors|Optional function, which will be executed if connector was saved sucsessfuly, like reload list of connecotrs.| + + +## Embed the Edit Connector flyout within any Kibana plugin + +Follow the instructions bellow to embed the Edit Connector flyout within any Kibana plugin: +1. Add TriggersAndActionsUIPublicPluginSetup and TriggersAndActionsUIPublicPluginStart to Kibana plugin setup dependencies: + +``` +import { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, + } from '../../../../../x-pack/plugins/triggers_actions_ui/public'; + +triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; +... + +triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; +``` +Then this dependency will be used to embed Edit Connector flyout. + +2. Add Create Connector flyout to React component: +``` +// import section +import { ActionsConnectorsContextProvider, ConnectorEditFlyout } from '../../../../../../../triggers_actions_ui/public'; + +// in the component state definition section +const [editFlyoutVisible, setEditFlyoutVisibility] = useState<boolean>(false); + +// load required dependancied +const { http, triggers_actions_ui, toastNotifications, capabilities } = useKibana().services; + +// UI control item for open flyout +<EuiButton + fill + iconType="plusInCircle" + iconSide="left" + onClick={() => setEditFlyoutVisibility(true)} +> + <FormattedMessage + id="emptyButton" + defaultMessage="Edit connector" + /> +</EuiButton> + +// in render section of component +<ActionsConnectorsContextProvider + value={{ + http: http, + toastNotifications: toastNotifications, + actionTypeRegistry: triggers_actions_ui.actionTypeRegistry, + capabilities: capabilities, + }} + > + <ConnectorEditFlyout + initialConnector={connector} + editFlyoutVisible={editFlyoutVisible} + setEditFlyoutVisibility={setEditFlyoutVisibility} + /> +</ActionsConnectorsContextProvider> + +``` + +ConnectorEditFlyout Props definition: +``` +export interface ConnectorEditProps { + initialConnector: ActionConnectorTableItem; + editFlyoutVisible: boolean; + setEditFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; +} +``` + +|Property|Description| +|---|---| +|initialConnector|Property, that allows to define the initial state of edited connector.| +|editFlyoutVisible|Visibility state of the Edit Connector flyout.| +|setEditFlyoutVisibility|Function for changing visibility state of the Edit Connector flyout.| + +ActionsConnectorsContextValue options: +``` +export interface ActionsConnectorsContextValue { + http: HttpSetup; + actionTypeRegistry: TypeRegistry<ActionTypeModel>; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + capabilities: ApplicationStart['capabilities']; + reloadConnectors?: () => Promise<void>; +} +``` + +|Property|Description| +|---|---| +|http|HttpSetup needed for executing API calls.| +|actionTypeRegistry|Registry for action types.| +|capabilities|Property, which is defining action current user usage capabilities like canSave or canDelete.| +|toastNotifications|Toast messages.| +|reloadConnectors|Optional function, which will be executed if connector was saved sucsessfuly, like reload list of connecotrs.| + diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx index f82b2c8c88ada..6c994051ec980 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx @@ -263,14 +263,14 @@ const EmailActionConnectorFields: React.FunctionComponent<ActionConnectorFieldsP isInvalid={errors.port.length > 0 && port !== undefined} fullWidth name="port" - value={port} + value={port || ''} data-test-subj="emailPortInput" onChange={e => { editActionConfig('port', parseInt(e.target.value, 10)); }} onBlur={() => { if (!port) { - editActionConfig('port', ''); + editActionConfig('port', 0); } }} /> @@ -380,7 +380,7 @@ const EmailParamsFields: React.FunctionComponent<ActionParamsProps<EmailActionPa const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState<boolean>(false); useEffect(() => { - if (defaultMessage && defaultMessage.length > 0) { + if (!message && defaultMessage && defaultMessage.length > 0) { editAction('message', defaultMessage, index); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx index 8d8045042cfc3..f0ac43c04ee0e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx @@ -75,7 +75,7 @@ export const ServerLogParamsFields: React.FunctionComponent<ActionParamsProps< useEffect(() => { editAction('level', 'info', index); - if (defaultMessage && defaultMessage.length > 0) { + if (!message && defaultMessage && defaultMessage.length > 0) { editAction('message', defaultMessage, index); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx index 916715de7ae18..a8ba11faa08dd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx @@ -143,7 +143,7 @@ const SlackParamsFields: React.FunctionComponent<ActionParamsProps<SlackActionPa const { message } = actionParams; const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState<boolean>(false); useEffect(() => { - if (defaultMessage && defaultMessage.length > 0) { + if (!message && defaultMessage && defaultMessage.length > 0) { editAction('message', defaultMessage, index); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx index fecf846ed6c9a..8625487282880 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx @@ -473,8 +473,6 @@ const WebhookParamsFields: React.FunctionComponent<ActionParamsProps<WebhookActi error={errors.body} > <EuiCodeEditor - fullWidth - isInvalid={errors.body.length > 0 && body !== undefined} mode="json" width="100%" height="200px" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx index a2ef67be7bca2..9a01a7f50c3df 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx @@ -17,7 +17,7 @@ import { EuiSelect, EuiSpacer, EuiComboBox, - EuiComboBoxOptionProps, + EuiComboBoxOptionOption, EuiFormRow, EuiCallOut, } from '@elastic/eui'; @@ -104,7 +104,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<IndexThr const [indexPopoverOpen, setIndexPopoverOpen] = useState(false); const [indexPatterns, setIndexPatterns] = useState([]); const [esFields, setEsFields] = useState<Record<string, any>>([]); - const [indexOptions, setIndexOptions] = useState<EuiComboBoxOptionProps[]>([]); + const [indexOptions, setIndexOptions] = useState<EuiComboBoxOptionOption[]>([]); const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); const [isIndiciesLoading, setIsIndiciesLoading] = useState<boolean>(false); @@ -143,7 +143,8 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<IndexThr groupBy: groupBy ?? DEFAULT_VALUES.GROUP_BY, threshold: threshold ?? DEFAULT_VALUES.THRESHOLD, }); - if (index.length > 0) { + + if (index && index.length > 0) { const currentEsFields = await getFields(index); const timeFields = getTimeFieldOptions(currentEsFields as any); @@ -256,7 +257,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<IndexThr value: anIndex, }; })} - onChange={async (selected: EuiComboBoxOptionProps[]) => { + onChange={async (selected: EuiComboBoxOptionOption[]) => { setAlertParams( 'index', selected.map(aSelected => aSelected.value) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx b/x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx index 11786950d0f26..b49cdc3d7d8b8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx @@ -5,15 +5,19 @@ */ import React, { createContext, useContext } from 'react'; -import { ActionType } from '../../types'; +import { HttpSetup, ToastsApi, ApplicationStart } from 'kibana/public'; +import { ActionTypeModel } from '../../types'; +import { TypeRegistry } from '../type_registry'; export interface ActionsConnectorsContextValue { - addFlyoutVisible: boolean; - editFlyoutVisible: boolean; - setEditFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; - setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; - actionTypesIndex: Record<string, ActionType> | undefined; - reloadConnectors: () => Promise<void>; + http: HttpSetup; + actionTypeRegistry: TypeRegistry<ActionTypeModel>; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + capabilities: ApplicationStart['capabilities']; + reloadConnectors?: () => Promise<void>; } const ActionsConnectorsContext = createContext<ActionsConnectorsContextValue>(null as any); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx b/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx index 1ffebed2eb002..1944cdeab7552 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx @@ -11,18 +11,19 @@ import { DataPublicPluginSetup } from 'src/plugins/data/public'; import { TypeRegistry } from '../type_registry'; import { AlertTypeModel, ActionTypeModel } from '../../types'; -export interface AlertsContextValue { +export interface AlertsContextValue<MetaData = Record<string, any>> { reloadAlerts?: () => Promise<void>; http: HttpSetup; alertTypeRegistry: TypeRegistry<AlertTypeModel>; actionTypeRegistry: TypeRegistry<ActionTypeModel>; - uiSettings?: IUiSettingsClient; - toastNotifications?: Pick< + toastNotifications: Pick< ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' >; + uiSettings?: IUiSettingsClient; charts?: ChartsPluginSetup; dataFieldsFormats?: DataPublicPluginSetup['fieldFormats']; + metadata?: MetaData; } const AlertsContext = createContext<AlertsContextValue>(null as any); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx index f7becb16c244a..800863e46034e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -9,26 +9,21 @@ import { coreMock } from '../../../../../../../src/core/public/mocks'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, ActionConnector } from '../../../types'; import { ActionConnectorForm } from './action_connector_form'; +import { ActionsConnectorsContextValue } from '../../context/actions_connectors_context'; const actionTypeRegistry = actionTypeRegistryMock.create(); describe('action_connector_form', () => { - let deps: any; + let deps: ActionsConnectorsContextValue; beforeAll(async () => { const mocks = coreMock.createSetup(); const [ { - chrome, - docLinks, application: { capabilities }, }, ] = await mocks.getStartServices(); deps = { - chrome, - docLinks, toastNotifications: mocks.notifications.toasts, - injectedMetadata: mocks.injectedMetadata, http: mocks.http, - uiSettings: mocks.uiSettings, capabilities: { ...capabilities, actions: { @@ -37,11 +32,7 @@ describe('action_connector_form', () => { show: true, }, }, - legacy: { - MANAGEMENT_BREADCRUMB: { set: () => {} } as any, - }, actionTypeRegistry: actionTypeRegistry as any, - alertTypeRegistry: {} as any, }; }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx new file mode 100644 index 0000000000000..caed0caefe109 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ValidationResult, Alert, AlertAction } from '../../../types'; +import { ActionForm } from './action_form'; +const actionTypeRegistry = actionTypeRegistryMock.create(); +describe('action_form', () => { + let deps: any; + const alertType = { + id: 'my-alert-type', + iconClass: 'test', + name: 'test-alert', + validate: (): ValidationResult => { + return { errors: {} }; + }, + alertParamsExpression: () => <Fragment />, + }; + + const actionType = { + id: 'my-action-type', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + + describe('action_form in alert', () => { + let wrapper: ReactWrapper<any>; + + async function setup() { + const mockes = coreMock.createSetup(); + deps = { + toastNotifications: mockes.notifications.toasts, + http: mockes.http, + actionTypeRegistry: actionTypeRegistry as any, + }; + actionTypeRegistry.list.mockReturnValue([actionType]); + actionTypeRegistry.has.mockReturnValue(true); + + const initialAlert = ({ + name: 'test', + params: {}, + consumer: 'alerting', + alertTypeId: alertType.id, + schedule: { + interval: '1m', + }, + actions: [ + { + group: 'default', + id: 'test', + actionTypeId: actionType.id, + params: { + message: '', + }, + }, + ], + tags: [], + muteAll: false, + enabled: false, + mutedInstanceIds: [], + } as unknown) as Alert; + + wrapper = mountWithIntl( + <ActionForm + actions={initialAlert.actions} + messageVariables={['test var1', 'test var2']} + defaultActionGroupId={'default'} + setActionIdByIndex={(id: string, index: number) => { + initialAlert.actions[index].id = id; + }} + setAlertProperty={(_updatedActions: AlertAction[]) => {}} + setActionParamsProperty={(key: string, value: any, index: number) => + (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) + } + http={deps!.http} + actionTypeRegistry={deps!.actionTypeRegistry} + defaultActionMessage={'Alert [{{ctx.metadata.name}}] has exceeded the threshold'} + actionTypes={[ + { id: actionType.id, name: 'Test', enabled: true }, + { id: '.index', name: 'Index', enabled: true }, + ]} + toastNotifications={deps!.toastNotifications} + /> + ); + + // Wait for active space to resolve before requesting the component to update + await act(async () => { + await nextTick(); + wrapper.update(); + }); + } + + it('renders available action cards', async () => { + await setup(); + const actionOption = wrapper.find( + `[data-test-subj="${actionType.id}-ActionTypeSelectOption"]` + ); + expect(actionOption.exists()).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx new file mode 100644 index 0000000000000..a43aa22026710 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -0,0 +1,512 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiTitle, + EuiSpacer, + EuiFormRow, + EuiComboBox, + EuiKeyPadMenuItem, + EuiAccordion, + EuiButtonIcon, + EuiEmptyPrompt, + EuiButtonEmpty, +} from '@elastic/eui'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { loadActionTypes, loadAllActions } from '../../lib/action_connector_api'; +import { + IErrorObject, + ActionTypeModel, + AlertAction, + ActionTypeIndex, + ActionConnector, + ActionType, +} from '../../../types'; +import { SectionLoading } from '../../components/section_loading'; +import { ConnectorAddModal } from './connector_add_modal'; +import { TypeRegistry } from '../../type_registry'; + +interface ActionAccordionFormProps { + actions: AlertAction[]; + defaultActionGroupId: string; + setActionIdByIndex: (id: string, index: number) => void; + setAlertProperty: (actions: AlertAction[]) => void; + setActionParamsProperty: (key: string, value: any, index: number) => void; + http: HttpSetup; + actionTypeRegistry: TypeRegistry<ActionTypeModel>; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + actionTypes?: ActionType[]; + messageVariables?: string[]; + defaultActionMessage?: string; +} + +interface ActiveActionConnectorState { + actionTypeId: string; + index: number; +} + +export const ActionForm = ({ + actions, + defaultActionGroupId, + setActionIdByIndex, + setAlertProperty, + setActionParamsProperty, + http, + actionTypeRegistry, + actionTypes, + messageVariables, + defaultActionMessage, + toastNotifications, +}: ActionAccordionFormProps) => { + const [addModalVisible, setAddModalVisibility] = useState<boolean>(false); + const [activeActionItem, setActiveActionItem] = useState<ActiveActionConnectorState | undefined>( + undefined + ); + const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState<boolean>(true); + const [connectors, setConnectors] = useState<ActionConnector[]>([]); + const [isLoadingActionTypes, setIsLoadingActionTypes] = useState<boolean>(false); + const [actionTypesIndex, setActionTypesIndex] = useState<ActionTypeIndex | undefined>(undefined); + + // load action types + useEffect(() => { + (async () => { + try { + setIsLoadingActionTypes(true); + const registeredActionTypes = actionTypes ?? (await loadActionTypes({ http })); + const index: ActionTypeIndex = {}; + for (const actionTypeItem of registeredActionTypes) { + index[actionTypeItem.id] = actionTypeItem; + } + setActionTypesIndex(index); + } catch (e) { + if (toastNotifications) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionTypesMessage', + { defaultMessage: 'Unable to load action types' } + ), + }); + } + } finally { + setIsLoadingActionTypes(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + loadConnectors(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function loadConnectors() { + try { + const actionsResponse = await loadAllActions({ http }); + setConnectors(actionsResponse.data); + } catch (e) { + if (toastNotifications) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionsMessage', + { + defaultMessage: 'Unable to load connectors', + } + ), + }); + } + } + } + + const actionsErrors = actions.reduce( + (acc: Record<string, { errors: IErrorObject }>, alertAction: AlertAction) => { + const actionType = actionTypeRegistry.get(alertAction.actionTypeId); + if (!actionType) { + return { ...acc }; + } + const actionValidationErrors = actionType.validateParams(alertAction.params); + return { ...acc, [alertAction.id]: actionValidationErrors }; + }, + {} + ) as Record<string, { errors: IErrorObject }>; + + const getSelectedOptions = (actionItemId: string) => { + const val = connectors.find(connector => connector.id === actionItemId); + if (!val) { + return []; + } + return [ + { + label: val.name, + value: val.name, + id: actionItemId, + }, + ]; + }; + + const getActionTypeForm = ( + actionItem: AlertAction, + actionConnector: ActionConnector, + index: number + ) => { + const optionsList = connectors + .filter( + connectorItem => + connectorItem.actionTypeId === actionItem.actionTypeId && + (connectorItem.id === actionItem.id || + !actions.find( + (existingAction: AlertAction) => + existingAction.id === connectorItem.id && existingAction.group === actionItem.group + )) + ) + .map(({ name, id }) => ({ + label: name, + key: id, + id, + })); + const actionTypeRegistered = actionTypeRegistry.get(actionConnector.actionTypeId); + if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; + const ParamsFieldsComponent = actionTypeRegistered.actionParamsFields; + const actionParamsErrors: { errors: IErrorObject } = + Object.keys(actionsErrors).length > 0 ? actionsErrors[actionItem.id] : { errors: {} }; + + return ( + <EuiAccordion + initialIsOpen={true} + key={index} + id={index.toString()} + className="euiAccordionForm" + buttonContentClassName="euiAccordionForm__button" + data-test-subj={`alertActionAccordion-${defaultActionGroupId}`} + buttonContent={ + <EuiFlexGroup gutterSize="s" alignItems="center"> + <EuiFlexItem grow={false}> + <EuiIcon type={actionTypeRegistered.iconClass} size="m" /> + </EuiFlexItem> + <EuiFlexItem> + <EuiTitle size="s"> + <h5> + <FormattedMessage + defaultMessage="Action: {actionConnectorName}" + id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle" + values={{ + actionConnectorName: actionConnector.name, + }} + /> + </h5> + </EuiTitle> + </EuiFlexItem> + </EuiFlexGroup> + } + extraAction={ + <EuiButtonIcon + iconType="cross" + color="danger" + className="euiAccordionForm__extraAction" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel', + { + defaultMessage: 'Delete', + } + )} + onClick={() => { + const updatedActions = actions.filter( + (item: AlertAction) => item.id !== actionItem.id + ); + setAlertProperty(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0 + ); + setActiveActionItem(undefined); + }} + /> + } + paddingSize="l" + > + <EuiFlexGroup component="div"> + <EuiFlexItem> + <EuiFormRow + fullWidth + label={ + <FormattedMessage + id="xpack.triggersActionsUI.sections.alertForm.actionIdLabel" + defaultMessage="{connectorInstance} instance" + values={{ + connectorInstance: actionTypesIndex + ? actionTypesIndex[actionConnector.actionTypeId].name + : actionConnector.actionTypeId, + }} + /> + } + labelAppend={ + <EuiButtonEmpty + size="xs" + onClick={() => { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); + }} + > + <FormattedMessage + defaultMessage="Add new" + id="xpack.triggersActionsUI.sections.alertForm.addNewConnectorEmptyButton" + /> + </EuiButtonEmpty> + } + > + <EuiComboBox + fullWidth + singleSelection={{ asPlainText: true }} + options={optionsList} + selectedOptions={getSelectedOptions(actionItem.id)} + onChange={selectedOptions => { + setActionIdByIndex(selectedOptions[0].id ?? '', index); + }} + isClearable={false} + /> + </EuiFormRow> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size="xl" /> + {ParamsFieldsComponent ? ( + <ParamsFieldsComponent + actionParams={actionItem.params as any} + index={index} + errors={actionParamsErrors.errors} + editAction={setActionParamsProperty} + messageVariables={messageVariables} + defaultMessage={defaultActionMessage ?? undefined} + /> + ) : null} + </EuiAccordion> + ); + }; + + const getAddConnectorsForm = (actionItem: AlertAction, index: number) => { + const actionTypeName = actionTypesIndex + ? actionTypesIndex[actionItem.actionTypeId].name + : actionItem.actionTypeId; + const actionTypeRegistered = actionTypeRegistry.get(actionItem.actionTypeId); + if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; + return ( + <EuiAccordion + initialIsOpen={true} + key={index} + id={index.toString()} + className="euiAccordionForm" + buttonContentClassName="euiAccordionForm__button" + data-test-subj={`alertActionAccordion-${defaultActionGroupId}`} + buttonContent={ + <EuiFlexGroup gutterSize="s" alignItems="center"> + <EuiFlexItem grow={false}> + <EuiIcon type={actionTypeRegistered.iconClass} size="m" /> + </EuiFlexItem> + <EuiFlexItem> + <EuiTitle size="s"> + <h5> + <FormattedMessage + defaultMessage="Action: {actionConnectorName}" + id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle" + values={{ + actionConnectorName: actionTypeRegistered.actionTypeTitle, + }} + /> + </h5> + </EuiTitle> + </EuiFlexItem> + </EuiFlexGroup> + } + extraAction={ + <EuiButtonIcon + iconType="cross" + color="danger" + className="euiAccordionForm__extraAction" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel', + { + defaultMessage: 'Delete', + } + )} + onClick={() => { + const updatedActions = actions.filter( + (item: AlertAction) => item.id !== actionItem.id + ); + setAlertProperty(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0 + ); + setActiveActionItem(undefined); + }} + /> + } + paddingSize="l" + > + <EuiEmptyPrompt + title={ + <FormattedMessage + id="xpack.triggersActionsUI.sections.alertForm.emptyConnectorsLabel" + defaultMessage="There are no {actionTypeName} connectors" + values={{ + actionTypeName, + }} + /> + } + actions={[ + <EuiButton + color="primary" + fill + data-test-subj="createActionConnectorButton" + onClick={() => { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); + }} + > + <FormattedMessage + id="xpack.triggersActionsUI.sections.alertForm.addConnectorButtonLabel" + defaultMessage="Add {actionTypeName} connector" + values={{ + actionTypeName, + }} + /> + </EuiButton>, + ]} + /> + </EuiAccordion> + ); + }; + + function addActionType(actionTypeModel: ActionTypeModel) { + if (!defaultActionGroupId) { + toastNotifications!.addDanger({ + title: i18n.translate('xpack.triggersActionsUI.sections.alertForm.unableToAddAction', { + defaultMessage: 'Unable to add action, because default action group is not defined', + }), + }); + return; + } + setIsAddActionPanelOpen(false); + const actionTypeConnectors = connectors.filter( + field => field.actionTypeId === actionTypeModel.id + ); + let freeConnectors; + if (actionTypeConnectors.length > 0) { + // Should we allow adding multiple actions to the same connector under the alert? + freeConnectors = actionTypeConnectors.filter( + (actionConnector: ActionConnector) => + !actions.find((actionItem: AlertAction) => actionItem.id === actionConnector.id) + ); + if (freeConnectors.length > 0) { + actions.push({ + id: '', + actionTypeId: actionTypeModel.id, + group: defaultActionGroupId, + params: {}, + }); + setActionIdByIndex(freeConnectors[0].id, actions.length - 1); + } + } + if (actionTypeConnectors.length === 0 || !freeConnectors || freeConnectors.length === 0) { + // if no connectors exists or all connectors is already assigned an action under current alert + // set actionType as id to be able to create new connector within the alert form + actions.push({ + id: '', + actionTypeId: actionTypeModel.id, + group: defaultActionGroupId, + params: {}, + }); + setActionIdByIndex(actions.length.toString(), actions.length - 1); + } + } + + const actionTypeNodes = actionTypesIndex + ? actionTypeRegistry.list().map(function(item, index) { + return actionTypesIndex[item.id] ? ( + <EuiKeyPadMenuItem + key={index} + data-test-subj={`${item.id}-ActionTypeSelectOption`} + label={actionTypesIndex[item.id].name} + onClick={() => addActionType(item)} + > + <EuiIcon size="xl" type={item.iconClass} /> + </EuiKeyPadMenuItem> + ) : null; + }) + : null; + + return ( + <Fragment> + {actions.map((actionItem: AlertAction, index: number) => { + const actionConnector = connectors.find(field => field.id === actionItem.id); + // connectors doesn't exists + if (!actionConnector) { + return getAddConnectorsForm(actionItem, index); + } + return getActionTypeForm(actionItem, actionConnector, index); + })} + <EuiSpacer size="m" /> + {isAddActionPanelOpen === false ? ( + <EuiButton + iconType="plusInCircle" + data-test-subj="addAlertActionButton" + onClick={() => setIsAddActionPanelOpen(true)} + > + <FormattedMessage + id="xpack.triggersActionsUI.sections.alertForm.addActionButtonLabel" + defaultMessage="Add action" + /> + </EuiButton> + ) : null} + {isAddActionPanelOpen ? ( + <Fragment> + <EuiTitle size="xs"> + <h5 id="alertActionTypeTitle"> + <FormattedMessage + defaultMessage="Actions: Select an action type" + id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeTitle" + /> + </h5> + </EuiTitle> + <EuiSpacer /> + <EuiFlexGroup gutterSize="s" wrap> + {isLoadingActionTypes ? ( + <SectionLoading> + <FormattedMessage + id="xpack.triggersActionsUI.sections.alertForm.loadingActionTypesDescription" + defaultMessage="Loading action types…" + /> + </SectionLoading> + ) : ( + actionTypeNodes + )} + </EuiFlexGroup> + </Fragment> + ) : null} + {actionTypesIndex && activeActionItem ? ( + <ConnectorAddModal + key={activeActionItem.index} + actionType={actionTypesIndex[activeActionItem.actionTypeId]} + addModalVisible={addModalVisible} + setAddModalVisibility={setAddModalVisibility} + postSaveEventHandler={(savedAction: ActionConnector) => { + connectors.push(savedAction); + setActionIdByIndex(savedAction.id, activeActionItem.index); + }} + actionTypeRegistry={actionTypeRegistry} + http={http} + toastNotifications={toastNotifications} + /> + ) : null} + </Fragment> + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx index c1c6d9d94e810..4f098165033e7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -6,31 +6,28 @@ import * as React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; +import { + ActionsConnectorsContextProvider, + ActionsConnectorsContextValue, +} from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ActionTypeMenu } from './action_type_menu'; import { ValidationResult } from '../../../types'; const actionTypeRegistry = actionTypeRegistryMock.create(); describe('connector_add_flyout', () => { - let deps: any; + let deps: ActionsConnectorsContextValue; beforeAll(async () => { const mockes = coreMock.createSetup(); const [ { - chrome, - docLinks, application: { capabilities }, }, ] = await mockes.getStartServices(); deps = { - chrome, - docLinks, - toastNotifications: mockes.notifications.toasts, - injectedMetadata: mockes.injectedMetadata, http: mockes.http, - uiSettings: mockes.uiSettings, + toastNotifications: mockes.notifications.toasts, capabilities: { ...capabilities, actions: { @@ -39,11 +36,7 @@ describe('connector_add_flyout', () => { show: true, }, }, - legacy: { - MANAGEMENT_BREADCRUMB: { set: () => {} } as any, - }, actionTypeRegistry: actionTypeRegistry as any, - alertTypeRegistry: {} as any, }; }); @@ -68,14 +61,10 @@ describe('connector_add_flyout', () => { const wrapper = mountWithIntl( <ActionsConnectorsContextProvider value={{ - addFlyoutVisible: true, - setAddFlyoutVisibility: state => {}, - editFlyoutVisible: false, - setEditFlyoutVisibility: state => {}, - actionTypesIndex: { - 'first-action-type': { id: 'first-action-type', name: 'first', enabled: true }, - 'second-action-type': { id: 'second-action-type', name: 'second', enabled: true }, - }, + http: deps!.http, + actionTypeRegistry: deps!.actionTypeRegistry, + capabilities: deps!.capabilities, + toastNotifications: deps!.toastNotifications, reloadConnectors: () => { return new Promise<void>(() => {}); }, @@ -83,12 +72,17 @@ describe('connector_add_flyout', () => { > <ActionTypeMenu onActionTypeChange={onActionTypeChange} - actionTypeRegistry={deps.actionTypeRegistry} + actionTypes={[ + { + id: actionType.id, + enabled: true, + name: 'Test', + }, + ]} /> </ActionsConnectorsContextProvider> ); - expect(wrapper.find('[data-test-subj="first-action-type-card"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="second-action-type-card"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeTruthy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx index ddd08cf6d6d79..a63665a68fb6b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx @@ -3,24 +3,46 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid } from '@elastic/eui'; -import { ActionType, ActionTypeModel } from '../../../types'; +import { i18n } from '@kbn/i18n'; +import { ActionType, ActionTypeIndex } from '../../../types'; +import { loadActionTypes } from '../../lib/action_connector_api'; import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; -import { TypeRegistry } from '../../type_registry'; interface Props { onActionTypeChange: (actionType: ActionType) => void; - actionTypeRegistry: TypeRegistry<ActionTypeModel>; + actionTypes?: ActionType[]; } -export const ActionTypeMenu = ({ onActionTypeChange, actionTypeRegistry }: Props) => { - const { actionTypesIndex } = useActionsConnectorsContext(); - if (!actionTypesIndex) { - return null; - } +export const ActionTypeMenu = ({ onActionTypeChange, actionTypes }: Props) => { + const { http, toastNotifications, actionTypeRegistry } = useActionsConnectorsContext(); + const [actionTypesIndex, setActionTypesIndex] = useState<ActionTypeIndex | undefined>(undefined); - const actionTypes = Object.entries(actionTypesIndex) + useEffect(() => { + (async () => { + try { + const availableActionTypes = actionTypes ?? (await loadActionTypes({ http })); + const index: ActionTypeIndex = {}; + for (const actionTypeItem of availableActionTypes) { + index[actionTypeItem.id] = actionTypeItem; + } + setActionTypesIndex(index); + } catch (e) { + if (toastNotifications) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionTypesMessage', + { defaultMessage: 'Unable to load action types' } + ), + }); + } + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const registeredActionTypes = Object.entries(actionTypesIndex ?? []) .filter(([index]) => actionTypeRegistry.has(index)) .map(([index, actionType]) => { const actionTypeModel = actionTypeRegistry.get(index); @@ -33,7 +55,7 @@ export const ActionTypeMenu = ({ onActionTypeChange, actionTypeRegistry }: Props }; }); - const cardNodes = actionTypes + const cardNodes = registeredActionTypes .sort((a, b) => a.name.localeCompare(b.name)) .map((item, index) => { return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx index 6b87002a1d2cf..cf0edbe422495 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx @@ -7,37 +7,28 @@ import * as React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { ConnectorAddFlyout } from './connector_add_flyout'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; +import { + ActionsConnectorsContextProvider, + ActionsConnectorsContextValue, +} from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult } from '../../../types'; -import { AppContextProvider } from '../../app_context'; -import { AppDeps } from '../../app'; -import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; -import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; const actionTypeRegistry = actionTypeRegistryMock.create(); describe('connector_add_flyout', () => { - let deps: AppDeps | null; + let deps: ActionsConnectorsContextValue; beforeAll(async () => { const mocks = coreMock.createSetup(); const [ { - chrome, - docLinks, application: { capabilities }, }, ] = await mocks.getStartServices(); deps = { - chrome, - docLinks, - dataPlugin: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), toastNotifications: mocks.notifications.toasts, - injectedMetadata: mocks.injectedMetadata, http: mocks.http, - uiSettings: mocks.uiSettings, capabilities: { ...capabilities, actions: { @@ -46,9 +37,7 @@ describe('connector_add_flyout', () => { show: true, }, }, - setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, - alertTypeRegistry: {} as any, }; }); @@ -71,24 +60,29 @@ describe('connector_add_flyout', () => { actionTypeRegistry.has.mockReturnValue(true); const wrapper = mountWithIntl( - <AppContextProvider appDeps={deps}> - <ActionsConnectorsContextProvider - value={{ - addFlyoutVisible: true, - setAddFlyoutVisibility: state => {}, - editFlyoutVisible: false, - setEditFlyoutVisibility: state => {}, - actionTypesIndex: { - 'my-action-type': { id: 'my-action-type', name: 'test', enabled: true }, + <ActionsConnectorsContextProvider + value={{ + http: deps!.http, + toastNotifications: deps!.toastNotifications, + actionTypeRegistry: deps!.actionTypeRegistry, + capabilities: deps!.capabilities, + reloadConnectors: () => { + return new Promise<void>(() => {}); + }, + }} + > + <ConnectorAddFlyout + addFlyoutVisible={true} + setAddFlyoutVisibility={() => {}} + actionTypes={[ + { + id: actionType.id, + enabled: true, + name: 'Test', }, - reloadConnectors: () => { - return new Promise<void>(() => {}); - }, - }} - > - <ConnectorAddFlyout /> - </ActionsConnectorsContextProvider> - </AppContextProvider> + ]} + /> + </ActionsConnectorsContextProvider> ); expect(wrapper.find('ActionTypeMenu')).toHaveLength(1); expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index 1eabf2441da4f..1b86116781084 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -20,18 +20,33 @@ import { EuiBetaBadge, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { ActionTypeMenu } from './action_type_menu'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; import { ActionType, ActionConnector, IErrorObject } from '../../../types'; -import { useAppDependencies } from '../../app_context'; import { connectorReducer } from './connector_reducer'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { createActionConnector } from '../../lib/action_connector_api'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; + +export interface ConnectorAddFlyoutProps { + addFlyoutVisible: boolean; + setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; + actionTypes?: ActionType[]; +} -export const ConnectorAddFlyout = () => { +export const ConnectorAddFlyout = ({ + addFlyoutVisible, + setAddFlyoutVisibility, + actionTypes, +}: ConnectorAddFlyoutProps) => { let hasErrors = false; - const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies(); + const { + http, + toastNotifications, + capabilities, + actionTypeRegistry, + reloadConnectors, + } = useActionsConnectorsContext(); const [actionType, setActionType] = useState<ActionType | undefined>(undefined); // hooks @@ -48,11 +63,6 @@ export const ConnectorAddFlyout = () => { dispatch({ command: { type: 'setConnector' }, payload: { key: 'connector', value } }); }; - const { - addFlyoutVisible, - setAddFlyoutVisibility, - reloadConnectors, - } = useActionsConnectorsContext(); const [isSaving, setIsSaving] = useState<boolean>(false); const closeFlyout = useCallback(() => { @@ -79,10 +89,7 @@ export const ConnectorAddFlyout = () => { let actionTypeModel; if (!actionType) { currentForm = ( - <ActionTypeMenu - onActionTypeChange={onActionTypeChange} - actionTypeRegistry={actionTypeRegistry} - /> + <ActionTypeMenu onActionTypeChange={onActionTypeChange} actionTypes={actionTypes} /> ); } else { actionTypeModel = actionTypeRegistry.get(actionType.id); @@ -108,17 +115,19 @@ export const ConnectorAddFlyout = () => { const onActionConnectorSave = async (): Promise<ActionConnector | undefined> => await createActionConnector({ http, connector }) .then(savedConnector => { - toastNotifications.addSuccess( - i18n.translate( - 'xpack.triggersActionsUI.sections.addConnectorForm.updateSuccessNotificationText', - { - defaultMessage: "Created '{connectorName}'", - values: { - connectorName: savedConnector.name, - }, - } - ) - ); + if (toastNotifications) { + toastNotifications.addSuccess( + i18n.translate( + 'xpack.triggersActionsUI.sections.addConnectorForm.updateSuccessNotificationText', + { + defaultMessage: "Created '{connectorName}'", + values: { + connectorName: savedConnector.name, + }, + } + ) + ); + } return savedConnector; }) .catch(errorRes => { @@ -218,7 +227,9 @@ export const ConnectorAddFlyout = () => { setIsSaving(false); if (savedAction) { closeFlyout(); - reloadConnectors(); + if (reloadConnectors) { + reloadConnectors(); + } } }} > diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index d9f3e98919d76..31d801bb340f3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -7,35 +7,24 @@ import * as React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { ConnectorAddModal } from './connector_add_modal'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult } from '../../../types'; -import { AppDeps } from '../../app'; -import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; -import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; +import { ActionsConnectorsContextValue } from '../../context/actions_connectors_context'; const actionTypeRegistry = actionTypeRegistryMock.create(); describe('connector_add_modal', () => { - let deps: AppDeps | null; + let deps: ActionsConnectorsContextValue; beforeAll(async () => { const mocks = coreMock.createSetup(); const [ { - chrome, - docLinks, application: { capabilities }, }, ] = await mocks.getStartServices(); deps = { - chrome, - docLinks, - dataPlugin: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), toastNotifications: mocks.notifications.toasts, - injectedMetadata: mocks.injectedMetadata, http: mocks.http, - uiSettings: mocks.uiSettings, capabilities: { ...capabilities, actions: { @@ -44,9 +33,7 @@ describe('connector_add_modal', () => { show: true, }, }, - setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, - alertTypeRegistry: {} as any, }; }); it('renders connector modal form if addModalVisible is true', () => { @@ -75,30 +62,14 @@ describe('connector_add_modal', () => { const wrapper = deps ? mountWithIntl( - <ActionsConnectorsContextProvider - value={{ - addFlyoutVisible: true, - setAddFlyoutVisibility: state => {}, - editFlyoutVisible: false, - setEditFlyoutVisibility: state => {}, - actionTypesIndex: { - 'my-action-type': { id: 'my-action-type', name: 'test', enabled: true }, - }, - reloadConnectors: () => { - return new Promise<void>(() => {}); - }, - }} - > - <ConnectorAddModal - addModalVisible={true} - setAddModalVisibility={() => {}} - actionType={actionType} - http={deps.http} - actionTypeRegistry={deps.actionTypeRegistry} - alertTypeRegistry={deps.alertTypeRegistry} - toastNotifications={deps.toastNotifications} - /> - </ActionsConnectorsContextProvider> + <ConnectorAddModal + addModalVisible={true} + setAddModalVisibility={() => {}} + actionType={actionType} + http={deps.http} + actionTypeRegistry={deps.actionTypeRegistry} + toastNotifications={deps.toastNotifications} + /> ) : undefined; expect(wrapper?.find('EuiModalHeader')).toHaveLength(1); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index 6486292725660..1cc26f39990ff 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -19,13 +19,7 @@ import { EuiOverlayMask } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { HttpSetup, ToastsApi } from 'kibana/public'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; -import { - ActionType, - ActionConnector, - IErrorObject, - AlertTypeModel, - ActionTypeModel, -} from '../../../types'; +import { ActionType, ActionConnector, IErrorObject, ActionTypeModel } from '../../../types'; import { connectorReducer } from './connector_reducer'; import { createActionConnector } from '../../lib/action_connector_api'; import { TypeRegistry } from '../../type_registry'; @@ -36,7 +30,6 @@ interface ConnectorAddModalProps { setAddModalVisibility: React.Dispatch<React.SetStateAction<boolean>>; postSaveEventHandler?: (savedAction: ActionConnector) => void; http: HttpSetup; - alertTypeRegistry: TypeRegistry<AlertTypeModel>; actionTypeRegistry: TypeRegistry<ActionTypeModel>; toastNotifications?: Pick< ToastsApi, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx index a82003759d973..f9aa2cad8bfc6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx @@ -11,8 +11,6 @@ import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult } from '../../../types'; import { ConnectorEditFlyout } from './connector_edit_flyout'; import { AppContextProvider } from '../../app_context'; -import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; -import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; const actionTypeRegistry = actionTypeRegistryMock.create(); let deps: any; @@ -22,18 +20,11 @@ describe('connector_edit_flyout', () => { const mockes = coreMock.createSetup(); const [ { - chrome, - docLinks, application: { capabilities }, }, ] = await mockes.getStartServices(); deps = { - chrome, - docLinks, - dataPlugin: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), toastNotifications: mockes.notifications.toasts, - injectedMetadata: mockes.injectedMetadata, http: mockes.http, uiSettings: mockes.uiSettings, capabilities: { @@ -44,7 +35,6 @@ describe('connector_edit_flyout', () => { show: true, }, }, - setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: {} as any, }; @@ -82,19 +72,20 @@ describe('connector_edit_flyout', () => { <AppContextProvider appDeps={deps}> <ActionsConnectorsContextProvider value={{ - addFlyoutVisible: false, - setAddFlyoutVisibility: state => {}, - editFlyoutVisible: true, - setEditFlyoutVisibility: state => {}, - actionTypesIndex: { - 'test-action-type-id': { id: 'test-action-type-id', name: 'test', enabled: true }, - }, + http: deps.http, + toastNotifications: deps.toastNotifications, + capabilities: deps.capabilities, + actionTypeRegistry: deps.actionTypeRegistry, reloadConnectors: () => { return new Promise<void>(() => {}); }, }} > - <ConnectorEditFlyout initialConnector={connector} /> + <ConnectorEditFlyout + initialConnector={connector} + editFlyoutVisible={true} + setEditFlyoutVisibility={state => {}} + /> </ActionsConnectorsContextProvider> </AppContextProvider> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 6fe555fd74b39..c52bb8cc08f6f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -19,27 +19,33 @@ import { EuiBetaBadge, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; -import { useAppDependencies } from '../../app_context'; import { ActionConnectorTableItem, ActionConnector, IErrorObject } from '../../../types'; import { connectorReducer } from './connector_reducer'; import { updateActionConnector } from '../../lib/action_connector_api'; import { hasSaveActionsCapability } from '../../lib/capabilities'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; export interface ConnectorEditProps { initialConnector: ActionConnectorTableItem; + editFlyoutVisible: boolean; + setEditFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; } -export const ConnectorEditFlyout = ({ initialConnector }: ConnectorEditProps) => { +export const ConnectorEditFlyout = ({ + initialConnector, + editFlyoutVisible, + setEditFlyoutVisibility, +}: ConnectorEditProps) => { let hasErrors = false; - const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies(); - const canSave = hasSaveActionsCapability(capabilities); const { - editFlyoutVisible, - setEditFlyoutVisibility, + http, + toastNotifications, + capabilities, + actionTypeRegistry, reloadConnectors, } = useActionsConnectorsContext(); + const canSave = hasSaveActionsCapability(capabilities); const closeFlyout = useCallback(() => setEditFlyoutVisibility(false), [setEditFlyoutVisibility]); const [{ connector }, dispatch] = useReducer(connectorReducer, { connector: { ...initialConnector, secrets: {} }, @@ -63,17 +69,19 @@ export const ConnectorEditFlyout = ({ initialConnector }: ConnectorEditProps) => const onActionConnectorSave = async (): Promise<ActionConnector | undefined> => await updateActionConnector({ http, connector, id: connector.id }) .then(savedConnector => { - toastNotifications.addSuccess( - i18n.translate( - 'xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText', - { - defaultMessage: "Updated '{connectorName}'", - values: { - connectorName: savedConnector.name, - }, - } - ) - ); + if (toastNotifications) { + toastNotifications.addSuccess( + i18n.translate( + 'xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText', + { + defaultMessage: "Updated '{connectorName}'", + values: { + connectorName: savedConnector.name, + }, + } + ) + ); + } return savedConnector; }) .catch(errorRes => { @@ -151,7 +159,9 @@ export const ConnectorEditFlyout = ({ initialConnector }: ConnectorEditProps) => setIsSaving(false); if (savedAction) { closeFlyout(); - reloadConnectors(); + if (reloadConnectors) { + reloadConnectors(); + } } }} > diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/index.ts index aac7a514948d1..52ee1efbdaf9f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/index.ts @@ -6,3 +6,4 @@ export { ConnectorAddFlyout } from './connector_add_flyout'; export { ConnectorEditFlyout } from './connector_edit_flyout'; +export { ActionForm } from './action_form'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index f48e27791419d..4e514281be0ea 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -18,16 +18,16 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ActionsConnectorsContextProvider } from '../../../context/actions_connectors_context'; import { useAppDependencies } from '../../../app_context'; import { loadAllActions, loadActionTypes } from '../../../lib/action_connector_api'; import { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types'; import { ConnectorAddFlyout, ConnectorEditFlyout } from '../../action_connector_form'; import { hasDeleteActionsCapability, hasSaveActionsCapability } from '../../../lib/capabilities'; import { DeleteConnectorsModal } from '../../../components/delete_connectors_modal'; +import { ActionsConnectorsContextProvider } from '../../../context/actions_connectors_context'; export const ActionsConnectorsList: React.FunctionComponent = () => { - const { http, toastNotifications, capabilities } = useAppDependencies(); + const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies(); const canDelete = hasDeleteActionsCapability(capabilities); const canSave = hasSaveActionsCapability(capabilities); @@ -377,19 +377,23 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { {data.length === 0 && !canSave && noPermissionPrompt} <ActionsConnectorsContextProvider value={{ - addFlyoutVisible, - setAddFlyoutVisibility, - editFlyoutVisible, - setEditFlyoutVisibility, - actionTypesIndex, + actionTypeRegistry, + http, + capabilities, + toastNotifications, reloadConnectors: loadActions, }} > - <ConnectorAddFlyout /> + <ConnectorAddFlyout + addFlyoutVisible={addFlyoutVisible} + setAddFlyoutVisibility={setAddFlyoutVisibility} + /> {editedConnectorItem ? ( <ConnectorEditFlyout key={editedConnectorItem.id} initialConnector={editedConnectorItem} + editFlyoutVisible={editFlyoutVisible} + setEditFlyoutVisibility={setEditFlyoutVisibility} /> ) : null} </ActionsConnectorsContextProvider> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index 7bc44eafe7543..1177b41788bd6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -6,11 +6,13 @@ import * as React from 'react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { act } from 'react-dom/test-utils'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFormLabel } from '@elastic/eui'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { AlertAdd } from './alert_add'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult } from '../../../types'; -import { AlertsContextProvider } from '../../context/alerts_context'; +import { AlertsContextProvider, useAlertsContext } from '../../context/alerts_context'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; @@ -18,6 +20,21 @@ import { ReactWrapper } from 'enzyme'; const actionTypeRegistry = actionTypeRegistryMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); +export const TestExpression: React.FunctionComponent<any> = () => { + const alertsContext = useAlertsContext(); + const { metadata } = alertsContext; + + return ( + <EuiFormLabel> + <FormattedMessage + defaultMessage="Metadata: {val}. Fields: {fields}." + id="xpack.triggersActionsUI.sections.alertAdd.metadataTest" + values={{ val: metadata!.test, fields: metadata!.fields.join(' ') }} + /> + </EuiFormLabel> + ); +}; + describe('alert_add', () => { let deps: any; let wrapper: ReactWrapper<any>; @@ -41,7 +58,7 @@ describe('alert_add', () => { validate: (): ValidationResult => { return { errors: {} }; }, - alertParamsExpression: () => <React.Fragment />, + alertParamsExpression: TestExpression, }; const actionTypeModel = { @@ -77,13 +94,10 @@ describe('alert_add', () => { alertTypeRegistry: deps.alertTypeRegistry, toastNotifications: deps.toastNotifications, uiSettings: deps.uiSettings, + metadata: { test: 'some value', fields: ['test'] }, }} > - <AlertAdd - consumer={'alerting'} - addFlyoutVisible={true} - setAddFlyoutVisibility={state => {}} - /> + <AlertAdd consumer={'alerting'} addFlyoutVisible={true} setAddFlyoutVisibility={() => {}} /> </AlertsContextProvider> ); // Wait for active space to resolve before requesting the component to update @@ -97,5 +111,10 @@ describe('alert_add', () => { await setup(); expect(wrapper.find('[data-test-subj="addAlertFlyoutTitle"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="saveAlertButton"]').exists()).toBeTruthy(); + wrapper + .find('[data-test-subj="my-alert-type-SelectOption"]') + .first() + .simulate('click'); + expect(wrapper.contains('Metadata: some value. Fields: test.')).toBeTruthy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx index d216b4d2a4afe..4ebeba3924faf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx @@ -20,7 +20,7 @@ describe('alert_edit', () => { let deps: any; let wrapper: ReactWrapper<any>; - beforeAll(async () => { + async function setup() { const mockes = coreMock.createSetup(); deps = { toastNotifications: mockes.notifications.toasts, @@ -122,9 +122,10 @@ describe('alert_edit', () => { await nextTick(); wrapper.update(); }); - }); + } - it('renders alert add flyout', () => { + it('renders alert add flyout', async () => { + await setup(); expect(wrapper.find('[data-test-subj="editAlertFlyoutTitle"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="saveEditedAlertButton"]').exists()).toBeTruthy(); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx index 0c22ce0fca80c..6119b407a6590 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx @@ -4,22 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { Fragment } from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { ValidationResult, Alert } from '../../../types'; import { AlertForm } from './alert_form'; -import { AppDeps } from '../../app'; -import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; -import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; import { AlertsContextProvider } from '../../context/alerts_context'; +import { coreMock } from 'src/core/public/mocks'; const actionTypeRegistry = actionTypeRegistryMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); describe('alert_form', () => { - let deps: AppDeps | null; + let deps: any; const alertType = { id: 'my-alert-type', iconClass: 'test', @@ -44,42 +41,19 @@ describe('alert_form', () => { actionConnectorFields: null, actionParamsFields: null, }; - beforeAll(async () => { - const mockes = coreMock.createSetup(); - const [ - { - chrome, - docLinks, - application: { capabilities }, - }, - ] = await mockes.getStartServices(); - deps = { - chrome, - docLinks, - toastNotifications: mockes.notifications.toasts, - injectedMetadata: mockes.injectedMetadata, - http: mockes.http, - uiSettings: mockes.uiSettings, - dataPlugin: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - capabilities: { - ...capabilities, - siem: { - 'alerting:show': true, - 'alerting:save': true, - 'alerting:delete': false, - }, - }, - setBreadcrumbs: jest.fn(), - actionTypeRegistry: actionTypeRegistry as any, - alertTypeRegistry: alertTypeRegistry as any, - }; - }); describe('alert_form create alert', () => { let wrapper: ReactWrapper<any>; - beforeAll(async () => { + async function setup() { + const mockes = coreMock.createSetup(); + deps = { + toastNotifications: mockes.notifications.toasts, + http: mockes.http, + uiSettings: mockes.uiSettings, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: alertTypeRegistry as any, + }; alertTypeRegistry.list.mockReturnValue([alertType]); alertTypeRegistry.has.mockReturnValue(true); actionTypeRegistry.list.mockReturnValue([actionType]); @@ -99,47 +73,49 @@ describe('alert_form', () => { mutedInstanceIds: [], } as unknown) as Alert; + wrapper = mountWithIntl( + <AlertsContextProvider + value={{ + reloadAlerts: () => { + return new Promise<void>(() => {}); + }, + http: deps!.http, + actionTypeRegistry: deps!.actionTypeRegistry, + alertTypeRegistry: deps!.alertTypeRegistry, + toastNotifications: deps!.toastNotifications, + uiSettings: deps!.uiSettings, + }} + > + <AlertForm + alert={initialAlert} + dispatch={() => {}} + errors={{ name: [] }} + serverError={null} + /> + </AlertsContextProvider> + ); + await act(async () => { - if (deps) { - wrapper = mountWithIntl( - <AlertsContextProvider - value={{ - reloadAlerts: () => { - return new Promise<void>(() => {}); - }, - http: deps.http, - actionTypeRegistry: deps.actionTypeRegistry, - alertTypeRegistry: deps.alertTypeRegistry, - toastNotifications: deps.toastNotifications, - uiSettings: deps.uiSettings, - }} - > - <AlertForm - alert={initialAlert} - dispatch={() => {}} - errors={{ name: [] }} - serverError={null} - /> - </AlertsContextProvider> - ); - } + await nextTick(); + wrapper.update(); }); + } - await waitForRender(wrapper); - }); - - it('renders alert name', () => { + it('renders alert name', async () => { + await setup(); const alertNameField = wrapper.find('[data-test-subj="alertNameInput"]'); expect(alertNameField.exists()).toBeTruthy(); expect(alertNameField.first().prop('value')).toBe('test'); }); - it('renders registered selected alert type', () => { + it('renders registered selected alert type', async () => { + await setup(); const alertTypeSelectOptions = wrapper.find('[data-test-subj="my-alert-type-SelectOption"]'); expect(alertTypeSelectOptions.exists()).toBeTruthy(); }); - it('renders registered action types', () => { + it('renders registered action types', async () => { + await setup(); const alertTypeSelectOptions = wrapper.find( '[data-test-subj=".server-log-ActionTypeSelectOption"]' ); @@ -150,7 +126,15 @@ describe('alert_form', () => { describe('alert_form edit alert', () => { let wrapper: ReactWrapper<any>; - beforeAll(async () => { + async function setup() { + const mockes = coreMock.createSetup(); + deps = { + toastNotifications: mockes.notifications.toasts, + http: mockes.http, + uiSettings: mockes.uiSettings, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: alertTypeRegistry as any, + }; alertTypeRegistry.list.mockReturnValue([alertType]); alertTypeRegistry.get.mockReturnValue(alertType); alertTypeRegistry.has.mockReturnValue(true); @@ -173,57 +157,45 @@ describe('alert_form', () => { mutedInstanceIds: [], } as unknown) as Alert; + wrapper = mountWithIntl( + <AlertsContextProvider + value={{ + reloadAlerts: () => { + return new Promise<void>(() => {}); + }, + http: deps!.http, + actionTypeRegistry: deps!.actionTypeRegistry, + alertTypeRegistry: deps!.alertTypeRegistry, + toastNotifications: deps!.toastNotifications, + uiSettings: deps!.uiSettings, + }} + > + <AlertForm + alert={initialAlert} + dispatch={() => {}} + errors={{ name: [] }} + serverError={null} + /> + </AlertsContextProvider> + ); + await act(async () => { - if (deps) { - wrapper = mountWithIntl( - <AlertsContextProvider - value={{ - reloadAlerts: () => { - return new Promise<void>(() => {}); - }, - http: deps.http, - actionTypeRegistry: deps.actionTypeRegistry, - alertTypeRegistry: deps.alertTypeRegistry, - toastNotifications: deps.toastNotifications, - uiSettings: deps.uiSettings, - }} - > - <AlertForm - alert={initialAlert} - dispatch={() => {}} - errors={{ name: [] }} - serverError={null} - /> - </AlertsContextProvider> - ); - } + await nextTick(); + wrapper.update(); }); + } - await waitForRender(wrapper); - }); - - it('renders alert name', () => { + it('renders alert name', async () => { + await setup(); const alertNameField = wrapper.find('[data-test-subj="alertNameInput"]'); expect(alertNameField.exists()).toBeTruthy(); expect(alertNameField.first().prop('value')).toBe('test'); }); - it('renders registered selected alert type', () => { + it('renders registered selected alert type', async () => { + await setup(); const alertTypeSelectOptions = wrapper.find('[data-test-subj="selectedAlertTypeTitle"]'); expect(alertTypeSelectOptions.exists()).toBeTruthy(); }); - - it('renders registered action types', () => { - const actionTypeSelectOptions = wrapper.find( - '[data-test-subj="my-action-type-ActionTypeSelectOption"]' - ); - expect(actionTypeSelectOptions.exists()).toBeTruthy(); - }); }); - - async function waitForRender(wrapper: ReactWrapper<any, any>) { - await Promise.resolve(); - await Promise.resolve(); - wrapper.update(); - } }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index b875fae75c7df..190f14f0428d8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -7,7 +7,6 @@ import React, { Fragment, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiButton, EuiFlexGroup, EuiFlexItem, EuiIcon, @@ -22,29 +21,15 @@ import { EuiFieldNumber, EuiSelect, EuiIconTip, - EuiAccordion, EuiButtonIcon, - EuiEmptyPrompt, - EuiButtonEmpty, EuiHorizontalRule, } from '@elastic/eui'; import { loadAlertTypes } from '../../lib/alert_api'; -import { loadActionTypes, loadAllActions } from '../../lib/action_connector_api'; import { AlertReducerAction } from './alert_reducer'; -import { - AlertTypeModel, - Alert, - IErrorObject, - ActionTypeModel, - AlertAction, - ActionTypeIndex, - ActionConnector, - AlertTypeIndex, -} from '../../../types'; -import { SectionLoading } from '../../components/section_loading'; -import { ConnectorAddModal } from '../action_connector_form/connector_add_modal'; +import { AlertTypeModel, Alert, IErrorObject, AlertAction, AlertTypeIndex } from '../../../types'; import { getTimeOptions } from '../../../common/lib/get_time_options'; import { useAlertsContext } from '../../context/alerts_context'; +import { ActionForm } from '../action_connector_form/action_form'; export function validateBaseProperties(alertObject: Alert) { const validationResult = { errors: {} }; @@ -89,11 +74,6 @@ interface AlertFormProps { canChangeTrigger?: boolean; // to hide Change trigger button } -interface ActiveActionConnectorState { - actionTypeId: string; - index: number; -} - export const AlertForm = ({ alert, canChangeTrigger = true, @@ -108,9 +88,6 @@ export const AlertForm = ({ alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null ); - const [addModalVisible, setAddModalVisibility] = useState<boolean>(false); - const [isLoadingActionTypes, setIsLoadingActionTypes] = useState<boolean>(false); - const [actionTypesIndex, setActionTypesIndex] = useState<ActionTypeIndex | undefined>(undefined); const [alertTypesIndex, setAlertTypesIndex] = useState<AlertTypeIndex | undefined>(undefined); const [alertInterval, setAlertInterval] = useState<number>( alert.schedule.interval ? parseInt(alert.schedule.interval.replace(/^[A-Za-z]+$/, ''), 0) : 1 @@ -124,39 +101,7 @@ export const AlertForm = ({ const [alertThrottleUnit, setAlertThrottleUnit] = useState<string>( alert.throttle ? alert.throttle.replace((alertThrottle ?? '').toString(), '') : 'm' ); - const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState<boolean>(true); - const [connectors, setConnectors] = useState<ActionConnector[]>([]); const [defaultActionGroupId, setDefaultActionGroupId] = useState<string | undefined>(undefined); - const [activeActionItem, setActiveActionItem] = useState<ActiveActionConnectorState | undefined>( - undefined - ); - - // load action types - useEffect(() => { - (async () => { - try { - setIsLoadingActionTypes(true); - const actionTypes = await loadActionTypes({ http }); - const index: ActionTypeIndex = {}; - for (const actionTypeItem of actionTypes) { - index[actionTypeItem.id] = actionTypeItem; - } - setActionTypesIndex(index); - } catch (e) { - if (toastNotifications) { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionTypesMessage', - { defaultMessage: 'Unable to load action types' } - ), - }); - } - } finally { - setIsLoadingActionTypes(false); - } - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // load alert types useEffect(() => { @@ -172,24 +117,17 @@ export const AlertForm = ({ } setAlertTypesIndex(index); } catch (e) { - if (toastNotifications) { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.alertForm.unableToLoadAlertTypesMessage', - { defaultMessage: 'Unable to load alert types' } - ), - }); - } + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.unableToLoadAlertTypesMessage', + { defaultMessage: 'Unable to load alert types' } + ), + }); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { - loadConnectors(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const setAlertProperty = (key: string, value: any) => { dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); }; @@ -202,93 +140,20 @@ export const AlertForm = ({ dispatch({ command: { type: 'setScheduleProperty' }, payload: { key, value } }); }; - const setActionParamsProperty = (key: string, value: any, index: number) => { - dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } }); - }; - const setActionProperty = (key: string, value: any, index: number) => { dispatch({ command: { type: 'setAlertActionProperty' }, payload: { key, value, index } }); }; - const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; - - async function loadConnectors() { - try { - const actionsResponse = await loadAllActions({ http }); - setConnectors(actionsResponse.data); - } catch (e) { - if (toastNotifications) { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionsMessage', - { - defaultMessage: 'Unable to load connectors', - } - ), - }); - } - } - } + const setActionParamsProperty = (key: string, value: any, index: number) => { + dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } }); + }; - const actionsErrors = alert.actions.reduce( - (acc: Record<string, { errors: IErrorObject }>, alertAction: AlertAction) => { - const actionType = actionTypeRegistry.get(alertAction.actionTypeId); - if (!actionType) { - return { ...acc }; - } - const actionValidationErrors = actionType.validateParams(alertAction.params); - return { ...acc, [alertAction.id]: actionValidationErrors }; - }, - {} - ); + const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; const AlertParamsExpressionComponent = alertTypeModel ? alertTypeModel.alertParamsExpression : null; - function addActionType(actionTypeModel: ActionTypeModel) { - if (!defaultActionGroupId) { - toastNotifications!.addDanger({ - title: i18n.translate('xpack.triggersActionsUI.sections.alertForm.unableToAddAction', { - defaultMessage: 'Unable to add action, because default action group is not defined', - }), - }); - return; - } - setIsAddActionPanelOpen(false); - const actionTypeConnectors = connectors.filter( - field => field.actionTypeId === actionTypeModel.id - ); - let freeConnectors; - if (actionTypeConnectors.length > 0) { - // Should we allow adding multiple actions to the same connector under the alert? - freeConnectors = actionTypeConnectors.filter( - (actionConnector: ActionConnector) => - !alert.actions.find((actionItem: AlertAction) => actionItem.id === actionConnector.id) - ); - if (freeConnectors.length > 0) { - alert.actions.push({ - id: '', - actionTypeId: actionTypeModel.id, - group: defaultActionGroupId, - params: {}, - }); - setActionProperty('id', freeConnectors[0].id, alert.actions.length - 1); - } - } - if (actionTypeConnectors.length === 0 || !freeConnectors || freeConnectors.length === 0) { - // if no connectors exists or all connectors is already assigned an action under current alert - // set actionType as id to be able to create new connector within the alert form - alert.actions.push({ - id: '', - actionTypeId: actionTypeModel.id, - group: defaultActionGroupId, - params: {}, - }); - setActionProperty('id', alert.actions.length, alert.actions.length - 1); - } - } - const alertTypeNodes = alertTypeRegistry.list().map(function(item, index) { return ( <EuiKeyPadMenuItem @@ -308,293 +173,6 @@ export const AlertForm = ({ ); }); - const actionTypeNodes = actionTypeRegistry.list().map(function(item, index) { - return ( - <EuiKeyPadMenuItem - key={index} - data-test-subj={`${item.id}-ActionTypeSelectOption`} - label={actionTypesIndex ? actionTypesIndex[item.id].name : item.id} - onClick={() => addActionType(item)} - > - <EuiIcon size="xl" type={item.iconClass} /> - </EuiKeyPadMenuItem> - ); - }); - - const getSelectedOptions = (actionItemId: string) => { - const val = connectors.find(connector => connector.id === actionItemId); - if (!val) { - return []; - } - return [ - { - label: val.name, - value: val.name, - id: actionItemId, - }, - ]; - }; - - const getActionTypeForm = ( - actionItem: AlertAction, - actionConnector: ActionConnector, - index: number - ) => { - const optionsList = connectors - .filter( - connectorItem => - connectorItem.actionTypeId === actionItem.actionTypeId && - (connectorItem.id === actionItem.id || - !alert.actions.find( - (existingAction: AlertAction) => - existingAction.id === connectorItem.id && existingAction.group === actionItem.group - )) - ) - .map(({ name, id }) => ({ - label: name, - key: id, - id, - })); - const actionTypeRegisterd = actionTypeRegistry.get(actionConnector.actionTypeId); - if (!actionTypeRegisterd || actionItem.group !== defaultActionGroupId) return null; - const ParamsFieldsComponent = actionTypeRegisterd.actionParamsFields; - const actionParamsErrors: { errors: IErrorObject } = - Object.keys(actionsErrors).length > 0 ? actionsErrors[actionItem.id] : { errors: {} }; - - return ( - <EuiAccordion - initialIsOpen={true} - key={index} - id={index.toString()} - className="euiAccordionForm" - buttonContentClassName="euiAccordionForm__button" - data-test-subj={`alertActionAccordion-${defaultActionGroupId}`} - buttonContent={ - <EuiFlexGroup gutterSize="s" alignItems="center"> - <EuiFlexItem grow={false}> - <EuiIcon type={actionTypeRegisterd.iconClass} size="m" /> - </EuiFlexItem> - <EuiFlexItem> - <EuiTitle size="s"> - <h5> - <FormattedMessage - defaultMessage="Action: {actionConnectorName}" - id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle" - values={{ - actionConnectorName: actionConnector.name, - }} - /> - </h5> - </EuiTitle> - </EuiFlexItem> - </EuiFlexGroup> - } - extraAction={ - <EuiButtonIcon - iconType="cross" - color="danger" - className="euiAccordionForm__extraAction" - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel', - { - defaultMessage: 'Delete', - } - )} - onClick={() => { - const updatedActions = alert.actions.filter( - (item: AlertAction) => item.id !== actionItem.id - ); - setAlertProperty('actions', updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0 - ); - setActiveActionItem(undefined); - }} - /> - } - paddingSize="l" - > - <EuiFlexGroup component="div"> - <EuiFlexItem> - <EuiFormRow - fullWidth - label={ - <FormattedMessage - id="xpack.triggersActionsUI.sections.alertForm.actionIdLabel" - defaultMessage="{connectorInstance} instance" - values={{ - connectorInstance: actionTypesIndex - ? actionTypesIndex[actionConnector.actionTypeId].name - : actionConnector.actionTypeId, - }} - /> - } - labelAppend={ - <EuiButtonEmpty - size="xs" - onClick={() => { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > - <FormattedMessage - defaultMessage="Add new" - id="xpack.triggersActionsUI.sections.alertForm.addNewConnectorEmptyButton" - /> - </EuiButtonEmpty> - } - > - <EuiComboBox - fullWidth - singleSelection={{ asPlainText: true }} - options={optionsList} - selectedOptions={getSelectedOptions(actionItem.id)} - onChange={selectedOptions => { - setActionProperty('id', selectedOptions[0].id, index); - }} - isClearable={false} - /> - </EuiFormRow> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer size="xl" /> - {ParamsFieldsComponent ? ( - <ParamsFieldsComponent - actionParams={actionItem.params as any} - index={index} - errors={actionParamsErrors.errors} - editAction={setActionParamsProperty} - messageVariables={ - alertTypesIndex && alertTypesIndex[alert.alertTypeId] - ? alertTypesIndex[alert.alertTypeId].actionVariables - : undefined - } - defaultMessage={alertTypeModel?.defaultActionMessage ?? undefined} - /> - ) : null} - </EuiAccordion> - ); - }; - - const getAddConnectorsForm = (actionItem: AlertAction, index: number) => { - const actionTypeName = actionTypesIndex - ? actionTypesIndex[actionItem.actionTypeId].name - : actionItem.actionTypeId; - const actionTypeRegisterd = actionTypeRegistry.get(actionItem.actionTypeId); - if (!actionTypeRegisterd || actionItem.group !== defaultActionGroupId) return null; - return ( - <EuiAccordion - initialIsOpen={true} - key={index} - id={index.toString()} - className="euiAccordionForm" - buttonContentClassName="euiAccordionForm__button" - data-test-subj={`alertActionAccordion-${defaultActionGroupId}`} - buttonContent={ - <EuiFlexGroup gutterSize="s" alignItems="center"> - <EuiFlexItem grow={false}> - <EuiIcon type={actionTypeRegisterd.iconClass} size="m" /> - </EuiFlexItem> - <EuiFlexItem> - <EuiTitle size="s"> - <h5> - <FormattedMessage - defaultMessage="Action: {actionConnectorName}" - id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle" - values={{ - actionConnectorName: actionTypeRegisterd.actionTypeTitle, - }} - /> - </h5> - </EuiTitle> - </EuiFlexItem> - </EuiFlexGroup> - } - extraAction={ - <EuiButtonIcon - iconType="cross" - color="danger" - className="euiAccordionForm__extraAction" - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel', - { - defaultMessage: 'Delete', - } - )} - onClick={() => { - const updatedActions = alert.actions.filter( - (item: AlertAction) => item.id !== actionItem.id - ); - setAlertProperty('actions', updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0 - ); - setActiveActionItem(undefined); - }} - /> - } - paddingSize="l" - > - <EuiEmptyPrompt - title={ - <FormattedMessage - id="xpack.triggersActionsUI.sections.alertForm.emptyConnectorsLabel" - defaultMessage="There are no {actionTypeName} connectors" - values={{ - actionTypeName, - }} - /> - } - actions={[ - <EuiButton - color="primary" - fill - data-test-subj="createActionConnectorButton" - onClick={() => { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > - <FormattedMessage - id="xpack.triggersActionsUI.sections.alertForm.addConnectorButtonLabel" - defaultMessage="Add {actionTypeName} connector" - values={{ - actionTypeName, - }} - /> - </EuiButton>, - ]} - /> - </EuiAccordion> - ); - }; - - const selectedGroupActions = ( - <Fragment> - {alert.actions.map((actionItem: AlertAction, index: number) => { - const actionConnector = connectors.find(field => field.id === actionItem.id); - // connectors doesn't exists - if (!actionConnector) { - return getAddConnectorsForm(actionItem, index); - } - return getActionTypeForm(actionItem, actionConnector, index); - })} - <EuiSpacer size="m" /> - {isAddActionPanelOpen === false ? ( - <EuiButton - iconType="plusInCircle" - data-test-subj="addAlertActionButton" - onClick={() => setIsAddActionPanelOpen(true)} - > - <FormattedMessage - id="xpack.triggersActionsUI.sections.alertForm.addActionButtonLabel" - defaultMessage="Add action" - /> - </EuiButton> - ) : null} - </Fragment> - ); - const alertTypeDetails = ( <Fragment> <EuiHorizontalRule /> @@ -639,31 +217,27 @@ export const AlertForm = ({ /> ) : null} <EuiSpacer size="xl" /> - {selectedGroupActions} - {isAddActionPanelOpen ? ( - <Fragment> - <EuiTitle size="xs"> - <h5 id="alertActionTypeTitle"> - <FormattedMessage - defaultMessage="Actions: Select an action type" - id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeTitle" - /> - </h5> - </EuiTitle> - <EuiSpacer /> - <EuiFlexGroup gutterSize="s" wrap> - {isLoadingActionTypes ? ( - <SectionLoading> - <FormattedMessage - id="xpack.triggersActionsUI.sections.alertForm.loadingActionTypesDescription" - defaultMessage="Loading action types…" - /> - </SectionLoading> - ) : ( - actionTypeNodes - )} - </EuiFlexGroup> - </Fragment> + {defaultActionGroupId ? ( + <ActionForm + actions={alert.actions} + messageVariables={ + alertTypesIndex && alertTypesIndex[alert.alertTypeId] + ? alertTypesIndex[alert.alertTypeId].actionVariables + : undefined + } + defaultActionGroupId={defaultActionGroupId} + setActionIdByIndex={(id: string, index: number) => setActionProperty('id', id, index)} + setAlertProperty={(updatedActions: AlertAction[]) => + setAlertProperty('actions', updatedActions) + } + setActionParamsProperty={(key: string, value: any, index: number) => + setActionParamsProperty(key, value, index) + } + http={http} + actionTypeRegistry={actionTypeRegistry} + defaultActionMessage={alertTypeModel?.defaultActionMessage} + toastNotifications={toastNotifications} + /> ) : null} </Fragment> ); @@ -862,22 +436,6 @@ export const AlertForm = ({ </EuiFlexGroup> </Fragment> )} - {actionTypesIndex && activeActionItem ? ( - <ConnectorAddModal - key={activeActionItem.index} - actionType={actionTypesIndex[activeActionItem.actionTypeId]} - addModalVisible={addModalVisible} - setAddModalVisibility={setAddModalVisibility} - postSaveEventHandler={(savedAction: ActionConnector) => { - connectors.push(savedAction); - setActionProperty('id', savedAction.id, activeActionItem.index); - }} - actionTypeRegistry={actionTypeRegistry} - alertTypeRegistry={alertTypeRegistry} - http={http} - toastNotifications={toastNotifications} - /> - ) : null} </EuiForm> ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/of.test.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/of.test.tsx index 2e674f4fb47b1..4d0017ce5c8e6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/of.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/of.test.tsx @@ -23,6 +23,7 @@ describe('of expression', () => { expect(wrapper.find('[data-test-subj="availablefieldsOptionsComboBox"]')) .toMatchInlineSnapshot(` <EuiComboBox + async={false} compressed={false} data-test-subj="availablefieldsOptionsComboBox" fullWidth={true} @@ -75,35 +76,36 @@ describe('of expression', () => { ); expect(wrapper.find('[data-test-subj="availablefieldsOptionsComboBox"]')) .toMatchInlineSnapshot(` - <EuiComboBox - compressed={false} - data-test-subj="availablefieldsOptionsComboBox" - fullWidth={true} - isClearable={true} - isInvalid={false} - noSuggestions={false} - onChange={[Function]} - options={ - Array [ - Object { - "label": "test2", - }, - ] - } - placeholder="Select a field" - selectedOptions={ - Array [ + <EuiComboBox + async={false} + compressed={false} + data-test-subj="availablefieldsOptionsComboBox" + fullWidth={true} + isClearable={true} + isInvalid={false} + noSuggestions={false} + onChange={[Function]} + options={ + Array [ + Object { + "label": "test2", + }, + ] + } + placeholder="Select a field" + selectedOptions={ + Array [ + Object { + "label": "test", + }, + ] + } + singleSelection={ Object { - "label": "test", - }, - ] - } - singleSelection={ - Object { - "asPlainText": true, + "asPlainText": true, + } } - } - /> + /> `); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 0be0a919112f8..fbffd5c2f999d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -8,7 +8,14 @@ import { PluginInitializerContext } from 'src/core/public'; import { Plugin } from './plugin'; export { AlertsContextProvider } from './application/context/alerts_context'; +export { ActionsConnectorsContextProvider } from './application/context/actions_connectors_context'; export { AlertAdd } from './application/sections/alert_form'; +export { ActionForm } from './application/sections/action_connector_form'; +export { AlertAction, Alert } from './types'; +export { + ConnectorAddFlyout, + ConnectorEditFlyout, +} from './application/sections/action_connector_form'; export function plugin(ctx: PluginInitializerContext) { return new Plugin(ctx); diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index 459197d80d7aa..9f975cba3c0d1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -22,7 +22,10 @@ export interface TriggersAndActionsUIPublicPluginSetup { alertTypeRegistry: TypeRegistry<AlertTypeModel>; } -export type Start = void; +export interface TriggersAndActionsUIPublicPluginStart { + actionTypeRegistry: TypeRegistry<ActionTypeModel>; + alertTypeRegistry: TypeRegistry<AlertTypeModel>; +} interface PluginsStart { data: DataPublicPluginStart; @@ -30,7 +33,9 @@ interface PluginsStart { management: ManagementStart; } -export class Plugin implements CorePlugin<TriggersAndActionsUIPublicPluginSetup, Start> { +export class Plugin + implements + CorePlugin<TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart> { private actionTypeRegistry: TypeRegistry<ActionTypeModel>; private alertTypeRegistry: TypeRegistry<AlertTypeModel>; @@ -57,44 +62,46 @@ export class Plugin implements CorePlugin<TriggersAndActionsUIPublicPluginSetup, }; } - public start(core: CoreStart, plugins: PluginsStart) { + public start(core: CoreStart, plugins: PluginsStart): TriggersAndActionsUIPublicPluginStart { const { capabilities } = core.application; const canShowActions = hasShowActionsCapability(capabilities); const canShowAlerts = hasShowAlertsCapability(capabilities); // Don't register routes when user doesn't have access to the application - if (!canShowActions && !canShowAlerts) { - return; + if (canShowActions || canShowAlerts) { + plugins.management.sections.getSection('kibana')!.registerApp({ + id: 'triggersActions', + title: i18n.translate('xpack.triggersActionsUI.managementSection.displayName', { + defaultMessage: 'Alerts and Actions', + }), + order: 7, + mount: params => { + boot({ + dataPlugin: plugins.data, + charts: plugins.charts, + element: params.element, + toastNotifications: core.notifications.toasts, + injectedMetadata: core.injectedMetadata, + http: core.http, + uiSettings: core.uiSettings, + docLinks: core.docLinks, + chrome: core.chrome, + savedObjects: core.savedObjects.client, + I18nContext: core.i18n.Context, + capabilities: core.application.capabilities, + setBreadcrumbs: params.setBreadcrumbs, + actionTypeRegistry: this.actionTypeRegistry, + alertTypeRegistry: this.alertTypeRegistry, + }); + return () => {}; + }, + }); } - - plugins.management.sections.getSection('kibana')!.registerApp({ - id: 'triggersActions', - title: i18n.translate('xpack.triggersActionsUI.managementSection.displayName', { - defaultMessage: 'Alerts and Actions', - }), - order: 7, - mount: params => { - boot({ - dataPlugin: plugins.data, - charts: plugins.charts, - element: params.element, - toastNotifications: core.notifications.toasts, - injectedMetadata: core.injectedMetadata, - http: core.http, - uiSettings: core.uiSettings, - docLinks: core.docLinks, - chrome: core.chrome, - savedObjects: core.savedObjects.client, - I18nContext: core.i18n.Context, - capabilities: core.application.capabilities, - setBreadcrumbs: params.setBreadcrumbs, - actionTypeRegistry: this.actionTypeRegistry, - alertTypeRegistry: this.alertTypeRegistry, - }); - return () => {}; - }, - }); + return { + actionTypeRegistry: this.actionTypeRegistry, + alertTypeRegistry: this.alertTypeRegistry, + }; } public stop() {} diff --git a/x-pack/plugins/upgrade_assistant/common/types.ts b/x-pack/plugins/upgrade_assistant/common/types.ts index e3a06093ddc3d..e1081a9c2cbd6 100644 --- a/x-pack/plugins/upgrade_assistant/common/types.ts +++ b/x-pack/plugins/upgrade_assistant/common/types.ts @@ -29,13 +29,23 @@ export enum ReindexStatus { export const REINDEX_OP_TYPE = 'upgrade-assistant-reindex-operation'; +export interface QueueSettings extends SavedObjectAttributes { + queuedAt: number; +} + export interface ReindexOptions extends SavedObjectAttributes { /** * Whether to treat the index as if it were closed. This instructs the * reindex strategy to first open the index, perform reindexing and * then close the index again. */ - openAndClose: boolean; + openAndClose?: boolean; + + /** + * Set this key to configure a reindex operation as part of a + * batch to be run in series. + */ + queueSettings?: QueueSettings; } export interface ReindexOperation extends SavedObjectAttributes { diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.ts index e7636eea66479..6182a82f6f1bd 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.ts @@ -90,9 +90,9 @@ export const esVersionCheck = async ( } }; -export const versionCheckHandlerWrapper = (handler: RequestHandler<any, any, any>) => async ( +export const versionCheckHandlerWrapper = <P, Q, B>(handler: RequestHandler<P, Q, B>) => async ( ctx: RequestHandlerContext, - request: KibanaRequest, + request: KibanaRequest<P, Q, B>, response: KibanaResponseFactory ) => { const errorResponse = await esVersionCheck(ctx, response); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts index b7bc197fbd162..59922abd3e635 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts @@ -12,6 +12,7 @@ import { ReindexTaskFailed, ReindexAlreadyInProgress, MultipleReindexJobsFound, + ReindexCannotBeCancelled, } from './error_symbols'; export class ReindexError extends Error { @@ -32,4 +33,5 @@ export const error = { reindexTaskCannotBeDeleted: createErrorFactory(ReindexTaskCannotBeDeleted), reindexAlreadyInProgress: createErrorFactory(ReindexAlreadyInProgress), multipleReindexJobsFound: createErrorFactory(MultipleReindexJobsFound), + reindexCannotBeCancelled: createErrorFactory(ReindexCannotBeCancelled), }; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts index 9e49d280d1be2..d5e8d643f4595 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts @@ -11,5 +11,6 @@ export const CannotCreateIndex = Symbol('CannotCreateIndex'); export const ReindexTaskFailed = Symbol('ReindexTaskFailed'); export const ReindexTaskCannotBeDeleted = Symbol('ReindexTaskCannotBeDeleted'); export const ReindexAlreadyInProgress = Symbol('ReindexAlreadyInProgress'); +export const ReindexCannotBeCancelled = Symbol('ReindexCannotBeCancelled'); export const MultipleReindexJobsFound = Symbol('MultipleReindexJobsFound'); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts new file mode 100644 index 0000000000000..dbed7de13f010 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { flow } from 'fp-ts/lib/function'; +import { ReindexSavedObject } from '../../../common/types'; + +export interface SortedReindexSavedObjects { + /** + * Reindex objects sorted into this array represent Elasticsearch reindex tasks that + * have no inherent order and are considered to be processed in parallel. + */ + parallel: ReindexSavedObject[]; + + /** + * Reindex objects sorted into this array represent Elasticsearch reindex tasks that + * are consistently ordered (see {@link orderQueuedReindexOperations}) and should be + * processed in order. + */ + queue: ReindexSavedObject[]; +} + +const sortReindexOperations = (ops: ReindexSavedObject[]): SortedReindexSavedObjects => { + const parallel: ReindexSavedObject[] = []; + const queue: ReindexSavedObject[] = []; + for (const op of ops) { + if (op.attributes.reindexOptions?.queueSettings) { + queue.push(op); + } else { + parallel.push(op); + } + } + + return { + parallel, + queue, + }; +}; +const orderQueuedReindexOperations = ({ + parallel, + queue, +}: SortedReindexSavedObjects): SortedReindexSavedObjects => ({ + parallel, + // Sort asc + queue: queue.sort( + (a, b) => + a.attributes.reindexOptions!.queueSettings!.queuedAt - + b.attributes.reindexOptions!.queueSettings!.queuedAt + ), +}); + +export const sortAndOrderReindexOperations = flow( + sortReindexOperations, + orderQueuedReindexOperations +); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts index c16cfbd75d3fe..81c8f2563a66d 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts @@ -11,10 +11,10 @@ import { IndexGroup, REINDEX_OP_TYPE, ReindexOperation, + ReindexOptions, ReindexSavedObject, ReindexStatus, ReindexStep, - ReindexOptions, } from '../../../common/types'; import { generateNewIndexName } from './index_settings'; import { FlatSettings } from './types'; @@ -152,11 +152,10 @@ export const reindexActionsFactory = ( // ----- Public interface return { - async createReindexOp(indexName: string, options = { openAndClose: false }) { + async createReindexOp(indexName: string, opts?: ReindexOptions) { return client.create<ReindexOperation>(REINDEX_OP_TYPE, { indexName, newIndexName: generateNewIndexName(indexName), - reindexOptions: options, status: ReindexStatus.inProgress, lastCompletedStep: ReindexStep.created, locked: null, @@ -164,6 +163,7 @@ export const reindexActionsFactory = ( reindexTaskPercComplete: null, errorMessage: null, runningReindexCount: null, + reindexOptions: opts, }); }, diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts index 743ed837d58b5..8c9e271c5ea68 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts @@ -8,6 +8,7 @@ import { first } from 'rxjs/operators'; import { IndexGroup, + ReindexOptions, ReindexSavedObject, ReindexStatus, ReindexStep, @@ -86,8 +87,9 @@ export interface ReindexService { /** * Resumes the paused reindex operation for a given index. * @param indexName + * @param opts As with {@link createReindexOperation} we support this setting. */ - resumeReindexOperation(indexName: string): Promise<ReindexSavedObject>; + resumeReindexOperation(indexName: string, opts?: ReindexOptions): Promise<ReindexSavedObject>; /** * Cancel an in-progress reindex operation for a given index. Only allowed when the @@ -316,7 +318,7 @@ export const reindexServiceFactory = ( const startReindexing = async (reindexOp: ReindexSavedObject) => { const { indexName, reindexOptions } = reindexOp.attributes; - if (reindexOptions?.openAndClose) { + if (reindexOptions?.openAndClose === true) { await callAsUser('indices.open', { index: indexName }); } @@ -424,7 +426,7 @@ export const reindexServiceFactory = ( throw error.cannotCreateIndex(`Index aliases could not be created.`); } - if (reindexOptions?.openAndClose) { + if (reindexOptions?.openAndClose === true) { await callAsUser('indices.close', { index: indexName }); } @@ -528,7 +530,7 @@ export const reindexServiceFactory = ( } }, - async createReindexOperation(indexName: string, opts = { openAndClose: false }) { + async createReindexOperation(indexName: string, opts?: ReindexOptions) { const indexExists = await callAsUser('indices.exists', { index: indexName }); if (!indexExists) { throw error.indexNotFound(`Index ${indexName} does not exist in this cluster.`); @@ -638,7 +640,7 @@ export const reindexServiceFactory = ( }); }, - async resumeReindexOperation(indexName: string) { + async resumeReindexOperation(indexName: string, opts?: ReindexOptions) { const reindexOp = await this.findReindexOperation(indexName); if (!reindexOp) { @@ -653,7 +655,10 @@ export const reindexServiceFactory = ( throw new Error(`Reindex operation must be paused in order to be resumed.`); } - return actions.updateReindexOp(op, { status: ReindexStatus.inProgress }); + return actions.updateReindexOp(op, { + status: ReindexStatus.inProgress, + reindexOptions: opts ?? reindexOp.attributes.reindexOptions, + }); }); }, @@ -661,11 +666,13 @@ export const reindexServiceFactory = ( const reindexOp = await this.findReindexOperation(indexName); if (!reindexOp) { - throw new Error(`No reindex operation found for index ${indexName}`); + throw error.indexNotFound(`No reindex operation found for index ${indexName}`); } else if (reindexOp.attributes.status !== ReindexStatus.inProgress) { - throw new Error(`Reindex operation is not in progress`); + throw error.reindexCannotBeCancelled(`Reindex operation is not in progress`); } else if (reindexOp.attributes.lastCompletedStep !== ReindexStep.reindexStarted) { - throw new Error(`Reindex operation is not current waiting for reindex task to complete`); + throw error.reindexCannotBeCancelled( + `Reindex operation is not currently waiting for reindex task to complete` + ); } const resp = await callAsUser('tasks.cancel', { @@ -673,7 +680,7 @@ export const reindexServiceFactory = ( }); if (resp.node_failures && resp.node_failures.length > 0) { - throw new Error(`Could not cancel reindex.`); + throw error.reindexCannotBeCancelled(`Could not cancel reindex.`); } return reindexOp; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts index bad6db62efe41..482b9f280ad7e 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts @@ -5,12 +5,12 @@ */ import { IClusterClient, Logger, SavedObjectsClientContract, FakeRequest } from 'src/core/server'; import moment from 'moment'; - import { ReindexSavedObject, ReindexStatus } from '../../../common/types'; import { CredentialStore } from './credential_store'; import { reindexActionsFactory } from './reindex_actions'; import { ReindexService, reindexServiceFactory } from './reindex_service'; import { LicensingPluginSetup } from '../../../../licensing/server'; +import { sortAndOrderReindexOperations } from './op_utils'; const POLL_INTERVAL = 30000; // If no nodes have been able to update this index in 2 minutes (due to missing credentials), set to paused. @@ -105,15 +105,17 @@ export class ReindexWorker { private startUpdateOperationLoop = async () => { this.updateOperationLoopRunning = true; - while (this.inProgressOps.length > 0) { - this.log.debug(`Updating ${this.inProgressOps.length} reindex operations`); + try { + while (this.inProgressOps.length > 0) { + this.log.debug(`Updating ${this.inProgressOps.length} reindex operations`); - // Push each operation through the state machine and refresh. - await Promise.all(this.inProgressOps.map(this.processNextStep)); - await this.refresh(); + // Push each operation through the state machine and refresh. + await Promise.all(this.inProgressOps.map(this.processNextStep)); + await this.refresh(); + } + } finally { + this.updateOperationLoopRunning = false; } - - this.updateOperationLoopRunning = false; }; private pollForOperations = async () => { @@ -126,14 +128,28 @@ export class ReindexWorker { } }; - private refresh = async () => { + private updateInProgressOps = async () => { try { - this.inProgressOps = await this.reindexService.findAllByStatus(ReindexStatus.inProgress); + const inProgressOps = await this.reindexService.findAllByStatus(ReindexStatus.inProgress); + const { parallel, queue } = sortAndOrderReindexOperations(inProgressOps); + + const [firstOpInQueue] = queue; + + if (firstOpInQueue) { + this.log.debug( + `Queue detected; current length ${queue.length}, current item ReindexOperation(id: ${firstOpInQueue.id}, indexName: ${firstOpInQueue.attributes.indexName})` + ); + } + + this.inProgressOps = parallel.concat(firstOpInQueue ? [firstOpInQueue] : []); } catch (e) { - this.log.debug(`Could not fetch reindex operations from Elasticsearch`); + this.log.debug(`Could not fetch reindex operations from Elasticsearch, ${e.message}`); this.inProgressOps = []; } + }; + private refresh = async () => { + await this.updateInProgressOps(); // If there are operations in progress and we're not already updating operations, kick off the update loop if (!this.updateOperationLoopRunning) { this.startUpdateOperationLoop(); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/index.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/index.ts new file mode 100644 index 0000000000000..9f1d3e4021c3f --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { createReindexWorker, registerReindexIndicesRoutes } from './reindex_indices'; diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts new file mode 100644 index 0000000000000..dfacd587d7524 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { IScopedClusterClient, Logger, SavedObjectsClientContract } from 'kibana/server'; + +import { LicensingPluginSetup } from '../../../../licensing/server'; + +import { ReindexOperation, ReindexOptions, ReindexStatus } from '../../../common/types'; + +import { reindexActionsFactory } from '../../lib/reindexing/reindex_actions'; +import { reindexServiceFactory } from '../../lib/reindexing'; +import { CredentialStore } from '../../lib/reindexing/credential_store'; +import { error } from '../../lib/reindexing/error'; + +interface ReindexHandlerArgs { + savedObjects: SavedObjectsClientContract; + dataClient: IScopedClusterClient; + indexName: string; + log: Logger; + licensing: LicensingPluginSetup; + headers: Record<string, any>; + credentialStore: CredentialStore; + reindexOptions?: { + openAndClose?: boolean; + enqueue?: boolean; + }; +} + +export const reindexHandler = async ({ + credentialStore, + dataClient, + headers, + indexName, + licensing, + log, + savedObjects, + reindexOptions, +}: ReindexHandlerArgs): Promise<ReindexOperation> => { + const callAsCurrentUser = dataClient.callAsCurrentUser.bind(dataClient); + const reindexActions = reindexActionsFactory(savedObjects, callAsCurrentUser); + const reindexService = reindexServiceFactory(callAsCurrentUser, reindexActions, log, licensing); + + if (!(await reindexService.hasRequiredPrivileges(indexName))) { + throw error.accessForbidden( + i18n.translate('xpack.upgradeAssistant.reindex.reindexPrivilegesErrorBatch', { + defaultMessage: `You do not have adequate privileges to reindex "{indexName}".`, + values: { indexName }, + }) + ); + } + + const existingOp = await reindexService.findReindexOperation(indexName); + + const opts: ReindexOptions | undefined = reindexOptions + ? { + ...reindexOptions, + queueSettings: reindexOptions.enqueue ? { queuedAt: Date.now() } : undefined, + } + : undefined; + + // If the reindexOp already exists and it's paused, resume it. Otherwise create a new one. + const reindexOp = + existingOp && existingOp.attributes.status === ReindexStatus.paused + ? await reindexService.resumeReindexOperation(indexName, opts) + : await reindexService.createReindexOperation(indexName, opts); + + // Add users credentials for the worker to use + credentialStore.set(reindexOp, headers); + + return reindexOp.attributes; +}; diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts similarity index 71% rename from x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.test.ts rename to x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts index 294ade4d0eeb6..e517195d4db21 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts @@ -5,9 +5,9 @@ */ import { kibanaResponseFactory } from 'src/core/server'; -import { licensingMock } from '../../../licensing/server/mocks'; -import { createMockRouter, MockRouter, routeHandlerContextMock } from './__mocks__/routes.mock'; -import { createRequestMock } from './__mocks__/request.mock'; +import { licensingMock } from '../../../../licensing/server/mocks'; +import { createMockRouter, MockRouter, routeHandlerContextMock } from '../__mocks__/routes.mock'; +import { createRequestMock } from '../__mocks__/request.mock'; const mockReindexService = { hasRequiredPrivileges: jest.fn(), @@ -21,18 +21,23 @@ const mockReindexService = { cancelReindexing: jest.fn(), }; -jest.mock('../lib/es_version_precheck', () => ({ +jest.mock('../../lib/es_version_precheck', () => ({ versionCheckHandlerWrapper: (a: any) => a, })); -jest.mock('../lib/reindexing', () => { +jest.mock('../../lib/reindexing', () => { return { reindexServiceFactory: () => mockReindexService, }; }); -import { IndexGroup, ReindexSavedObject, ReindexStatus, ReindexWarning } from '../../common/types'; -import { credentialStoreFactory } from '../lib/reindexing/credential_store'; +import { + IndexGroup, + ReindexSavedObject, + ReindexStatus, + ReindexWarning, +} from '../../../common/types'; +import { credentialStoreFactory } from '../../lib/reindexing/credential_store'; import { registerReindexIndicesRoutes } from './reindex_indices'; /** @@ -76,7 +81,7 @@ describe('reindex API', () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); describe('GET /api/upgrade_assistant/reindex/{indexName}', () => { @@ -232,7 +237,7 @@ describe('reindex API', () => { kibanaResponseFactory ); // It called resume correctly - expect(mockReindexService.resumeReindexOperation).toHaveBeenCalledWith('theIndex'); + expect(mockReindexService.resumeReindexOperation).toHaveBeenCalledWith('theIndex', undefined); expect(mockReindexService.createReindexOperation).not.toHaveBeenCalled(); // It returned the right results @@ -260,6 +265,111 @@ describe('reindex API', () => { }); }); + describe('POST /api/upgrade_assistant/reindex/batch', () => { + const queueSettingsArg = { + queueSettings: { queuedAt: expect.any(Number) }, + }; + it('creates a collection of index operations', async () => { + mockReindexService.createReindexOperation + .mockResolvedValueOnce({ + attributes: { indexName: 'theIndex1' }, + }) + .mockResolvedValueOnce({ + attributes: { indexName: 'theIndex2' }, + }) + .mockResolvedValueOnce({ + attributes: { indexName: 'theIndex3' }, + }); + + const resp = await routeDependencies.router.getHandler({ + method: 'post', + pathPattern: '/api/upgrade_assistant/reindex/batch', + })( + routeHandlerContextMock, + createRequestMock({ body: { indexNames: ['theIndex1', 'theIndex2', 'theIndex3'] } }), + kibanaResponseFactory + ); + + // It called create correctly + expect(mockReindexService.createReindexOperation).toHaveBeenNthCalledWith( + 1, + 'theIndex1', + queueSettingsArg + ); + expect(mockReindexService.createReindexOperation).toHaveBeenNthCalledWith( + 2, + 'theIndex2', + queueSettingsArg + ); + expect(mockReindexService.createReindexOperation).toHaveBeenNthCalledWith( + 3, + 'theIndex3', + queueSettingsArg + ); + + // It returned the right results + expect(resp.status).toEqual(200); + const data = resp.payload; + expect(data).toEqual({ + errors: [], + enqueued: [ + { indexName: 'theIndex1' }, + { indexName: 'theIndex2' }, + { indexName: 'theIndex3' }, + ], + }); + }); + + it('gracefully handles partial successes', async () => { + mockReindexService.createReindexOperation + .mockResolvedValueOnce({ + attributes: { indexName: 'theIndex1' }, + }) + .mockRejectedValueOnce(new Error('oops!')); + + mockReindexService.hasRequiredPrivileges + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + const resp = await routeDependencies.router.getHandler({ + method: 'post', + pathPattern: '/api/upgrade_assistant/reindex/batch', + })( + routeHandlerContextMock, + createRequestMock({ body: { indexNames: ['theIndex1', 'theIndex2', 'theIndex3'] } }), + kibanaResponseFactory + ); + + // It called create correctly + expect(mockReindexService.createReindexOperation).toHaveBeenCalledTimes(2); + expect(mockReindexService.createReindexOperation).toHaveBeenNthCalledWith( + 1, + 'theIndex1', + queueSettingsArg + ); + expect(mockReindexService.createReindexOperation).toHaveBeenNthCalledWith( + 2, + 'theIndex3', + queueSettingsArg + ); + + // It returned the right results + expect(resp.status).toEqual(200); + const data = resp.payload; + expect(data).toEqual({ + errors: [ + { + indexName: 'theIndex2', + message: 'You do not have adequate privileges to reindex "theIndex2".', + }, + { indexName: 'theIndex3', message: 'oops!' }, + ], + enqueued: [{ indexName: 'theIndex1' }], + }); + }); + }); + describe('POST /api/upgrade_assistant/reindex/{indexName}/cancel', () => { it('returns a 501', async () => { mockReindexService.cancelReindexing.mockResolvedValueOnce({}); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts similarity index 62% rename from x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.ts rename to x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts index 05211275a5aa2..041510165df5e 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts @@ -3,31 +3,38 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { schema } from '@kbn/config-schema'; import { - Logger, ElasticsearchServiceSetup, - SavedObjectsClient, kibanaResponseFactory, -} from '../../../../../src/core/server'; -import { ReindexStatus } from '../../common/types'; -import { versionCheckHandlerWrapper } from '../lib/es_version_precheck'; -import { reindexServiceFactory, ReindexWorker } from '../lib/reindexing'; -import { CredentialStore } from '../lib/reindexing/credential_store'; -import { reindexActionsFactory } from '../lib/reindexing/reindex_actions'; -import { RouteDependencies } from '../types'; -import { LicensingPluginSetup } from '../../../licensing/server'; -import { ReindexError } from '../lib/reindexing/error'; + Logger, + SavedObjectsClient, +} from '../../../../../../src/core/server'; + +import { LicensingPluginSetup } from '../../../../licensing/server'; + +import { ReindexStatus } from '../../../common/types'; + +import { versionCheckHandlerWrapper } from '../../lib/es_version_precheck'; +import { reindexServiceFactory, ReindexWorker } from '../../lib/reindexing'; +import { CredentialStore } from '../../lib/reindexing/credential_store'; +import { reindexActionsFactory } from '../../lib/reindexing/reindex_actions'; +import { sortAndOrderReindexOperations } from '../../lib/reindexing/op_utils'; +import { ReindexError } from '../../lib/reindexing/error'; +import { RouteDependencies } from '../../types'; import { AccessForbidden, - IndexNotFound, CannotCreateIndex, + IndexNotFound, + MultipleReindexJobsFound, ReindexAlreadyInProgress, + ReindexCannotBeCancelled, ReindexTaskCannotBeDeleted, ReindexTaskFailed, - MultipleReindexJobsFound, -} from '../lib/reindexing/error_symbols'; +} from '../../lib/reindexing/error_symbols'; + +import { reindexHandler } from './reindex_handler'; +import { GetBatchQueueResponse, PostBatchResponse } from './types'; interface CreateReindexWorker { logger: Logger; @@ -63,6 +70,7 @@ const mapAnyErrorToKibanaHttpResponse = (e: any) => { return kibanaResponseFactory.customError({ body: e.message, statusCode: 422 }); case ReindexAlreadyInProgress: case MultipleReindexJobsFound: + case ReindexCannotBeCancelled: return kibanaResponseFactory.badRequest({ body: e.message }); default: // nothing matched @@ -94,13 +102,14 @@ export function registerReindexIndicesRoutes( async ( { core: { - savedObjects, + savedObjects: { client: savedObjectsClient }, elasticsearch: { dataClient }, }, }, request, response ) => { +<<<<<<< HEAD:x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.ts const { indexName } = request.params as any; const { openAndClose } = request.body as any; const { client } = savedObjects; @@ -130,11 +139,26 @@ export function registerReindexIndicesRoutes( // Add users credentials for the worker to use credentialStore.set(reindexOp, request.headers); +======= + const { indexName } = request.params; + try { + const result = await reindexHandler({ + savedObjects: savedObjectsClient, + dataClient, + indexName, + log, + licensing, + headers: request.headers, + credentialStore, + }); +>>>>>>> 3a53fe8e452b6b872fe59c53682b7f79c4cea7ad:x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts // Kick the worker on this node to immediately pickup the new reindex operation. getWorker().forceRefresh(); - return response.ok({ body: reindexOp.attributes }); + return response.ok({ + body: result, + }); } catch (e) { return mapAnyErrorToKibanaHttpResponse(e); } @@ -142,6 +166,97 @@ export function registerReindexIndicesRoutes( ) ); + // Get the current batch queue + router.get( + { + path: `${BASE_PATH}/batch/queue`, + validate: {}, + }, + async ( + { + core: { + elasticsearch: { dataClient }, + savedObjects, + }, + }, + request, + response + ) => { + const { client } = savedObjects; + const callAsCurrentUser = dataClient.callAsCurrentUser.bind(dataClient); + const reindexActions = reindexActionsFactory(client, callAsCurrentUser); + try { + const inProgressOps = await reindexActions.findAllByStatus(ReindexStatus.inProgress); + const { queue } = sortAndOrderReindexOperations(inProgressOps); + const result: GetBatchQueueResponse = { + queue: queue.map(savedObject => savedObject.attributes), + }; + return response.ok({ + body: result, + }); + } catch (e) { + return mapAnyErrorToKibanaHttpResponse(e); + } + } + ); + + // Add indices for reindexing to the worker's batch + router.post( + { + path: `${BASE_PATH}/batch`, + validate: { + body: schema.object({ + indexNames: schema.arrayOf(schema.string()), + }), + }, + }, + versionCheckHandlerWrapper( + async ( + { + core: { + savedObjects: { client: savedObjectsClient }, + elasticsearch: { dataClient }, + }, + }, + request, + response + ) => { + const { indexNames } = request.body; + const results: PostBatchResponse = { + enqueued: [], + errors: [], + }; + for (const indexName of indexNames) { + try { + const result = await reindexHandler({ + savedObjects: savedObjectsClient, + dataClient, + indexName, + log, + licensing, + headers: request.headers, + credentialStore, + enqueue: true, + }); + results.enqueued.push(result); + } catch (e) { + results.errors.push({ + indexName, + message: e.message, + }); + } + } + + if (results.errors.length < indexNames.length) { + // Kick the worker on this node to immediately pickup the batch. + getWorker().forceRefresh(); + } + + return response.ok({ body: results }); + } + ) + ); + // Get status router.get( { @@ -164,7 +279,7 @@ export function registerReindexIndicesRoutes( response ) => { const { client } = savedObjects; - const { indexName } = request.params as any; + const { indexName } = request.params; const callAsCurrentUser = dataClient.callAsCurrentUser.bind(dataClient); const reindexActions = reindexActionsFactory(client, callAsCurrentUser); const reindexService = reindexServiceFactory( @@ -219,7 +334,7 @@ export function registerReindexIndicesRoutes( request, response ) => { - const { indexName } = request.params as any; + const { indexName } = request.params; const { client } = savedObjects; const callAsCurrentUser = dataClient.callAsCurrentUser.bind(dataClient); const reindexActions = reindexActionsFactory(client, callAsCurrentUser); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/types.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/types.ts new file mode 100644 index 0000000000000..251450a9e37f2 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/types.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ReindexOperation } from '../../../common/types'; + +// These types represent contracts from the reindex RESTful API endpoints and +// should be changed in a way that respects backwards compatibility. + +export interface PostBatchResponse { + enqueued: ReindexOperation[]; + errors: Array<{ indexName: string; message: string }>; +} + +export interface GetBatchQueueResponse { + queue: ReindexOperation[]; +} diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_simulate.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_simulate.tsx index c906d05be64be..b9fce52b480ef 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_simulate.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_simulate.tsx @@ -374,7 +374,6 @@ export const JsonWatchEditSimulate = ({ errors={executeWatchErrors} > <EuiCodeEditor - fullWidth mode={xJsonMode} width="100%" height="200px" diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx index b79f9eee01834..dc2c5d5df0686 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx @@ -241,7 +241,6 @@ export const WebhookActionFields: React.FunctionComponent<Props> = ({ errors={errors} > <EuiCodeEditor - fullWidth mode="json" width="100%" height="200px" diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/threshold_watch_edit.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/threshold_watch_edit.tsx index e3d729c33fddf..b10c2363e662e 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/threshold_watch_edit.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/threshold_watch_edit.tsx @@ -9,7 +9,7 @@ import { EuiButton, EuiButtonEmpty, EuiComboBox, - EuiComboBoxOptionProps, + EuiComboBoxOptionOption, EuiExpression, EuiFieldNumber, EuiFieldText, @@ -323,7 +323,7 @@ export const ThresholdWatchEdit = ({ pageTitle }: { pageTitle: string }) => { value: anIndex, }; })} - onChange={async (selected: EuiComboBoxOptionProps[]) => { + onChange={async (selected: EuiComboBoxOptionOption[]) => { setWatchProperty( 'index', selected.map(aSelected => aSelected.value) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts index c0f56c55ba850..50cc80011777e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts @@ -18,7 +18,7 @@ export default function alertingApiIntegrationTests({ const esArchiver = getService('esArchiver'); describe('alerting api integration security and spaces enabled', function() { - this.tags('ciGroup3'); + this.tags('ciGroup5'); before(async () => { for (const space of Spaces) { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts index b118a48fd642c..10397a571b0ef 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts @@ -16,7 +16,7 @@ export default function alertingApiIntegrationTests({ const esArchiver = getService('esArchiver'); describe('alerting api integration spaces only', function() { - this.tags('ciGroup3'); + this.tags('ciGroup9'); before(async () => { for (const space of Object.values(Spaces)) { diff --git a/x-pack/test/functional/apps/endpoint/alert_list.ts b/x-pack/test/functional/apps/endpoint/alert_list.ts index 089fa487ef1b8..eae7713c37a06 100644 --- a/x-pack/test/functional/apps/endpoint/alert_list.ts +++ b/x-pack/test/functional/apps/endpoint/alert_list.ts @@ -8,10 +8,12 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function({ getPageObjects, getService }: FtrProviderContext) { const pageObjects = getPageObjects(['common', 'endpoint']); const testSubjects = getService('testSubjects'); + const esArchiver = getService('esArchiver'); describe('Endpoint Alert List', function() { this.tags(['ciGroup7']); before(async () => { + await esArchiver.load('endpoint/alerts/api_feature'); await pageObjects.common.navigateToUrlWithBrowserHistory('endpoint', '/alerts'); }); @@ -21,5 +23,9 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('includes Alert list data grid', async () => { await testSubjects.existOrFail('alertListGrid'); }); + + after(async () => { + await esArchiver.unload('endpoint/alerts/api_feature'); + }); }); } diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts index 25ebc6d610f86..75ae6b9ea7c21 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -60,7 +60,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('thresholdAlertTimeFieldSelect'); const fieldOptions = await find.allByCssSelector('#thresholdTimeField option'); await fieldOptions[1].click(); + // need this two out of popup clicks to close them await nameInput.click(); + await testSubjects.click('intervalInput'); + await testSubjects.click('.slack-ActionTypeSelectOption'); await testSubjects.click('createActionConnectorButton'); const connectorNameInput = await testSubjects.find('nameInput'); diff --git a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js index 0bc6e83ad1065..fb11a814af071 100644 --- a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js +++ b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js @@ -7,6 +7,7 @@ import expect from '@kbn/expect'; import { ReindexStatus, REINDEX_OP_TYPE } from '../../../plugins/upgrade_assistant/common/types'; +import { generateNewIndexName } from '../../../plugins/upgrade_assistant/server/lib/reindexing/index_settings'; export default function({ getService }) { const supertest = getService('supertest'); @@ -143,5 +144,73 @@ export default function({ getService }) { expect(lastState.errorMessage).to.equal(null); expect(lastState.status).to.equal(ReindexStatus.completed); }); + + it('should reindex a batch in order and report queue state', async () => { + const assertQueueState = async (firstInQueueIndexName, queueLength) => { + const response = await supertest + .get(`/api/upgrade_assistant/reindex/batch/queue`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + const { queue } = response.body; + + const [firstInQueue] = queue; + + if (!firstInQueueIndexName) { + expect(firstInQueueIndexName).to.be(undefined); + } else { + expect(firstInQueue.indexName).to.be(firstInQueueIndexName); + } + + expect(queue.length).to.be(queueLength); + }; + + const test1 = 'batch-reindex-test1'; + const test2 = 'batch-reindex-test2'; + const test3 = 'batch-reindex-test3'; + + const cleanupReindex = async indexName => { + try { + await es.indices.delete({ index: generateNewIndexName(indexName) }); + } catch (e) { + try { + await es.indices.delete({ index: indexName }); + } catch (e) { + // Ignore + } + } + }; + + try { + // Set up indices for the batch + await es.indices.create({ index: test1 }); + await es.indices.create({ index: test2 }); + await es.indices.create({ index: test3 }); + + const result = await supertest + .post(`/api/upgrade_assistant/reindex/batch`) + .set('kbn-xsrf', 'xxx') + .send({ indexNames: [test1, test2, test3] }) + .expect(200); + + expect(result.body.enqueued.length).to.equal(3); + expect(result.body.errors.length).to.equal(0); + + await assertQueueState(test1, 3); + await waitForReindexToComplete(test1); + + await assertQueueState(test2, 2); + await waitForReindexToComplete(test2); + + await assertQueueState(test3, 1); + await waitForReindexToComplete(test3); + + await assertQueueState(undefined, 0); + } finally { + await cleanupReindex(test1); + await cleanupReindex(test2); + await cleanupReindex(test3); + } + }); }); } diff --git a/x-pack/test/visual_regression/config.js b/x-pack/test/visual_regression/config.ts similarity index 69% rename from x-pack/test/visual_regression/config.js rename to x-pack/test/visual_regression/config.ts index aff6aaaf4114a..dce17348f75e6 100644 --- a/x-pack/test/visual_regression/config.js +++ b/x-pack/test/visual_regression/config.ts @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { services as ossVisualRegressionServices } from '../../../test/visual_regression/services'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; -export default async function({ readConfigFile }) { +import { services } from './services'; + +export default async function({ readConfigFile }: FtrConfigProviderContext) { const functionalConfig = await readConfigFile(require.resolve('../functional/config')); return { @@ -19,10 +21,7 @@ export default async function({ readConfigFile }) { require.resolve('./tests/infra'), ], - services: { - ...functionalConfig.get('services'), - visualTesting: ossVisualRegressionServices.visualTesting, - }, + services, junit: { reportName: 'X-Pack Visual Regression Tests', diff --git a/x-pack/test/visual_regression/ftr_provider_context.d.ts b/x-pack/test/visual_regression/ftr_provider_context.d.ts new file mode 100644 index 0000000000000..bb257cdcbfe1b --- /dev/null +++ b/x-pack/test/visual_regression/ftr_provider_context.d.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; + +import { pageObjects } from './page_objects'; +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext<typeof services, typeof pageObjects>; diff --git a/x-pack/test/visual_regression/page_objects.ts b/x-pack/test/visual_regression/page_objects.ts new file mode 100644 index 0000000000000..ea3e49d0ccc5e --- /dev/null +++ b/x-pack/test/visual_regression/page_objects.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pageObjects } from '../functional/page_objects'; + +export { pageObjects }; diff --git a/x-pack/test/visual_regression/services.ts b/x-pack/test/visual_regression/services.ts new file mode 100644 index 0000000000000..447c16281b838 --- /dev/null +++ b/x-pack/test/visual_regression/services.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { services as ossVisualRegressionServices } from '../../../test/visual_regression/services'; +import { services as functionalServices } from '../functional/services'; + +export const services = { + ...functionalServices, + visualTesting: ossVisualRegressionServices.visualTesting, +}; diff --git a/x-pack/test/visual_regression/tests/login_page.js b/x-pack/test/visual_regression/tests/login_page.ts similarity index 91% rename from x-pack/test/visual_regression/tests/login_page.js rename to x-pack/test/visual_regression/tests/login_page.ts index b290b8f819589..ce90669a6bfe1 100644 --- a/x-pack/test/visual_regression/tests/login_page.js +++ b/x-pack/test/visual_regression/tests/login_page.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export default function({ getService, getPageObjects }) { +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const visualTesting = getService('visualTesting'); const testSubjects = getService('testSubjects'); diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 31ef0bef18a85..a6c94ff74620e 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -43,4 +43,4 @@ "jest" ] } -} \ No newline at end of file +} diff --git a/x-pack/typings/@elastic/eui/index.d.ts b/x-pack/typings/@elastic/eui/index.d.ts index 688d1a2fa127d..ea7a81fa986ce 100644 --- a/x-pack/typings/@elastic/eui/index.d.ts +++ b/x-pack/typings/@elastic/eui/index.d.ts @@ -7,7 +7,6 @@ // TODO: Remove once typescript definitions are in EUI declare module '@elastic/eui' { - export const EuiCodeEditor: React.FC<any>; export const Query: any; } diff --git a/yarn.lock b/yarn.lock index dde08490d62f0..1cf77d50d7dbb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1952,16 +1952,17 @@ tabbable "^1.1.0" uuid "^3.1.0" -"@elastic/eui@19.0.0": - version "19.0.0" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-19.0.0.tgz#cf7d644945c95997d442585cf614e853f173746e" - integrity sha512-8/USz56MYhu6bV4oecJct7tsdi0ktErOIFLobNmQIKdxDOni/KpttX6IHqxM7OuIWi1AEMXoIozw68+oyL/uKQ== +"@elastic/eui@20.0.2": + version "20.0.2" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-20.0.2.tgz#c64b16fef15da6aa9e627d45cdd372f1fc676359" + integrity sha512-8TtazI7RO1zJH4Qkl6TZKvAxaFG9F8BEdwyGmbGhyvXOJbkvttRzoaEg9jSQpKr+z7w2vsjGNbza/fEAE41HOA== dependencies: "@types/chroma-js" "^1.4.3" "@types/enzyme" "^3.1.13" "@types/lodash" "^4.14.116" "@types/numeral" "^0.0.25" "@types/react-beautiful-dnd" "^10.1.0" + "@types/react-input-autosize" "^2.0.2" "@types/react-virtualized" "^9.18.7" chroma-js "^2.0.4" classnames "^2.2.5" @@ -5011,6 +5012,13 @@ dependencies: "@types/react" "*" +"@types/react-input-autosize@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@types/react-input-autosize/-/react-input-autosize-2.0.2.tgz#6ccdfb100c21b6096c1a04c3c3fac196b0ce61c1" + integrity sha512-QzewaD5kog7c6w5e3dretb+50oM8RDdDvVumQKCtPjI6VHyR8lA/HxCiTrv5l9Vgbi4NCitYuix/NorOevlrng== + dependencies: + "@types/react" "*" + "@types/react-intl@^2.3.15": version "2.3.17" resolved "https://registry.yarnpkg.com/@types/react-intl/-/react-intl-2.3.17.tgz#e1fc6e46e8af58bdef9531259d509380a8a99e8e"