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.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js index 58da4f8eeddc3..fb4158a6e3e03 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js @@ -643,7 +643,7 @@ function discoverController( // no timefield, no vis, nothing to update if (!$scope.opts.timefield) return; - const buckets = $scope.vis.getAggConfig().bySchemaGroup('buckets'); + const buckets = $scope.vis.getAggConfig().byTypeName('buckets'); if (buckets && buckets.length === 1) { $scope.bucketInterval = buckets[0].buckets.getInterval(); diff --git a/src/legacy/core_plugins/tile_map/public/tile_map_type.js b/src/legacy/core_plugins/tile_map/public/tile_map_type.js index 80cec5b93f485..544b63abe82c7 100644 --- a/src/legacy/core_plugins/tile_map/public/tile_map_type.js +++ b/src/legacy/core_plugins/tile_map/public/tile_map_type.js @@ -137,7 +137,7 @@ export function createTileMapTypeDefinition(dependencies) { title: i18n.translate('tileMap.vis.map.editorConfig.schemas.geoCoordinatesTitle', { defaultMessage: 'Geo coordinates', }), - aggFilter: 'geohash_grid', + aggFilter: ['geohash_grid'], min: 1, max: 1, }, diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap b/src/legacy/core_plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap index aed0285fd3405..ba5f2ae975cbe 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap +++ b/src/legacy/core_plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap @@ -2,11 +2,11 @@ exports[`DefaultEditorAgg component should init with the default set of props 1`] = ` - Schema name + metric } @@ -45,9 +45,7 @@ exports[`DefaultEditorAgg component should init with the default set of props 1` "getIndexPattern": [Function], "id": "1", "params": Object {}, - "schema": Object { - "title": "Schema name", - }, + "schema": "metric", "title": "Metrics", } } @@ -58,10 +56,21 @@ exports[`DefaultEditorAgg component should init with the default set of props 1` indexPattern={Object {}} metricAggs={Array []} onAggTypeChange={[Function]} + schemas={ + Array [ + Object { + "name": "metric", + }, + ] + } setAggParamValue={[MockFunction]} setTouched={[Function]} setValidity={[Function]} - state={Object {}} + state={ + Object { + "params": Object {}, + } + } /> `; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg.test.tsx index f5ce55e82967d..22e0ebb3d30dc 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg.test.tsx @@ -28,6 +28,7 @@ import { IAggType, AggGroupNames } from '../legacy_imports'; import { DefaultEditorAgg, DefaultEditorAggProps } from './agg'; import { DefaultEditorAggParams } from './agg_params'; import { AGGS_ACTION_KEYS } from './agg_group_state'; +import { Schema } from '../schemas'; jest.mock('ui/new_platform'); @@ -55,7 +56,7 @@ describe('DefaultEditorAgg component', () => { id: '1', brandNew: true, getIndexPattern: () => ({} as IndexPattern), - schema: { title: 'Schema name' }, + schema: 'metric', title: 'Metrics', params: {}, } as any, @@ -69,13 +70,18 @@ describe('DefaultEditorAgg component', () => { isLastBucket: false, isRemovable: false, metricAggs: [], - state: {} as VisState, + state: { params: {} } as VisState, setAggParamValue, setStateParamValue, onAggTypeChange: () => {}, setAggsState, onToggleEnableAgg, removeAgg, + schemas: [ + { + name: 'metric', + } as Schema, + ], }; }); @@ -175,9 +181,7 @@ describe('DefaultEditorAgg component', () => { }); it('should add schema component', () => { - defaultProps.agg.schema = { - name: 'split', - } as any; + defaultProps.agg.schema = 'split'; const comp = mount(); expect(comp.find('RowsOrColumnsControl').exists()).toBeTruthy(); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg.tsx index 5450c29450bac..30ccd4f0b6cae 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg.tsx @@ -34,6 +34,7 @@ import { DefaultEditorAggCommonProps } from './agg_common_props'; import { AGGS_ACTION_KEYS, AggsAction } from './agg_group_state'; import { RowsOrColumnsControl } from './controls/rows_or_columns'; import { RadiusRatioOptionControl } from './controls/radius_ratio_option'; +import { getSchemaByName } from '../schemas'; export interface DefaultEditorAggProps extends DefaultEditorAggCommonProps { agg: IAggConfig; @@ -67,6 +68,7 @@ function DefaultEditorAgg({ onToggleEnableAgg, removeAgg, setAggsState, + schemas, }: DefaultEditorAggProps) { const [isEditorOpen, setIsEditorOpen] = useState((agg as any).brandNew); const [validState, setValidState] = useState(true); @@ -80,11 +82,11 @@ function DefaultEditorAgg({ let SchemaComponent; - if (agg.schema.name === 'split') { + if (agg.schema === 'split') { SchemaComponent = RowsOrColumnsControl; } - if (agg.schema.name === 'radius') { + if (agg.schema === 'radius') { SchemaComponent = RadiusRatioOptionControl; } @@ -255,10 +257,10 @@ function DefaultEditorAgg({ ); }; - + const schemaTitle = getSchemaByName(schemas, agg.schema).title; const buttonContent = ( <> - {agg.schema.title} {showDescription && {aggDescription}} + {schemaTitle || agg.schema} {showDescription && {aggDescription}} ); @@ -272,7 +274,7 @@ function DefaultEditorAgg({ className="visEditorSidebar__section visEditorSidebar__collapsible visEditorSidebar__collapsible--marginBottom" aria-label={i18n.translate('visDefaultEditor.agg.toggleEditorButtonAriaLabel', { defaultMessage: 'Toggle {schema} editor', - values: { schema: agg.schema.title }, + values: { schema: schemaTitle || agg.schema }, })} data-test-subj={`visEditorAggAccordion${agg.id}`} extraAction={renderAggButtons()} @@ -303,6 +305,7 @@ function DefaultEditorAgg({ onAggTypeChange={onAggTypeChange} setTouched={setTouched} setValidity={setValidity} + schemas={schemas} /> diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_add.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_add.tsx index d8df5b315fca0..24cb83498d4d0 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_add.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_add.tsx @@ -29,7 +29,8 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { IAggConfig, AggGroupNames, Schema } from '../legacy_imports'; +import { IAggConfig, AggGroupNames } from '../legacy_imports'; +import { Schema } from '../schemas'; interface DefaultEditorAggAddProps { group?: IAggConfig[]; @@ -72,7 +73,7 @@ function DefaultEditorAggAdd({ : i18n.translate('visDefaultEditor.aggAdd.metricLabel', { defaultMessage: 'metric' }); const isSchemaDisabled = (schema: Schema): boolean => { - const count = group.filter(agg => agg.schema.name === schema.name).length; + const count = group.filter(agg => agg.schema === schema.name).length; return count >= schema.max; }; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_common_props.ts b/src/legacy/core_plugins/vis_default_editor/public/components/agg_common_props.ts index 17d2c18d2532c..b43894e74689f 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_common_props.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_common_props.ts @@ -18,7 +18,8 @@ */ import { VisState, VisParams } from 'src/legacy/core_plugins/visualizations/public'; -import { IAggType, IAggConfig, AggGroupNames, Schema } from '../legacy_imports'; +import { IAggType, IAggConfig, AggGroupNames } from '../legacy_imports'; +import { Schema } from '../schemas'; type AggId = IAggConfig['id']; type AggParams = IAggConfig['params']; @@ -44,4 +45,5 @@ export interface DefaultEditorAggCommonProps extends DefaultEditorCommonProps { setStateParamValue: (paramName: T, value: VisParams[T]) => void; onToggleEnableAgg: (aggId: AggId, isEnable: boolean) => void; removeAgg: (aggId: AggId) => void; + schemas: Schema[]; } diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.test.tsx index c36c0176439f9..ec467480539ab 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.test.tsx @@ -21,10 +21,11 @@ import React from 'react'; import { mount, shallow } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { VisState } from 'src/legacy/core_plugins/visualizations/public'; -import { IAggConfigs, IAggConfig, Schema } from '../legacy_imports'; +import { IAggConfigs, IAggConfig } from '../legacy_imports'; import { DefaultEditorAggGroup, DefaultEditorAggGroupProps } from './agg_group'; import { DefaultEditorAgg } from './agg'; import { DefaultEditorAggAdd } from './agg_add'; +import { Schema } from '../schemas'; jest.mock('@elastic/eui', () => ({ EuiTitle: 'eui-title', @@ -75,7 +76,7 @@ describe('DefaultEditorAgg component', () => { type: 'number', }, }, - schema: { group: 'metrics' }, + schema: 'metrics', } as IAggConfig, { id: '3', @@ -84,7 +85,7 @@ describe('DefaultEditorAgg component', () => { type: 'string', }, }, - schema: { group: 'metrics' }, + schema: 'metrics', } as IAggConfig, { id: '2', @@ -93,7 +94,7 @@ describe('DefaultEditorAgg component', () => { type: 'number', }, }, - schema: { group: 'buckets' }, + schema: 'buckets', } as IAggConfig, ], } as IAggConfigs; @@ -107,9 +108,13 @@ describe('DefaultEditorAgg component', () => { } as VisState, schemas: [ { + name: 'metrics', + group: 'metrics', max: 1, } as Schema, { + name: 'buckets', + group: 'buckets', max: 1, } as Schema, ], diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.tsx index 768a9669025e4..a15a98d4983ce 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.tsx @@ -30,7 +30,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { IAggConfig, aggGroupNamesMap, AggGroupNames, Schema } from '../legacy_imports'; +import { IAggConfig, aggGroupNamesMap, AggGroupNames } from '../legacy_imports'; import { DefaultEditorAgg } from './agg'; import { DefaultEditorAggAdd } from './agg_add'; import { AddSchema, ReorderAggs, DefaultEditorAggCommonProps } from './agg_common_props'; @@ -41,6 +41,7 @@ import { getEnabledMetricAggsCount, } from './agg_group_helper'; import { aggGroupReducer, initAggsState, AGGS_ACTION_KEYS } from './agg_group_state'; +import { Schema, getSchemasByGroup } from '../schemas'; export interface DefaultEditorAggGroupProps extends DefaultEditorAggCommonProps { schemas: Schema[]; @@ -69,9 +70,12 @@ function DefaultEditorAggGroup({ }: DefaultEditorAggGroupProps) { const groupNameLabel = (aggGroupNamesMap() as any)[groupName]; // e.g. buckets can have no aggs + const schemaNames = getSchemasByGroup(schemas, groupName).map(s => s.name); const group: IAggConfig[] = useMemo( - () => state.aggs.aggs.filter((agg: IAggConfig) => agg.schema.group === groupName) || [], - [groupName, state.aggs.aggs] + () => + state.aggs.aggs.filter((agg: IAggConfig) => agg.schema && schemaNames.includes(agg.schema)) || + [], + [state.aggs.aggs, schemaNames] ); const stats = { @@ -162,14 +166,14 @@ function DefaultEditorAggGroup({ 1} isLastBucket={groupName === AggGroupNames.Buckets && index === group.length - 1} - isRemovable={isAggRemovable(agg, group)} - isDisabled={agg.schema.name === 'metric' && isMetricAggregationDisabled} + isRemovable={isAggRemovable(agg, group, schemas)} + isDisabled={agg.schema === 'metric' && isMetricAggregationDisabled} lastParentPipelineAggTitle={lastParentPipelineAggTitle} metricAggs={metricAggs} state={state} @@ -179,6 +183,7 @@ function DefaultEditorAggGroup({ onToggleEnableAgg={onToggleEnableAgg} removeAgg={removeAgg} setAggsState={setAggsState} + schemas={schemas} /> )} diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group_helper.test.ts b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group_helper.test.ts index b18e5af27f8b4..aebece29e7ae6 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group_helper.test.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group_helper.test.ts @@ -25,9 +25,11 @@ import { getEnabledMetricAggsCount, } from './agg_group_helper'; import { AggsState } from './agg_group_state'; +import { Schema } from '../schemas'; describe('DefaultEditorGroup helpers', () => { let group: IAggConfig[]; + let schemas: Schema[]; beforeEach(() => { group = [ @@ -38,7 +40,7 @@ describe('DefaultEditorGroup helpers', () => { type: 'number', }, }, - schema: { name: 'metric', min: 1, mustBeFirst: true }, + schema: 'metric', } as IAggConfig, { id: '2', @@ -47,20 +49,45 @@ describe('DefaultEditorGroup helpers', () => { type: 'string', }, }, - schema: { name: 'metric', min: 2 }, + schema: 'metric2', } as IAggConfig, ]; + schemas = [ + { + name: 'metric', + title: 'Metric', + group: 'metrics', + min: 0, + max: 3, + aggFilter: [], + editor: false, + params: [], + defaults: null, + mustBeFirst: true, + }, + { + name: 'metric2', + title: 'Metric', + group: 'metrics', + min: 2, + max: 3, + aggFilter: [], + editor: false, + params: [], + defaults: null, + }, + ]; }); describe('isAggRemovable', () => { it('should return true when the number of aggs with the same schema is above the min', () => { - const isRemovable = isAggRemovable(group[0], group); + const isRemovable = isAggRemovable(group[0], group, schemas); expect(isRemovable).toBeTruthy(); }); it('should return false when the number of aggs with the same schema is not above the min', () => { - const isRemovable = isAggRemovable(group[1], group); + const isRemovable = isAggRemovable(group[1], group, schemas); expect(isRemovable).toBeFalsy(); }); @@ -77,6 +104,7 @@ describe('DefaultEditorGroup helpers', () => { it('should return 2 when there are multiple enabled aggs', () => { group[0].enabled = true; group[1].enabled = true; + group[1].schema = 'metric'; const enabledAggs = getEnabledMetricAggsCount(group); expect(enabledAggs).toBe(2); @@ -85,26 +113,26 @@ describe('DefaultEditorGroup helpers', () => { describe('calcAggIsTooLow', () => { it('should return false when agg.schema.mustBeFirst has falsy value', () => { - const isRemovable = calcAggIsTooLow(group[1], 0, group); + const isRemovable = calcAggIsTooLow(group[1], 0, group, schemas); expect(isRemovable).toBeFalsy(); }); it('should return false when there is no different schema', () => { group[1].schema = group[0].schema; - const isRemovable = calcAggIsTooLow(group[0], 0, group); + const isRemovable = calcAggIsTooLow(group[0], 0, group, schemas); expect(isRemovable).toBeFalsy(); }); it('should return false when different schema is not less than agg index', () => { - const isRemovable = calcAggIsTooLow(group[0], 0, group); + const isRemovable = calcAggIsTooLow(group[0], 0, group, schemas); expect(isRemovable).toBeFalsy(); }); it('should return true when agg index is greater than different schema index', () => { - const isRemovable = calcAggIsTooLow(group[0], 2, group); + const isRemovable = calcAggIsTooLow(group[0], 2, group, schemas); expect(isRemovable).toBeTruthy(); }); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group_helper.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group_helper.tsx index d2e8e5401c0f7..0a8c5c3077ada 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group_helper.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group_helper.tsx @@ -20,27 +20,34 @@ import { findIndex, isEmpty } from 'lodash'; import { IAggConfig } from '../legacy_imports'; import { AggsState } from './agg_group_state'; +import { Schema, getSchemaByName } from '../schemas'; -const isAggRemovable = (agg: IAggConfig, group: IAggConfig[]) => { +const isAggRemovable = (agg: IAggConfig, group: IAggConfig[], schemas: Schema[]) => { + const schema = getSchemaByName(schemas, agg.schema); const metricCount = group.reduce( - (count, aggregation: IAggConfig) => - aggregation.schema.name === agg.schema.name ? ++count : count, + (count, aggregation: IAggConfig) => (aggregation.schema === agg.schema ? ++count : count), 0 ); // make sure the the number of these aggs is above the min - return metricCount > agg.schema.min; + return metricCount > schema.min; }; const getEnabledMetricAggsCount = (group: IAggConfig[]) => { return group.reduce( (count, aggregation: IAggConfig) => - aggregation.schema.name === 'metric' && aggregation.enabled ? ++count : count, + aggregation.schema === 'metric' && aggregation.enabled ? ++count : count, 0 ); }; -const calcAggIsTooLow = (agg: IAggConfig, aggIndex: number, group: IAggConfig[]) => { - if (!agg.schema.mustBeFirst) { +const calcAggIsTooLow = ( + agg: IAggConfig, + aggIndex: number, + group: IAggConfig[], + schemas: Schema[] +) => { + const schema = getSchemaByName(schemas, agg.schema); + if (!schema.mustBeFirst) { return false; } diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_param_props.ts b/src/legacy/core_plugins/vis_default_editor/public/components/agg_param_props.ts index 843cfddc07010..cdc5a4c8f8a77 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_param_props.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_param_props.ts @@ -22,6 +22,7 @@ import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { IAggConfig, AggParam } from '../legacy_imports'; import { ComboBoxGroupedOptions } from '../utils'; import { EditorConfig } from './utils'; +import { Schema } from '../schemas'; // NOTE: we cannot export the interface with export { InterfaceName } // as there is currently a bug on babel typescript transform plugin for it @@ -38,6 +39,7 @@ export interface AggParamCommonProps { state: VisState; value?: T; metricAggs: IAggConfig[]; + schemas: Schema[]; } export interface AggParamEditorProps extends AggParamCommonProps { diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.test.tsx index af851aa9b4418..d2821566fcb37 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.test.tsx @@ -105,6 +105,7 @@ describe('DefaultEditorAggParams component', () => { onAggTypeChange, setTouched, setValidity, + schemas: [], }; }); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.tsx index e9583ab4cec79..510c21af95da1 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.tsx @@ -40,6 +40,7 @@ import { } from './agg_params_state'; import { DefaultEditorCommonProps } from './agg_common_props'; import { EditorParamConfig, TimeIntervalParam, FixedParam, getEditorConfig } from './utils'; +import { Schema, getSchemaByName } from '../schemas'; const FIXED_VALUE_PROP = 'fixedValue'; const DEFAULT_PROP = 'default'; @@ -57,6 +58,9 @@ export interface DefaultEditorAggParamsProps extends DefaultEditorCommonProps { indexPattern: IndexPattern; setValidity: (isValid: boolean) => void; setTouched: (isTouched: boolean) => void; + schemas: Schema[]; + allowedAggs?: string[]; + hideCustomLabel?: boolean; } function DefaultEditorAggParams({ @@ -75,16 +79,22 @@ function DefaultEditorAggParams({ onAggTypeChange, setTouched, setValidity, + schemas, + allowedAggs = [], + hideCustomLabel = false, }: DefaultEditorAggParamsProps) { - const groupedAggTypeOptions = useMemo(() => getAggTypeOptions(agg, indexPattern, groupName), [ - agg, - indexPattern, - groupName, - ]); + const schema = getSchemaByName(schemas, agg.schema); + const { title } = schema; + const aggFilter = [...allowedAggs, ...(schema.aggFilter || [])]; + const groupedAggTypeOptions = useMemo( + () => getAggTypeOptions(agg, indexPattern, groupName, aggFilter), + [agg, indexPattern, groupName, aggFilter] + ); + const error = aggIsTooLow ? i18n.translate('visDefaultEditor.aggParams.errors.aggWrongRunOrderErrorMessage', { defaultMessage: '"{schema}" aggs must run before all other buckets!', - values: { schema: agg.schema.title }, + values: { schema: title }, }) : ''; const aggTypeName = agg.type?.name; @@ -94,12 +104,10 @@ function DefaultEditorAggParams({ aggTypeName, fieldName, ]); - const params = useMemo(() => getAggParamsToRender({ agg, editorConfig, metricAggs, state }), [ - agg, - editorConfig, - metricAggs, - state, - ]); + const params = useMemo( + () => getAggParamsToRender({ agg, editorConfig, metricAggs, state, schemas, hideCustomLabel }), + [agg, editorConfig, metricAggs, state, schemas, hideCustomLabel] + ); const allParams = [...params.basic, ...params.advanced]; const [paramsState, onChangeParamsState] = useReducer( aggParamsReducer, diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.test.ts b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.test.ts index f3bee80baa1ba..047467750794b 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.test.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.test.ts @@ -27,6 +27,7 @@ import { } from './agg_params_helper'; import { FieldParamEditor, OrderByParamEditor } from './controls'; import { EditorConfig } from './utils'; +import { Schema } from '../schemas'; jest.mock('../utils', () => ({ groupAndSortBy: jest.fn(() => ['indexedFields']), @@ -38,6 +39,15 @@ describe('DefaultEditorAggParams helpers', () => { describe('getAggParamsToRender', () => { let agg: IAggConfig; let editorConfig: EditorConfig; + const schemas: Schema[] = [ + { + name: 'metric', + } as Schema, + { + name: 'metric2', + hideCustomLabel: true, + } as Schema, + ]; const state = {} as VisState; const metricAggs: IAggConfig[] = []; const emptyParams = { @@ -50,16 +60,16 @@ describe('DefaultEditorAggParams helpers', () => { type: { params: [{ name: 'interval' }], }, - schema: {}, + schema: 'metric', } as IAggConfig; - const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state }); + const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state, schemas }); expect(params).toEqual(emptyParams); }); it('should not create any param if there is no agg type', () => { - agg = {} as IAggConfig; - const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state }); + agg = { schema: 'metric' } as IAggConfig; + const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state, schemas }); expect(params).toEqual(emptyParams); }); @@ -75,21 +85,19 @@ describe('DefaultEditorAggParams helpers', () => { hidden: true, }, }; - const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state }); + const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state, schemas }); expect(params).toEqual(emptyParams); }); it('should skip customLabel param if it is hidden', () => { - agg = { + agg = ({ type: { params: [{ name: 'customLabel' }], }, - schema: { - hideCustomLabel: true, - }, - } as IAggConfig; - const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state }); + schema: 'metric2', + } as any) as IAggConfig; + const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state, schemas }); expect(params).toEqual(emptyParams); }); @@ -116,7 +124,7 @@ describe('DefaultEditorAggParams helpers', () => { }, ], }, - schema: {}, + schema: 'metric', getIndexPattern: jest.fn(() => ({ fields: [ { name: '@timestamp', type: 'date' }, @@ -128,7 +136,7 @@ describe('DefaultEditorAggParams helpers', () => { field: 'field', }, } as any) as IAggConfig; - const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state }); + const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state, schemas }); expect(params).toEqual({ basic: [ @@ -140,6 +148,7 @@ describe('DefaultEditorAggParams helpers', () => { paramEditor: FieldParamEditor, metricAggs, state, + schemas, value: agg.params.field, }, { @@ -150,6 +159,7 @@ describe('DefaultEditorAggParams helpers', () => { paramEditor: OrderByParamEditor, metricAggs, state, + schemas, value: agg.params.orderBy, }, ], @@ -162,7 +172,7 @@ describe('DefaultEditorAggParams helpers', () => { describe('getAggTypeOptions', () => { it('should return agg type options grouped by subtype', () => { const indexPattern = {} as IndexPattern; - const aggs = getAggTypeOptions({} as IAggConfig, indexPattern, 'metrics'); + const aggs = getAggTypeOptions({} as IAggConfig, indexPattern, 'metrics', []); expect(aggs).toEqual(['indexedFields']); }); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.ts b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.ts index 0c0726ec67d50..520ff6ffc5ff5 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.ts @@ -35,12 +35,15 @@ import { IAggType, } from '../legacy_imports'; import { EditorConfig } from './utils'; +import { Schema, getSchemaByName } from '../schemas'; interface ParamInstanceBase { agg: IAggConfig; editorConfig: EditorConfig; metricAggs: IAggConfig[]; state: VisState; + schemas: Schema[]; + hideCustomLabel?: boolean; } export interface ParamInstance extends ParamInstanceBase { @@ -50,7 +53,14 @@ export interface ParamInstance extends ParamInstanceBase { value: unknown; } -function getAggParamsToRender({ agg, editorConfig, metricAggs, state }: ParamInstanceBase) { +function getAggParamsToRender({ + agg, + editorConfig, + metricAggs, + state, + schemas, + hideCustomLabel, +}: ParamInstanceBase) { const params = { basic: [] as ParamInstance[], advanced: [] as ParamInstance[], @@ -63,19 +73,26 @@ function getAggParamsToRender({ agg, editorConfig, metricAggs, state }: ParamIns .filter((param: AggParam) => !get(editorConfig, [param.name, 'hidden'], false))) || []; + const schema = getSchemaByName(schemas, agg.schema); // build collection of agg params components paramsToRender.forEach((param: AggParam, index: number) => { let indexedFields: ComboBoxGroupedOptions = []; let fields: IndexPatternField[]; - if (agg.schema.hideCustomLabel && param.name === 'customLabel') { + if (hideCustomLabel && param.name === 'customLabel') { return; } // if field param exists, compute allowed fields if (param.type === 'field') { - const availableFields: IndexPatternField[] = (param as IFieldParamType).getAvailableFields( - agg - ); + let availableFields: IndexPatternField[] = (param as IFieldParamType).getAvailableFields(agg); + // should be refactored in the future to provide a more general way + // for visualization to override some agg config settings + if (agg.type.name === 'top_hits' && param.name === 'field') { + const allowStrings = _.get(schema, `aggSettings[${agg.type.name}].allowStrings`, false); + if (!allowStrings) { + availableFields = availableFields.filter(field => field.type === 'number'); + } + } fields = aggTypeFieldFilters.filter(availableFields, agg); indexedFields = groupAndSortBy(fields, 'type', 'name'); @@ -109,6 +126,8 @@ function getAggParamsToRender({ agg, editorConfig, metricAggs, state }: ParamIns metricAggs, state, value: agg.params[param.name], + schemas, + hideCustomLabel, }); } }); @@ -119,9 +138,15 @@ function getAggParamsToRender({ agg, editorConfig, metricAggs, state }: ParamIns function getAggTypeOptions( agg: IAggConfig, indexPattern: IndexPattern, - groupName: string + groupName: string, + allowedAggs: string[] ): ComboBoxGroupedOptions { - const aggTypeOptions = aggTypeFilters.filter((aggTypes as any)[groupName], indexPattern, agg); + const aggTypeOptions = aggTypeFilters.filter( + (aggTypes as any)[groupName], + indexPattern, + agg, + allowedAggs + ); return groupAndSortBy(aggTypeOptions as any[], 'subtype', 'title'); } diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_select.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_select.tsx index 9a408c2d98b22..4d969a2d8ec6c 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_select.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_select.tsx @@ -19,7 +19,7 @@ import { get, has } from 'lodash'; import React, { useEffect, useCallback, useState } from 'react'; -import { EuiComboBox, EuiComboBoxOptionProps, EuiFormRow, EuiLink, EuiText } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow, EuiLink, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -104,7 +104,7 @@ function DefaultEditorAggSelect({ const isValid = !!value && !errors.length && !isDirty; const onChange = useCallback( - (options: EuiComboBoxOptionProps[]) => { + (options: EuiComboBoxOptionOption[]) => { const selectedOption = get(options, '0.target'); if (selectedOption) { setValue(selectedOption as IAggType); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.test.tsx index 89d39a0605b60..186738d0f551c 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.test.tsx @@ -29,7 +29,7 @@ import { FieldParamEditor, FieldParamEditorProps } from './field'; import { IAggConfig } from '../../legacy_imports'; function callComboBoxOnChange(comp: ReactWrapper, value: any = []) { - const comboBoxProps: EuiComboBoxProps = comp.find(EuiComboBox).props(); + const comboBoxProps = comp.find(EuiComboBox).props() as EuiComboBoxProps; if (comboBoxProps.onChange) { comboBoxProps.onChange(value); } @@ -81,6 +81,7 @@ describe('FieldParamEditor component', () => { setTouched, state: {} as VisState, metricAggs: [] as IAggConfig[], + schemas: [], }; }); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.tsx index d605fb203f4d3..0ec00ab6f20f0 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.tsx @@ -20,7 +20,7 @@ import { get } from 'lodash'; import React, { useEffect, useState, useCallback } from 'react'; -import { EuiComboBox, EuiComboBoxOptionProps, EuiFormRow } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { IndexPatternField } from 'src/plugins/data/public'; @@ -55,7 +55,7 @@ function FieldParamEditor({ ? [{ label: value.displayName || value.name, target: value }] : []; - const onChange = (options: EuiComboBoxOptionProps[]) => { + const onChange = (options: EuiComboBoxOptionOption[]) => { const selectedOption: IndexPatternField = get(options, '0.target'); if (!(aggParam.required && !selectedOption)) { setValue(selectedOption); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/order_agg.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/order_agg.tsx index 10679b578d54e..8c020c668b3c6 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/order_agg.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/order_agg.tsx @@ -35,6 +35,7 @@ function OrderAggParamEditor({ setValue, setValidity, setTouched, + schemas, }: AggParamEditorProps) { const orderBy = agg.params.orderBy; @@ -64,6 +65,8 @@ function OrderAggParamEditor({ ); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx index 020dbb351b497..0eaf9bcc987c1 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx @@ -47,6 +47,7 @@ describe('PercentilesEditor component', () => { setTouched, state: {} as VisState, metricAggs: [] as IAggConfig[], + schemas: [], }; }); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/rows_or_columns.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/rows_or_columns.tsx index 65c7964709279..83a341e045b5c 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/rows_or_columns.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/rows_or_columns.tsx @@ -28,8 +28,11 @@ const PARAMS = { COLUMNS: 'visEditorSplitBy__false', }; -function RowsOrColumnsControl({ agg, setAggParamValue }: AggControlProps) { - const idSelected = `visEditorSplitBy__${agg.params.row}`; +function RowsOrColumnsControl({ editorStateParams, setStateParamValue }: AggControlProps) { + if (editorStateParams.row === undefined) { + setStateParamValue(PARAMS.NAME, true); + } + const idSelected = `visEditorSplitBy__${editorStateParams.row}`; const options = [ { id: PARAMS.ROWS, @@ -45,8 +48,8 @@ function RowsOrColumnsControl({ agg, setAggParamValue }: AggControlProps) { }, ]; const onChange = useCallback( - optionId => setAggParamValue(agg.id, PARAMS.NAME, optionId === PARAMS.ROWS), - [setAggParamValue] + optionId => setStateParamValue(PARAMS.NAME, optionId === PARAMS.ROWS), + [setStateParamValue] ); return ( diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/sub_agg.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/sub_agg.tsx index 5dc28b59a52b3..5bc94bd4af226 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/sub_agg.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/sub_agg.tsx @@ -35,6 +35,7 @@ function SubAggParamEditor({ setValue, setValidity, setTouched, + schemas, }: AggParamEditorProps) { useEffect(() => { // we aren't creating a custom aggConfig @@ -61,6 +62,7 @@ function SubAggParamEditor({ ); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/sub_metric.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/sub_metric.tsx index 45ff0610d88ed..9d48b1c964a27 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/sub_metric.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/sub_metric.tsx @@ -35,6 +35,7 @@ function SubMetricParamEditor({ setValue, setValidity, setTouched, + schemas, }: AggParamEditorProps) { const metricTitle = i18n.translate('visDefaultEditor.controls.metrics.metricTitle', { defaultMessage: 'Metric', @@ -73,6 +74,7 @@ function SubMetricParamEditor({ ); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/test_utils.ts b/src/legacy/core_plugins/vis_default_editor/public/components/controls/test_utils.ts index 4280f85c901d7..8a21114999cd6 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/test_utils.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/test_utils.ts @@ -29,4 +29,5 @@ export const aggParamCommonPropsMock = { metricAggs: [] as IAggConfig[], state: {} as VisState, showValidation: false, + schemas: [], }; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/time_interval.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/time_interval.tsx index 5da0d6462a8ba..ee3666b2ed441 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/time_interval.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/time_interval.tsx @@ -19,14 +19,14 @@ import { get, find } from 'lodash'; import React, { useEffect } from 'react'; -import { EuiFormRow, EuiIconTip, EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiFormRow, EuiIconTip, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { isValidInterval, AggParamOption } from '../../legacy_imports'; import { AggParamEditorProps } from '../agg_param_props'; -interface ComboBoxOption extends EuiComboBoxOptionProps { +interface ComboBoxOption extends EuiComboBoxOptionOption { key: string; } @@ -105,7 +105,7 @@ function TimeIntervalParamEditor({ } }; - const onChange = (opts: EuiComboBoxOptionProps[]) => { + const onChange = (opts: EuiComboBoxOptionOption[]) => { const selectedOpt: ComboBoxOption = get(opts, '0'); setValue(selectedOpt ? selectedOpt.key : ''); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/data_tab.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/data_tab.tsx index efd17f02a0e09..1c1f9d57d8b90 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/data_tab.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/data_tab.tsx @@ -25,7 +25,6 @@ import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { IAggConfig, AggGroupNames, - ISchemas, parentPipelineType, IMetricAggType, } from '../../legacy_imports'; @@ -40,6 +39,7 @@ import { toggleEnabledAgg, } from './state'; import { AddSchema, ReorderAggs, DefaultEditorAggCommonProps } from '../agg_common_props'; +import { ISchemas } from '../../schemas'; export interface DefaultEditorDataTabProps { dispatch: React.Dispatch; @@ -76,8 +76,8 @@ function DefaultEditorDataTab({ const addSchema: AddSchema = useCallback(schema => dispatch(addNewAgg(schema)), [dispatch]); const onAggRemove: DefaultEditorAggCommonProps['removeAgg'] = useCallback( - aggId => dispatch(removeAgg(aggId)), - [dispatch] + aggId => dispatch(removeAgg(aggId, schemas.all || [])), + [dispatch, schemas] ); const onReorderAggs: ReorderAggs = useCallback((...props) => dispatch(reorderAggs(...props)), [ diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx index a70ffd3cd88e1..1efd8dae8178b 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx @@ -31,6 +31,7 @@ import { DefaultEditorAggCommonProps } from '../agg_common_props'; import { SidebarTitle } from './sidebar_title'; import { PersistedState } from '../../../../../../plugins/visualizations/public'; import { SavedSearch } from '../../../../../../plugins/discover/public'; +import { getSchemasByGroup } from '../../schemas'; interface DefaultEditorSideBarProps { isCollapsed: boolean; @@ -57,9 +58,12 @@ function DefaultEditorSideBar({ const { formState, setTouched, setValidity, resetValidity } = useEditorFormState(); const responseAggs = useMemo(() => state.aggs.getResponseAggs(), [state.aggs]); + const metricSchemas = getSchemasByGroup(vis.type.schemas.all || [], AggGroupNames.Metrics).map( + s => s.name + ); const metricAggs = useMemo( - () => responseAggs.filter(agg => get(agg, 'schema.group') === AggGroupNames.Metrics), - [responseAggs] + () => responseAggs.filter(agg => metricSchemas.includes(get(agg, 'schema'))), + [responseAggs, metricSchemas] ); const hasHistogramAgg = useMemo(() => responseAggs.some(agg => agg.type.name === 'histogram'), [ responseAggs, diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/actions.ts b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/actions.ts index 93fa1083bebf9..f9915bedc8878 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/actions.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/actions.ts @@ -18,8 +18,9 @@ */ import { Vis, VisParams } from 'src/legacy/core_plugins/visualizations/public'; -import { IAggConfig, Schema } from '../../../legacy_imports'; +import { IAggConfig } from '../../../legacy_imports'; import { EditorStateActionTypes } from './constants'; +import { Schema } from '../../../schemas'; export interface ActionType { type: T; @@ -47,7 +48,7 @@ type SetStateParamValue = ActionTyp EditorStateActionTypes.SET_STATE_PARAM_VALUE, { paramName: T; value: AggParams[T] } >; -type RemoveAgg = ActionType; +type RemoveAgg = ActionType; type ReorderAggs = ActionType< EditorStateActionTypes.REORDER_AGGS, { sourceAgg: IAggConfig; destinationAgg: IAggConfig } @@ -85,7 +86,7 @@ export interface EditorActions { paramName: T, value: AggParams[T] ): SetStateParamValue; - removeAgg(aggId: AggId): RemoveAgg; + removeAgg(aggId: AggId, schemas: Schema[]): RemoveAgg; reorderAggs(sourceAgg: IAggConfig, destinationAgg: IAggConfig): ReorderAggs; toggleEnabledAgg(aggId: AggId, enabled: IAggConfig['enabled']): ToggleEnabledAgg; updateStateParams(params: VisParams): UpdateStateParams; @@ -128,10 +129,11 @@ const setStateParamValue: EditorActions['setStateParamValue'] = (paramName, valu }, }); -const removeAgg: EditorActions['removeAgg'] = aggId => ({ +const removeAgg: EditorActions['removeAgg'] = (aggId, schemas) => ({ type: EditorStateActionTypes.REMOVE_AGG, payload: { aggId, + schemas, }, }); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/reducers.ts b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/reducers.ts index 6ae4e415f8caa..73675e75cbe36 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/reducers.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/reducers.ts @@ -20,8 +20,7 @@ import { cloneDeep } from 'lodash'; import { Vis, VisState } from 'src/legacy/core_plugins/visualizations/public'; - -import { createAggConfigs, IAggConfig, AggGroupNames } from '../../../legacy_imports'; +import { createAggConfigs, AggGroupNames } from '../../../legacy_imports'; import { EditorStateActionTypes } from './constants'; import { getEnabledMetricAggsCount } from '../../agg_group_helper'; import { EditorAction } from './actions'; @@ -33,8 +32,12 @@ function initEditorState(vis: Vis) { function editorStateReducer(state: VisState, action: EditorAction): VisState { switch (action.type) { case EditorStateActionTypes.ADD_NEW_AGG: { - const payloadAggConfig = action.payload as IAggConfig; - const aggConfig = state.aggs.createAggConfig(payloadAggConfig, { + const { schema } = action.payload; + const defaultConfig = + !state.aggs.aggs.find(agg => agg.schema === schema.name) && schema.defaults + ? (schema as any).defaults.slice(0, schema.max) + : { schema: schema.name }; + const aggConfig = state.aggs.createAggConfig(defaultConfig, { addToAggConfigs: false, }); aggConfig.brandNew = true; @@ -42,7 +45,7 @@ function editorStateReducer(state: VisState, action: EditorAction): VisState { return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs, state.aggs.schemas), + aggs: createAggConfigs(state.aggs.indexPattern, newAggs), }; } @@ -65,7 +68,7 @@ function editorStateReducer(state: VisState, action: EditorAction): VisState { return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs, state.aggs.schemas), + aggs: createAggConfigs(state.aggs.indexPattern, newAggs), }; } @@ -90,7 +93,7 @@ function editorStateReducer(state: VisState, action: EditorAction): VisState { return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs, state.aggs.schemas), + aggs: createAggConfigs(state.aggs.indexPattern, newAggs), }; } @@ -108,10 +111,10 @@ function editorStateReducer(state: VisState, action: EditorAction): VisState { case EditorStateActionTypes.REMOVE_AGG: { let isMetric = false; - const newAggs = state.aggs.aggs.filter(({ id, schema }) => { if (id === action.payload.aggId) { - if (schema.group === AggGroupNames.Metrics) { + const schemaDef = action.payload.schemas.find(s => s.name === schema); + if (schemaDef && schemaDef.group === AggGroupNames.Metrics) { isMetric = true; } @@ -122,7 +125,7 @@ function editorStateReducer(state: VisState, action: EditorAction): VisState { }); if (isMetric && getEnabledMetricAggsCount(newAggs) === 0) { - const aggToEnable = newAggs.find(agg => agg.schema.name === 'metric'); + const aggToEnable = newAggs.find(agg => agg.schema === 'metric'); if (aggToEnable) { aggToEnable.enabled = true; @@ -131,7 +134,7 @@ function editorStateReducer(state: VisState, action: EditorAction): VisState { return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs, state.aggs.schemas), + aggs: createAggConfigs(state.aggs.indexPattern, newAggs), }; } @@ -143,7 +146,7 @@ function editorStateReducer(state: VisState, action: EditorAction): VisState { return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs, state.aggs.schemas), + aggs: createAggConfigs(state.aggs.indexPattern, newAggs), }; } @@ -165,7 +168,7 @@ function editorStateReducer(state: VisState, action: EditorAction): VisState { return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs, state.aggs.schemas), + aggs: createAggConfigs(state.aggs.indexPattern, newAggs), }; } diff --git a/src/legacy/core_plugins/vis_default_editor/public/index.ts b/src/legacy/core_plugins/vis_default_editor/public/index.ts index fa6c2ee6d5ec7..156d50f451b57 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/index.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/index.ts @@ -23,3 +23,4 @@ export { RangesParamEditor, RangeValues } from './components/controls/ranges'; export * from './editor_size'; export * from './vis_options_props'; export * from './utils'; +export { ISchemas, Schemas, Schema } from './schemas'; diff --git a/src/legacy/core_plugins/vis_default_editor/public/legacy_imports.ts b/src/legacy/core_plugins/vis_default_editor/public/legacy_imports.ts index 8aed263c4e4d1..5c02b50286a95 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/legacy_imports.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/legacy_imports.ts @@ -32,8 +32,6 @@ export { IFieldParamType, BUCKET_TYPES, METRIC_TYPES, - ISchemas, - Schema, termsAggFilter, } from 'ui/agg_types'; export { aggTypeFilters, propFilter } from 'ui/agg_types'; diff --git a/src/legacy/core_plugins/data/public/search/aggs/schemas.ts b/src/legacy/core_plugins/vis_default_editor/public/schemas.ts similarity index 84% rename from src/legacy/core_plugins/data/public/search/aggs/schemas.ts rename to src/legacy/core_plugins/vis_default_editor/public/schemas.ts index 1aa5ebe08656b..5849d9d80011e 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/schemas.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/schemas.ts @@ -22,16 +22,17 @@ import _ from 'lodash'; import { Optional } from '@kbn/utility-types'; import { IndexedArray } from 'ui/indexed_array'; -import { AggGroupNames } from './agg_groups'; -import { AggParam } from './agg_params'; +import { AggGroupNames } from '../../data/public/search/aggs/agg_groups'; +import { AggParam } from '../../data/public/search/aggs/agg_params'; export interface ISchemas { [AggGroupNames.Buckets]: Schema[]; [AggGroupNames.Metrics]: Schema[]; + all: Schema[]; } export interface Schema { - aggFilter: string | string[]; + aggFilter: string[]; editor: boolean | string; group: AggGroupNames; max: number; @@ -103,3 +104,11 @@ export class Schemas { .commit(); } } + +export const getSchemaByName = (schemas: Schema[], schemaName?: string) => { + return schemas.find(s => s.name === schemaName) || ({} as Schema); +}; + +export const getSchemasByGroup = (schemas: Schema[], schemaGroup?: string) => { + return schemas.filter(s => s.group === schemaGroup); +}; diff --git a/src/legacy/core_plugins/vis_default_editor/public/vis_type_agg_filter.ts b/src/legacy/core_plugins/vis_default_editor/public/vis_type_agg_filter.ts index 60b675f50a342..fcb06f73513b0 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/vis_type_agg_filter.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/vis_type_agg_filter.ts @@ -26,8 +26,8 @@ const filterByName = propFilter('name'); * and limits available aggregations based on that. */ aggTypeFilters.addFilter( - (aggType: IAggType, indexPatterns: IndexPattern, aggConfig: IAggConfig) => { - const doesSchemaAllowAggType = filterByName([aggType], aggConfig.schema.aggFilter).length !== 0; + (aggType: IAggType, indexPatterns: IndexPattern, aggConfig: IAggConfig, aggFilter: string[]) => { + const doesSchemaAllowAggType = filterByName([aggType], aggFilter).length !== 0; return doesSchemaAllowAggType; } ); diff --git a/src/legacy/core_plugins/vis_type_table/public/table_vis_controller.test.ts b/src/legacy/core_plugins/vis_type_table/public/table_vis_controller.test.ts index 736152c7014dc..6d4e94c6292a6 100644 --- a/src/legacy/core_plugins/vis_type_table/public/table_vis_controller.test.ts +++ b/src/legacy/core_plugins/vis_type_table/public/table_vis_controller.test.ts @@ -113,24 +113,20 @@ describe('Table Vis - Controller', () => { return ({ type: tableVisTypeDefinition, params: Object.assign({}, tableVisTypeDefinition.visConfig.defaults, params), - aggs: createAggConfigs( - stubIndexPattern, - [ - { type: 'count', schema: 'metric' }, - { - type: 'range', - schema: 'bucket', - params: { - field: 'bytes', - ranges: [ - { from: 0, to: 1000 }, - { from: 1000, to: 2000 }, - ], - }, + aggs: createAggConfigs(stubIndexPattern, [ + { type: 'count', schema: 'metric' }, + { + type: 'range', + schema: 'bucket', + params: { + field: 'bytes', + ranges: [ + { from: 0, to: 1000 }, + { from: 1000, to: 2000 }, + ], }, - ], - tableVisTypeDefinition.editorConfig.schemas.all - ), + }, + ]), } as unknown) as Vis; } diff --git a/src/legacy/core_plugins/vis_type_timelion/public/components/timelion_interval.tsx b/src/legacy/core_plugins/vis_type_timelion/public/components/timelion_interval.tsx index 02783434bfdc2..13a57296bab7a 100644 --- a/src/legacy/core_plugins/vis_type_timelion/public/components/timelion_interval.tsx +++ b/src/legacy/core_plugins/vis_type_timelion/public/components/timelion_interval.tsx @@ -18,7 +18,7 @@ */ import React, { useMemo, useCallback } from 'react'; -import { EuiFormRow, EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { isValidEsInterval } from '../../../../core_plugins/data/common'; @@ -90,7 +90,7 @@ function TimelionInterval({ value, setValue, setValidity }: TimelionIntervalProp ); const onChange = useCallback( - (opts: Array>) => { + (opts: Array>) => { setValue((opts[0] && opts[0].value) || ''); }, [setValue] diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/icon_select/__snapshots__/icon_select.test.js.snap b/src/legacy/core_plugins/vis_type_timeseries/public/components/icon_select/__snapshots__/icon_select.test.js.snap index fd22bcafb8df4..d269f61beefab 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/icon_select/__snapshots__/icon_select.test.js.snap +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/icon_select/__snapshots__/icon_select.test.js.snap @@ -2,6 +2,7 @@ exports[`src/legacy/core_plugins/metrics/public/components/icon_select/icon_select.js should render and match a snapshot 1`] = ` diff --git a/src/legacy/core_plugins/vis_type_vislib/public/heatmap.ts b/src/legacy/core_plugins/vis_type_vislib/public/heatmap.ts index e2da7a3515deb..c8ce335f09e78 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/heatmap.ts +++ b/src/legacy/core_plugins/vis_type_vislib/public/heatmap.ts @@ -44,7 +44,7 @@ export interface HeatmapVisParams extends CommonVislibParams, ColorSchemaVislibP export const createHeatmapVisTypeDefinition = (deps: VisTypeVislibDependencies) => ({ name: 'heatmap', title: i18n.translate('visTypeVislib.heatmap.heatmapTitle', { defaultMessage: 'Heat Map' }), - icon: 'visHeatmap', + icon: 'heatmap', description: i18n.translate('visTypeVislib.heatmap.heatmapDescription', { defaultMessage: 'Shade cells within a matrix', }), diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.test.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.test.ts index afa638cdc5bf0..33b2da75b547e 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.test.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.test.ts @@ -383,9 +383,7 @@ describe('visualize loader pipeline helpers: build pipeline', () => { type: 'metrics', name: 'count', }, - schema: { - name: 'metric', - }, + schema: 'metric', params: {}, } as IAggConfig, ]; diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts index 1339e1f2fdfe8..069b5814908a8 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts @@ -142,10 +142,7 @@ const getSchemas = ( const metrics = responseAggs.filter((agg: IAggConfig) => agg.type.type === 'metrics'); responseAggs.forEach((agg: IAggConfig) => { let skipMetrics = false; - let schemaName = agg.schema ? agg.schema.name || agg.schema : null; - if (typeof schemaName === 'object') { - schemaName = null; - } + let schemaName = agg.schema; if (!schemaName) { if (agg.type.name === 'geo_centroid') { schemaName = 'geo_centroid'; @@ -155,7 +152,7 @@ const getSchemas = ( } } if (schemaName === 'split') { - schemaName = `split_${agg.params.row ? 'row' : 'column'}`; + schemaName = `split_${vis.params.row ? 'row' : 'column'}`; skipMetrics = responseAggs.length - metrics.length > 1; } if (!schemas[schemaName]) { diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.d.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.d.ts index 62b68082e21f8..0c4ea1572c4cd 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.d.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.d.ts @@ -20,6 +20,8 @@ import { Vis, VisState, VisParams } from './vis'; import { VisType } from './vis_types'; import { IIndexPattern } from '../../../../../../plugins/data/common'; +import { Schema } from '../../../../vis_default_editor/public'; +import { IAggConfig } from '../../../../data/public/search/aggs'; type InitVisStateType = | Partial @@ -44,6 +46,8 @@ export declare class VisImpl implements Vis { aggs: Array<{ [key: string]: any }>; }; + private initializeDefaultsFromSchemas(configStates: IAggConfig[], schemas: Schema[]); + // Since we haven't typed everything here yet, we basically "any" the rest // of that interface. This should be removed as soon as this type definition // has been completed. But that way we at least have typing for a couple of diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.js b/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.js index d5e6412b6bdab..abd8f351ae94d 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.js +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.js @@ -61,6 +61,23 @@ class VisImpl extends EventEmitter { }; } + initializeDefaultsFromSchemas(configStates, 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. + const newConfigs = [...configStates]; + schemas + .filter(schema => Array.isArray(schema.defaults) && schema.defaults.length > 0) + .filter(schema => !configStates.find(agg => agg.schema && agg.schema === schema.name)) + .forEach(schema => { + const defaults = schema.defaults.slice(0, schema.max); + defaults.forEach(d => newConfigs.push(d)); + }); + return newConfigs; + } + setCurrentState(state) { this.title = state.title || ''; const type = state.type || this.type; @@ -82,11 +99,9 @@ class VisImpl extends EventEmitter { updateVisualizationConfig(state.params, this.params); if (state.aggs || !this.aggs) { - this.aggs = getAggs().createAggConfigs( - this.indexPattern, - state.aggs ? state.aggs.aggs || state.aggs : [], - this.type.schemas.all - ); + let configStates = state.aggs ? state.aggs.aggs || state.aggs : []; + configStates = this.initializeDefaultsFromSchemas(configStates, this.type.schemas.all || []); + this.aggs = getAggs().createAggConfigs(this.indexPattern, configStates); } } diff --git a/src/legacy/ui/public/agg_types/index.ts b/src/legacy/ui/public/agg_types/index.ts index 9773b11086b78..db64bd025b8cb 100644 --- a/src/legacy/ui/public/agg_types/index.ts +++ b/src/legacy/ui/public/agg_types/index.ts @@ -52,7 +52,6 @@ export { BUCKET_TYPES, DateRangeKey, IpRangeKey, - ISchemas, METRIC_TYPES, OptionedParamEditorProps, OptionedValueProp, @@ -78,8 +77,8 @@ export { OptionedParamType, parentPipelineType, propFilter, - Schema, - Schemas, siblingPipelineType, termsAggFilter, } from '../../../core_plugins/data/public'; + +export { ISchemas, Schemas, Schema } from '../../../core_plugins/vis_default_editor/public/schemas'; diff --git a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap index dba1678339f24..88f30e03df052 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap +++ b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap @@ -1350,7 +1350,6 @@ exports[`Field for json setting should render as read only if saving is disabled "$blockScrolling": Infinity, } } - fullWidth={true} height="auto" isReadOnly={true} maxLines={30} @@ -1456,7 +1455,6 @@ exports[`Field for json setting should render as read only with help text if ove "$blockScrolling": Infinity, } } - fullWidth={true} height="auto" isReadOnly={true} maxLines={30} @@ -1538,7 +1536,6 @@ exports[`Field for json setting should render custom setting icon if it is custo "$blockScrolling": Infinity, } } - fullWidth={true} height="auto" isReadOnly={false} maxLines={30} @@ -1651,7 +1648,6 @@ exports[`Field for json setting should render default value if there is no user "$blockScrolling": Infinity, } } - fullWidth={true} height="auto" isReadOnly={false} maxLines={30} @@ -1740,7 +1736,6 @@ exports[`Field for json setting should render unsaved value if there are unsaved "$blockScrolling": Infinity, } } - fullWidth={true} height="auto" isReadOnly={false} maxLines={30} @@ -1864,7 +1859,6 @@ exports[`Field for json setting should render user value if there is user value "$blockScrolling": Infinity, } } - fullWidth={true} height="auto" isReadOnly={false} maxLines={30} @@ -1935,7 +1929,6 @@ exports[`Field for markdown setting should render as read only if saving is disa "$blockScrolling": Infinity, } } - fullWidth={true} height="auto" isReadOnly={true} maxLines={30} @@ -2038,7 +2031,6 @@ exports[`Field for markdown setting should render as read only with help text if "$blockScrolling": Infinity, } } - fullWidth={true} height="auto" isReadOnly={true} maxLines={30} @@ -2120,7 +2112,6 @@ exports[`Field for markdown setting should render custom setting icon if it is c "$blockScrolling": Infinity, } } - fullWidth={true} height="auto" isReadOnly={false} maxLines={30} @@ -2191,7 +2182,6 @@ exports[`Field for markdown setting should render default value if there is no u "$blockScrolling": Infinity, } } - fullWidth={true} height="auto" isReadOnly={false} maxLines={30} @@ -2280,7 +2270,6 @@ exports[`Field for markdown setting should render unsaved value if there are uns "$blockScrolling": Infinity, } } - fullWidth={true} height="auto" isReadOnly={false} maxLines={30} @@ -2397,7 +2386,6 @@ exports[`Field for markdown setting should render user value if there is user va "$blockScrolling": Infinity, } } - fullWidth={true} height="auto" isReadOnly={false} maxLines={30} diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx index 8e41fed685898..356e38c799659 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx @@ -363,7 +363,7 @@ describe('Field', () => { (component.instance() as Field).getImageAsBase64 = ({}: Blob) => Promise.resolve(''); it('should be able to change value and cancel', async () => { - (component.instance() as Field).onImageChange([userValue]); + (component.instance() as Field).onImageChange(([userValue] as unknown) as FileList); expect(handleChange).toBeCalled(); await wrapper.setProps({ unsavedChanges: { @@ -387,7 +387,9 @@ describe('Field', () => { const updated = wrapper.update(); findTestSubject(updated, `advancedSetting-changeImage-${setting.name}`).simulate('click'); const newUserValue = `${userValue}=`; - await (component.instance() as Field).onImageChange([newUserValue]); + await (component.instance() as Field).onImageChange(([ + newUserValue, + ] as unknown) as FileList); expect(handleChange).toBeCalled(); }); diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx index 18a1a365709d1..60d2b55dfceb4 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx @@ -90,7 +90,7 @@ export const getEditableValue = ( }; export class Field extends PureComponent { - private changeImageForm: EuiFilePicker | undefined = React.createRef(); + private changeImageForm = React.createRef(); getDisplayedDefaultValue( type: UiSettingsType, @@ -138,7 +138,7 @@ export class Field extends PureComponent { } } - onCodeEditorChange = (value: UiSettingsType) => { + onCodeEditorChange = (value: string) => { const { defVal, type } = this.props.setting; let newUnsavedValue; @@ -212,7 +212,9 @@ export class Field extends PureComponent { }); }; - onImageChange = async (files: any[]) => { + onImageChange = async (files: FileList | null) => { + if (files == null) return; + if (!files.length) { this.setState({ unsavedValue: null, @@ -278,9 +280,9 @@ export class Field extends PureComponent { }; cancelChangeImage = () => { - if (this.changeImageForm.current) { - this.changeImageForm.current.fileInput.value = null; - this.changeImageForm.current.handleChange({}); + if (this.changeImageForm.current?.fileInput) { + this.changeImageForm.current.fileInput.value = ''; + this.changeImageForm.current.handleChange(); } if (this.props.clearChange) { this.props.clearChange(this.props.setting.name); @@ -352,7 +354,6 @@ export class Field extends PureComponent { $blockScrolling: Infinity, }} showGutter={false} - fullWidth /> ); diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index e02045de24e8f..7fa6e88b427a9 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -24,4 +24,5 @@ export * from './index_patterns'; export * from './es_query'; export * from './utils'; export * from './types'; +export * from './search'; export * from './constants'; diff --git a/src/plugins/data/public/search/es_search/es_search_strategy.ts b/src/plugins/data/public/search/es_search/es_search_strategy.ts index 5382a59123e78..a61428c998157 100644 --- a/src/plugins/data/public/search/es_search/es_search_strategy.ts +++ b/src/plugins/data/public/search/es_search/es_search_strategy.ts @@ -30,11 +30,10 @@ export const esSearchStrategyProvider: TSearchStrategyProvider { - if (typeof request.params.preference === 'undefined') { - const setPreference = context.core.uiSettings.get('courier:setRequestPreference'); - const customPreference = context.core.uiSettings.get('courier:customRequestPreference'); - request.params.preference = getEsPreference(setPreference, customPreference); - } + request.params = { + preference: getEsPreference(context.core.uiSettings), + ...request.params, + }; return search({ ...request, serverStrategy: ES_SEARCH_STRATEGY }, options) as Observable< IEsSearchResponse >; diff --git a/src/plugins/data/public/search/es_search/get_es_preference.test.ts b/src/plugins/data/public/search/es_search/get_es_preference.test.ts index 27e6f9b48bbdd..8b8156b4519d6 100644 --- a/src/plugins/data/public/search/es_search/get_es_preference.test.ts +++ b/src/plugins/data/public/search/es_search/get_es_preference.test.ts @@ -18,29 +18,40 @@ */ import { getEsPreference } from './get_es_preference'; - -jest.useFakeTimers(); +import { CoreStart } from '../../../../../core/public'; +import { coreMock } from '../../../../../core/public/mocks'; describe('Get ES preference', () => { + let mockCoreStart: MockedKeys; + + beforeEach(() => { + mockCoreStart = coreMock.createStart(); + }); + test('returns the session ID if set to sessionId', () => { - const setPreference = 'sessionId'; - const customPreference = 'foobar'; - const sessionId = 'my_session_id'; - const preference = getEsPreference(setPreference, customPreference, sessionId); - expect(preference).toBe(sessionId); + mockCoreStart.uiSettings.get.mockImplementation((key: string) => { + if (key === 'courier:setRequestPreference') return 'sessionId'; + if (key === 'courier:customRequestPreference') return 'foobar'; + }); + const preference = getEsPreference(mockCoreStart.uiSettings, 'my_session_id'); + expect(preference).toBe('my_session_id'); }); test('returns the custom preference if set to custom', () => { - const setPreference = 'custom'; - const customPreference = 'foobar'; - const preference = getEsPreference(setPreference, customPreference); - expect(preference).toBe(customPreference); + mockCoreStart.uiSettings.get.mockImplementation((key: string) => { + if (key === 'courier:setRequestPreference') return 'custom'; + if (key === 'courier:customRequestPreference') return 'foobar'; + }); + const preference = getEsPreference(mockCoreStart.uiSettings); + expect(preference).toBe('foobar'); }); test('returns undefined if set to none', () => { - const setPreference = 'none'; - const customPreference = 'foobar'; - const preference = getEsPreference(setPreference, customPreference); + mockCoreStart.uiSettings.get.mockImplementation((key: string) => { + if (key === 'courier:setRequestPreference') return 'none'; + if (key === 'courier:customRequestPreference') return 'foobar'; + }); + const preference = getEsPreference(mockCoreStart.uiSettings); expect(preference).toBe(undefined); }); }); diff --git a/src/plugins/data/public/search/es_search/get_es_preference.ts b/src/plugins/data/public/search/es_search/get_es_preference.ts index 200e5bacb7f18..3f1c2b9b3b736 100644 --- a/src/plugins/data/public/search/es_search/get_es_preference.ts +++ b/src/plugins/data/public/search/es_search/get_es_preference.ts @@ -17,13 +17,13 @@ * under the License. */ +import { IUiSettingsClient } from '../../../../../core/public'; + const defaultSessionId = `${Date.now()}`; -export function getEsPreference( - setRequestPreference: string, - customRequestPreference?: string, - sessionId: string = defaultSessionId -) { - if (setRequestPreference === 'sessionId') return `${sessionId}`; - return setRequestPreference === 'custom' ? customRequestPreference : undefined; +export function getEsPreference(uiSettings: IUiSettingsClient, sessionId = defaultSessionId) { + const setPreference = uiSettings.get('courier:setRequestPreference'); + if (setPreference === 'sessionId') return `${sessionId}`; + const customPreference = uiSettings.get('courier:customRequestPreference'); + return setPreference === 'custom' ? customPreference : undefined; } diff --git a/src/plugins/data/public/search/es_search/index.ts b/src/plugins/data/public/search/es_search/index.ts new file mode 100644 index 0000000000000..41c6ec388bfaf --- /dev/null +++ b/src/plugins/data/public/search/es_search/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { esSearchStrategyProvider } from './es_search_strategy'; +export { getEsPreference } from './get_es_preference'; diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index 853dbd09e1f93..2a54cfe2be785 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -36,6 +36,7 @@ export { export { IEsSearchResponse, IEsSearchRequest, ES_SEARCH_STRATEGY } from '../../common/search'; export { ISyncSearchRequest, SYNC_SEARCH_STRATEGY } from './sync_search_strategy'; +export { esSearchStrategyProvider, getEsPreference } from './es_search'; export { IKibanaSearchResponse, IKibanaSearchRequest } from '../../common/search'; diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/generic_combo_box.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/generic_combo_box.tsx index 9d541af5a1d17..a5db8b66caa01 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/generic_combo_box.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/generic_combo_box.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import React from 'react'; export interface GenericComboBoxProps { @@ -38,7 +38,7 @@ export function GenericComboBox(props: GenericComboBoxProps) { const { options, selectedOptions, getLabel, onChange, ...otherProps } = props; const labels = options.map(getLabel); - const euiOptions: EuiComboBoxOptionProps[] = labels.map(label => ({ label })); + const euiOptions: EuiComboBoxOptionOption[] = labels.map(label => ({ label })); const selectedEuiOptions = selectedOptions .filter(option => { return options.indexOf(option) !== -1; @@ -47,7 +47,7 @@ export function GenericComboBox(props: GenericComboBoxProps) { return euiOptions[options.indexOf(option)]; }); - const onComboBoxChange = (newOptions: EuiComboBoxOptionProps[]) => { + const onComboBoxChange = (newOptions: EuiComboBoxOptionOption[]) => { const newValues = newOptions.map(({ label }) => { return options[labels.indexOf(label)]; }); diff --git a/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx b/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx index 829c8205a8b52..c56060bb9c288 100644 --- a/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx +++ b/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx @@ -39,7 +39,7 @@ export type IndexPatternSelectProps = Required< interface IndexPatternSelectState { isLoading: boolean; options: []; - selectedIndexPattern: string | undefined; + selectedIndexPattern: { value: string; label: string } | undefined; searchValue: string | undefined; } diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index b7ec02871306c..18ba1130cc26a 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -151,8 +151,16 @@ export { * Search */ -export { IRequestTypesMap, IResponseTypesMap } from './search'; -export * from './search'; +export { + ISearch, + ICancel, + ISearchOptions, + IRequestTypesMap, + IResponseTypesMap, + ISearchContext, + TSearchStrategyProvider, + getDefaultSearchParams, +} from './search'; /** * Types to be shared externally diff --git a/src/plugins/data/server/search/create_api.test.ts b/src/plugins/data/server/search/create_api.test.ts index 99e48056ef857..0cf68b7e020ce 100644 --- a/src/plugins/data/server/search/create_api.test.ts +++ b/src/plugins/data/server/search/create_api.test.ts @@ -23,8 +23,6 @@ import { TSearchStrategiesMap } from './i_search_strategy'; import { IRouteHandlerSearchContext } from './i_route_handler_search_context'; import { DEFAULT_SEARCH_STRATEGY } from '../../common/search'; -// let mockCoreSetup: MockedKeys; - const mockDefaultSearch = jest.fn(() => Promise.resolve({ total: 100, loaded: 0 })); const mockDefaultSearchStrategyProvider = jest.fn(() => Promise.resolve({ @@ -59,4 +57,15 @@ describe('createApi', () => { `"No strategy found for noneByThisName"` ); }); + + it('logs the response if `debug` is set to `true`', async () => { + const spy = jest.spyOn(console, 'log'); + await api.search({ params: {} }); + + expect(spy).not.toBeCalled(); + + await api.search({ debug: true, params: {} }); + + expect(spy).toBeCalled(); + }); }); diff --git a/src/plugins/data/server/search/create_api.ts b/src/plugins/data/server/search/create_api.ts index 798a4b82caaef..00665b21f2ba7 100644 --- a/src/plugins/data/server/search/create_api.ts +++ b/src/plugins/data/server/search/create_api.ts @@ -31,6 +31,10 @@ export function createApi({ }) { const api: IRouteHandlerSearchContext = { search: async (request, options, strategyName) => { + if (request.debug) { + // eslint-disable-next-line + console.log(JSON.stringify(request, null, 2)); + } const name = strategyName ?? DEFAULT_SEARCH_STRATEGY; const strategyProvider = searchStrategies[name]; if (!strategyProvider) { diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.test.ts b/src/plugins/data/server/search/es_search/es_search_strategy.test.ts index 99ccb4dcbebab..c4b8119f9e095 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.test.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.test.ts @@ -51,24 +51,6 @@ describe('ES search strategy', () => { expect(typeof esSearch.search).toBe('function'); }); - it('logs the response if `debug` is set to `true`', async () => { - const spy = jest.spyOn(console, 'log'); - const esSearch = esSearchStrategyProvider( - { - core: mockCoreSetup, - config$: mockConfig$, - }, - mockApiCaller, - mockSearch - ); - - expect(spy).not.toBeCalled(); - - await esSearch.search({ params: {}, debug: true }); - - expect(spy).toBeCalled(); - }); - it('calls the API caller with the params with defaults', async () => { const params = { index: 'logstash-*' }; const esSearch = esSearchStrategyProvider( diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.ts b/src/plugins/data/server/search/es_search/es_search_strategy.ts index 20bc964effc02..26055a3ae41f7 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.ts @@ -21,7 +21,7 @@ import { APICaller } from 'kibana/server'; import { SearchResponse } from 'elasticsearch'; import { ES_SEARCH_STRATEGY } from '../../../common/search'; import { ISearchStrategy, TSearchStrategyProvider } from '../i_search_strategy'; -import { ISearchContext } from '..'; +import { getDefaultSearchParams, ISearchContext } from '..'; export const esSearchStrategyProvider: TSearchStrategyProvider = ( context: ISearchContext, @@ -30,28 +30,18 @@ export const esSearchStrategyProvider: TSearchStrategyProvider { const config = await context.config$.pipe(first()).toPromise(); + const defaultParams = getDefaultSearchParams(config); const params = { - timeout: `${config.elasticsearch.shardTimeout.asMilliseconds()}ms`, - ignoreUnavailable: true, // Don't fail if the index/indices don't exist - restTotalHitsAsInt: true, // Get the number of hits as an int rather than a range + ...defaultParams, ...request.params, }; - if (request.debug) { - // eslint-disable-next-line - console.log(JSON.stringify(params, null, 2)); - } - const esSearchResponse = (await caller('search', params, options)) as SearchResponse; + const rawResponse = (await caller('search', params, options)) as SearchResponse; // The above query will either complete or timeout and throw an error. // There is no progress indication on this api. - return { - total: esSearchResponse._shards.total, - loaded: - esSearchResponse._shards.failed + - esSearchResponse._shards.skipped + - esSearchResponse._shards.successful, - rawResponse: esSearchResponse, - }; + const { total, failed, successful } = rawResponse._shards; + const loaded = failed + successful; + return { total, loaded, rawResponse }; }, }; }; diff --git a/src/plugins/data/server/search/es_search/get_default_search_params.ts b/src/plugins/data/server/search/es_search/get_default_search_params.ts new file mode 100644 index 0000000000000..b2341ccc0f3c8 --- /dev/null +++ b/src/plugins/data/server/search/es_search/get_default_search_params.ts @@ -0,0 +1,28 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SharedGlobalConfig } from '../../../../../core/server'; + +export function getDefaultSearchParams(config: SharedGlobalConfig) { + return { + timeout: `${config.elasticsearch.shardTimeout.asMilliseconds()}ms`, + ignoreUnavailable: true, // Don't fail if the index/indices don't exist + restTotalHitsAsInt: true, // Get the number of hits as an int rather than a range + }; +} diff --git a/src/plugins/data/server/search/es_search/index.ts b/src/plugins/data/server/search/es_search/index.ts index e5dcb0c97d7c9..5a8b3bc94c679 100644 --- a/src/plugins/data/server/search/es_search/index.ts +++ b/src/plugins/data/server/search/es_search/index.ts @@ -17,6 +17,6 @@ * under the License. */ -export { esSearchStrategyProvider } from './es_search_strategy'; - export { ES_SEARCH_STRATEGY, IEsSearchRequest, IEsSearchResponse } from '../../../common/search'; +export { esSearchStrategyProvider } from './es_search_strategy'; +export { getDefaultSearchParams } from './get_default_search_params'; diff --git a/src/plugins/data/server/search/index.ts b/src/plugins/data/server/search/index.ts index 298a665fd5b2c..385e96ee803b6 100644 --- a/src/plugins/data/server/search/index.ts +++ b/src/plugins/data/server/search/index.ts @@ -21,8 +21,10 @@ export { ISearchSetup } from './i_search_setup'; export { ISearchContext } from './i_search_context'; -export { IRequestTypesMap, IResponseTypesMap } from './i_search'; +export { ISearch, ICancel, ISearchOptions, IRequestTypesMap, IResponseTypesMap } from './i_search'; export { TStrategyTypes } from './strategy_types'; export { TSearchStrategyProvider } from './i_search_strategy'; + +export { getDefaultSearchParams } from './es_search'; diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/combobox_field.tsx b/src/plugins/es_ui_shared/static/forms/components/fields/combobox_field.tsx index 3613867950098..a10da62fa6906 100644 --- a/src/plugins/es_ui_shared/static/forms/components/fields/combobox_field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/fields/combobox_field.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFormRow, EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { FieldHook, VALIDATION_TYPES, FieldValidateResponse } from '../../hook_form_lib'; @@ -69,7 +69,7 @@ export const ComboBoxField = ({ field, euiFieldProps = {}, ...rest }: Props) => field.setValue(newValue); }; - const onComboChange = (options: EuiComboBoxOptionProps[]) => { + const onComboChange = (options: EuiComboBoxOptionOption[]) => { field.setValue(options.map(option => option.label)); }; diff --git a/src/plugins/es_ui_shared/static/forms/helpers/de_serializers.ts b/src/plugins/es_ui_shared/static/forms/helpers/de_serializers.ts index f4b528e681d43..274aa82b31834 100644 --- a/src/plugins/es_ui_shared/static/forms/helpers/de_serializers.ts +++ b/src/plugins/es_ui_shared/static/forms/helpers/de_serializers.ts @@ -16,10 +16,10 @@ * specific language governing permissions and limitations * under the License. */ -import { Option } from '@elastic/eui/src/components/selectable/types'; +import { EuiSelectableOption } from '@elastic/eui'; import { SerializerFunc } from '../hook_form_lib'; -type FuncType = (selectOptions: Option[]) => SerializerFunc; +type FuncType = (selectOptions: EuiSelectableOption[]) => SerializerFunc; export const multiSelectComponent: Record = { // This deSerializer takes the previously selected options and map them @@ -31,7 +31,7 @@ export const multiSelectComponent: Record = { return selectOptions; } - return (selectOptions as Option[]).map(option => ({ + return (selectOptions as EuiSelectableOption[]).map(option => ({ ...option, checked: (defaultFormValue as string[]).includes(option.label) ? 'on' : undefined, })); diff --git a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/min_selectable_selection.ts b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/min_selectable_selection.ts index a10371d08ad5a..8f75c45df6c4a 100644 --- a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/min_selectable_selection.ts +++ b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/min_selectable_selection.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Option } from '@elastic/eui/src/components/selectable/types'; +import { EuiSelectableOption } from '@elastic/eui'; import { ValidationFunc, ValidationError } from '../../hook_form_lib'; import { hasMinLengthArray } from '../../../validators/array'; @@ -42,7 +42,7 @@ export const minSelectableSelectionField = ({ // We need to convert all the options from the multi selectable component, to the // an actual Array of selection _before_ validating the Array length. - return hasMinLengthArray(total)(optionsToSelectedValue(value as Option[])) + return hasMinLengthArray(total)(optionsToSelectedValue(value as EuiSelectableOption[])) ? undefined : { code: 'ERR_MIN_SELECTION', diff --git a/src/plugins/es_ui_shared/static/forms/helpers/serializers.ts b/src/plugins/es_ui_shared/static/forms/helpers/serializers.ts index 0bb89cc1af593..bae6b4c2652ca 100644 --- a/src/plugins/es_ui_shared/static/forms/helpers/serializers.ts +++ b/src/plugins/es_ui_shared/static/forms/helpers/serializers.ts @@ -36,7 +36,7 @@ * ```` */ -import { Option } from '@elastic/eui/src/components/selectable/types'; +import { EuiSelectableOption } from '@elastic/eui'; import { SerializerFunc } from '../hook_form_lib'; export const multiSelectComponent: Record> = { @@ -45,7 +45,7 @@ export const multiSelectComponent: Record> = { * * @param value The Eui Selectable options array */ - optionsToSelectedValue(options: Option[]): string[] { + optionsToSelectedValue(options: EuiSelectableOption[]): string[] { return options.filter(option => option.checked === 'on').map(option => option.label); }, }; diff --git a/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap b/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap index 9cf725a2faa73..fcd03df5637d0 100644 --- a/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap +++ b/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap @@ -326,21 +326,25 @@ exports[`InspectorPanel should render as expected 1`] = `
- -

- View 1 -

-
+ +

+ View 1 +

+
+
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/services/elastic_chart.ts b/test/functional/services/elastic_chart.ts index 45ad157fc5c02..1c3071ac01587 100644 --- a/test/functional/services/elastic_chart.ts +++ b/test/functional/services/elastic_chart.ts @@ -51,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/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() : []} > { 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 { 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" >
-

- Element controls -

-
-
-
- Cut -
-
- - - - CTRL - - - - - - X - - - -
-
- Copy -
-
- - - - CTRL - - - - - - C - - - -
-
- Paste -
-
- - - - CTRL - - - - - - V - - - -
-
- Clone -
-
- - - - CTRL - - - - - - D - - - -
-
- Delete -
-
- - - - DEL - - - - - - or - - - - - - BACKSPACE - - - -
-
- Bring forward -
-
- - - - CTRL - - - - - - ↑ - - - -
-
- Bring to front -
-
- - - - CTRL - - - - - - SHIFT - - - - - - ↑ - - - -
-
- Send backward -
-
- - - - CTRL - - - - - - ↓ - - - -
-
- Send to back -
-
- - - - CTRL - - - - - - SHIFT - - - - - - ↓ - - - -
-
- Group -
-
- - - - G - - - -
-
- Ungroup -
-
- - - - U - - - -
-
- Shift up by 10px -
-
- - - - ↑ - - - -
-
- Shift down by 10px -
-
- - - - ↓ - - - -
-
- Shift left by 10px -
-
- - - - ← - - - -
-
- Shift right by 10px -
-
- - - - → - - - -
-
- Shift up by 1px -
-
- - - - SHIFT - - - - - - ↑ - - - -
-
- Shift down by 1px -
-
- - - - SHIFT - - - - - - ↓ - - - -
-
- Shift left by 1px -
-
- - - - SHIFT - - - - - - ← - - - -
-
- Shift right by 1px -
-
- - - - SHIFT - - - - - - → - - - -
-
-
-
-

- Expression controls -

-
-
-
- Run whole expression -
-
- - - - CTRL - - - - - - ENTER - - - -
-
+

+ Element controls +

+
+
+
+ Cut +
+
+ + + + CTRL + + + + + + X + + + +
+
+ Copy +
+
+ + + + CTRL + + + + + + C + + + +
+
+ Paste +
+
+ + + + CTRL + + + + + + V + + + +
+
+ Clone +
+
+ + + + CTRL + + + + + + D + + + +
+
+ Delete +
+
+ + + + DEL + + + + + + or + + + + + + BACKSPACE + + + +
+
+ Bring forward +
+
+ + + + CTRL + + + + + + ↑ + + + +
+
+ Bring to front +
+
+ + + + CTRL + + + + + + SHIFT + + + + + + ↑ + + + +
+
+ Send backward +
+
+ + + + CTRL + + + + + + ↓ + + + +
+
+ Send to back +
+
+ + + + CTRL + + + + + + SHIFT + + + + + + ↓ + + + +
+
+ Group +
+
+ + + + G + + + +
+
+ Ungroup +
+
+ + + + U + + + +
+
+ Shift up by 10px +
+
+ + + + ↑ + + + +
+
+ Shift down by 10px +
+
+ + + + ↓ + + + +
+
+ Shift left by 10px +
+
+ + + + ← + + + +
+
+ Shift right by 10px +
+
+ + + + → + + + +
+
+ Shift up by 1px +
+
+ + + + SHIFT + + + + + + ↑ + + + +
+
+ Shift down by 1px +
+
+ + + + SHIFT + + + + + + ↓ + + + +
+
+ Shift left by 1px +
+
+ + + + SHIFT + + + + + + ← + + + +
+
+ Shift right by 1px +
+
+ + + + SHIFT + + + + + + → + + + +
+
+
+
-
-
-

- Editor controls -

-
-
-
- Select multiple elements -
-
- - - - SHIFT - - - - - - CLICK - - - -
-
- Resize from center -
-
- - - - ALT - - - - - - DRAG - - - -
-
- Move, resize, and rotate without snapping -
-
- - - - CTRL - - - - - - DRAG - - - -
-
- Select element below -
-
- - - - CTRL - - - - - - CLICK - - - -
-
- Undo last action -
-
- - - - CTRL - - - - - - Z - - - -
-
- Redo last action -
-
- - - - CTRL - - - - - - SHIFT - - - - - - Z - - - -
-
- Go to previous page -
-
- - - - ALT - - - - - - [ - - - -
-
- Go to next page -
-
- - - - ALT - - - - - - ] - - - -
-
- Toggle edit mode -
-
- - - - ALT - - - - - - E - - - -
-
- Show grid -
-
- - - - ALT - - - - - - G - - - -
-
- Refresh workpad -
-
- - - - ALT - - - - - - R - - - -
-
- Zoom in -
-
- - - - CTRL - - - - - - ALT - - - - - - + - - - -
-
- Zoom out -
-
- - - - CTRL - - - - - - ALT - - - - - - - - - - -
-
- Reset zoom to 100% -
-
- - - - CTRL - - - - - - ALT - - - - - - [ - - - -
-
- Enter presentation mode -
-
- - - - ALT - - - - - - F - - - - - - or - - - - - - ALT - - - - - - P - - - -
-
+

+ Expression controls +

+
+
+
+ Run whole expression +
+
+ + + + CTRL + + + + + + ENTER + + + +
+
+
+
-
-
-

- Presentation controls -

-
-
-
- Enter presentation mode -
-
- - - - ALT - - - - - - F - - - - - - or - - - - - - ALT - - - - - - P - - - -
-
- Exit presentation mode -
-
- - - - ESC - - - -
-
- Go to previous page -
-
- - - - ALT - - - - - - [ - - - - - - or - - - - - - BACKSPACE - - - - - - or - - - - - - ← - - - -
-
- Go to next page -
-
- - - - ALT - - - - - - ] - - - - - - or - - - - - - SPACE - - - - - - or - - - - - - → - - - -
-
- Refresh workpad -
-
- - - - ALT - - - - - - R - - - -
-
- Toggle page cycling -
-
- - - - P - - - -
-
+

+ Editor controls +

+
+
+
+ Select multiple elements +
+
+ + + + SHIFT + + + + + + CLICK + + + +
+
+ Resize from center +
+
+ + + + ALT + + + + + + DRAG + + + +
+
+ Move, resize, and rotate without snapping +
+
+ + + + CTRL + + + + + + DRAG + + + +
+
+ Select element below +
+
+ + + + CTRL + + + + + + CLICK + + + +
+
+ Undo last action +
+
+ + + + CTRL + + + + + + Z + + + +
+
+ Redo last action +
+
+ + + + CTRL + + + + + + SHIFT + + + + + + Z + + + +
+
+ Go to previous page +
+
+ + + + ALT + + + + + + [ + + + +
+
+ Go to next page +
+
+ + + + ALT + + + + + + ] + + + +
+
+ Toggle edit mode +
+
+ + + + ALT + + + + + + E + + + +
+
+ Show grid +
+
+ + + + ALT + + + + + + G + + + +
+
+ Refresh workpad +
+
+ + + + ALT + + + + + + R + + + +
+
+ Zoom in +
+
+ + + + CTRL + + + + + + ALT + + + + + + + + + + +
+
+ Zoom out +
+
+ + + + CTRL + + + + + + ALT + + + + + + - + + + +
+
+ Reset zoom to 100% +
+
+ + + + CTRL + + + + + + ALT + + + + + + [ + + + +
+
+ Enter presentation mode +
+
+ + + + ALT + + + + + + F + + + + + + or + + + + + + ALT + + + + + + P + + + +
+
+
+
+ className="canvasKeyboardShortcut" + > +

+ Presentation controls +

+
+
+
+ Enter presentation mode +
+
+ + + + ALT + + + + + + F + + + + + + or + + + + + + ALT + + + + + + P + + + +
+
+ Exit presentation mode +
+
+ + + + ESC + + + +
+
+ Go to previous page +
+
+ + + + ALT + + + + + + [ + + + + + + or + + + + + + BACKSPACE + + + + + + or + + + + + + ← + + + +
+
+ Go to next page +
+
+ + + + ALT + + + + + + ] + + + + + + or + + + + + + SPACE + + + + + + or + + + + + + → + + + +
+
+ Refresh workpad +
+
+ + + + ALT + + + + + + R + + + +
+
+ Toggle page cycling +
+
+ + + + P + + + +
+
+
+
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> - } + options={toOptions(allFields, initialField)} selectedOptions={[ { value: currentField.name, label: currentField.name, - type: currentField.type, + type: currentField.type as ButtonHTMLAttributes['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['type'] }> { return fields .filter(field => !field.selected || field === currentField) .map(({ name, type }) => ({ label: name, value: name, - type, + type: type as ButtonHTMLAttributes['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 { + 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 { + 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 { + 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 { + // TODO remove when esDocField is typed + // @ts-ignore + return this._esDocField.getOrdinalFieldMetaRequest(); + } + + async getCategoricalFieldMetaRequest(): Promise { + // 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; getDataType(): Promise; + 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 { + async getOrdinalFieldMetaRequest(): Promise { 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 { + const baseLabel = await this._topTermAggField.getLabel(); + return `${baseLabel}%`; + } + + isValid(): boolean { + return this._topTermAggField.isValid(); + } + + async getDataType(): Promise { + return 'number'; + } + + async createTooltipProperty(value: unknown): Promise { + 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; +} + +export class AbstractESSource extends AbstractVectorSource implements IESSource { + getIndexPattern(): Promise; +} 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/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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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" > = ({ }); }; - 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 = ({ }); 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/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 = ({ 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 = memo( ({ existingGroups, selectedGroups, onChange, validation }) => { - const options = existingGroups.map(g => ({ + const options = existingGroups.map(g => ({ label: g, color: tabColor(g), })); - const selectedOptions = selectedGroups.map(g => ({ + const selectedOptions = selectedGroups.map(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 = ({ 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(jobCreator.calendars); - const [selectedOptions, setSelectedOptions] = useState>>( + const [selectedOptions, setSelectedOptions] = useState>>( [] ); - const [options, setOptions] = useState>>([]); + const [options, setOptions] = useState>>([]); 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 = ({ payload, @@ -90,7 +90,7 @@ export const AdvancedDetectorModal: FC = ({ 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 = ({ 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 = ({ ); }; -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 = ({ 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 = ({ 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 = ({ 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 = ({ testSubject, placeholder, }) => { - const options: EuiComboBoxOptionProps[] = fields.map( + const options: EuiComboBoxOptionOption[] = fields.map( f => ({ label: f.name, @@ -39,12 +39,12 @@ export const SplitFieldSelect: FC = ({ } 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 = ({ 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/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 { @@ -53,7 +53,7 @@ export class EntityControl extends Component 0) || (Array.isArray(selectedOptions) && @@ -84,7 +84,7 @@ export class EntityControl extends Component { + onChange = (selectedOptions: EuiComboBoxOptionOption[]) => { const options = selectedOptions.length > 0 ? selectedOptions : undefined; this.setState({ selectedOptions: options, 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; -}; +} + +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> => { - 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 => { - const elementsPositionAndAttributes: ElementsPositionAndAttribute[] = await browser.evaluate( - { - fn: (selector: string, attributes: any) => { - const elements: NodeListOf = document.querySelectorAll(selector); +): Promise => { + const { screenshot: screenshotSelector } = layout.selectors; // data-shared-items-container + let elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null; + try { + elementsPositionAndAttributes = await browser.evaluate( + { + fn: (selector, attributes) => { + const elements: NodeListOf = 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 => { - 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) => { 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 => { - logger.info(`taking screenshots`); +export const getScreenshots = async ( + browser: HeadlessBrowser, + elementsPositionAndAttributes: ElementsPositionAndAttribute[], + logger: LevelLogger +): Promise => { + 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 => { - 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 = 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 => { - 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 => { + 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 => { - 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 => { - 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; + +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 => { + 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 = 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 { + ): 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>; @@ -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 = 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 { + ): 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" >
- Could not fetch the job info +
+ Could not fetch the job info +
@@ -243,9 +247,13 @@ Array [ class="euiFlyoutBody__overflow" >
- Could not fetch the job info +
+ Could not fetch the job info +
@@ -332,13 +340,17 @@ Array [
- -
- Could not fetch the job info -
-
+
+ +
+ Could not fetch the job info +
+
+
@@ -440,13 +452,17 @@ Array [
- -
- Could not fetch the job info -
-
+
+ +
+ Could not fetch the job info +
+
+
@@ -599,8 +615,12 @@ Array [ class="euiFlyoutBody__overflow" >
+ class="euiFlyoutBody__overflowContent" + > +
+
@@ -658,8 +678,12 @@ Array [ class="euiFlyoutBody__overflow" >
+ class="euiFlyoutBody__overflowContent" + > +
+
@@ -745,11 +769,15 @@ Array [
- -
- +
+ +
+ +
@@ -851,11 +879,15 @@ Array [
- -
- +
+ +
+ +
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 { 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 { return (
@@ -215,13 +216,27 @@ class ReportListingUi extends Component { maxSizeReached = ( ); } + let warnings; + if (record.warnings) { + warnings = ( + + + + + + ); + } + let statusTimestamp; if (status === JobStatuses.PROCESSING && record.started_at) { statusTimestamp = this.formatDate(record.started_at); @@ -242,7 +257,7 @@ class ReportListingUi extends Component { return (
{ }} /> {maxSizeReached} + {warnings}
); } @@ -259,6 +275,7 @@ class ReportListingUi extends Component {
{statusLabel} {maxSizeReached} + {warnings}
); }, @@ -437,6 +454,7 @@ class ReportListingUi extends Component { 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 { 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 { 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> { + 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( + public async waitFor( { fn, args, toEqual, + timeout, }: { fn: EvaluateFn; args: SerializableOrJSHandle[]; - toEqual: T; + toEqual: number; + timeout: number; }, context: EvaluateMetaOpts, logger: LevelLogger - ) { + ): Promise { + 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 { 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 47e739e16c5c1..11b70c82f6fa8 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 @@ -19,7 +19,7 @@ import { import * as Rx from 'rxjs'; import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber'; import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators'; -import { BrowserConfig, NetworkPolicy } from '../../../../types'; +import { BrowserConfig, CaptureConfig } from '../../../../types'; import { LevelLogger as Logger } from '../../../lib/level_logger'; import { safeChildProcess } from '../../safe_child_process'; import { HeadlessChromiumDriver } from '../driver'; @@ -28,30 +28,27 @@ import { puppeteerLaunch } from '../puppeteer'; 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, @@ -89,7 +86,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 }> { return Rx.Observable.create(async (observer: InnerSubscriber) => { @@ -114,11 +111,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) { @@ -159,7 +154,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: 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 { - 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, any[]>; @@ -19,7 +19,7 @@ interface CreateMockBrowserDriverFactoryOpts { getCreatePage: (driver: HeadlessChromiumDriver) => jest.Mock; } -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 { 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/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): 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( timelineId, value, }) => { - const [updatedField, setUpdatedField] = useState([{ label: field }]); - const [updatedOperator, setUpdatedOperator] = useState( + const [updatedField, setUpdatedField] = useState([{ label: field }]); + const [updatedOperator, setUpdatedOperator] = useState( getInitialOperatorLabel(isExcluded, operator) ); const [updatedValue, setUpdatedValue] = useState(value); @@ -105,13 +105,13 @@ export const StatefulEditDataProvider = React.memo( } }; - 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>; + options: string[]; + optionsEmptyLabel: string; + selectedOptions: string[]; +} + +const ScrollableDiv = styled.div` + max-height: 250px; + overflow: auto; +`; + +export const toggleSelectedGroup = ( + group: string, + selectedGroups: string[], + setSelectedGroups: Dispatch> +): 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 ( + 0} + numActiveFilters={selectedOptions.length} + > + {buttonLabel} + + } + isOpen={isPopoverOpen} + closePopover={setIsPopoverOpenCb} + panelPaddingSize="none" + > + + {options.map((option, index) => ( + + {option} + + ))} + + {options.length === 0 && ( + + + + {optionsEmptyLabel} + + + + )} + + ); +}; + +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 & { 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 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( 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 => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { + const response = await KibanaServices.get().http.fetch(`${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(response.body!); + return convertToCamelCase(decodeCaseResponse(response.body)); +}; + +export const getTags = async (): Promise => { + const response = await KibanaServices.get().http.fetch(`${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 => { - 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(`${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 => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { +export const postCase = async (newCase: CaseRequest): Promise => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { method: 'POST', asResponse: true, body: JSON.stringify(newCase), }); await throwIfNotOk(response.response); - return convertToCamelCase(response.body!); + return convertToCamelCase(decodeCaseResponse(response.body)); }; -export const updateCaseProperty = async ( +export const patchCase = async ( caseId: string, - updatedCase: Partial, + updatedCase: Partial, version: string -): Promise> => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { +): Promise => { + 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>(response.body!); + return convertToCamelCase(decodeCaseResponse(response.body)); }; -export const createComment = async (newComment: NewComment, caseId: string): Promise => { - 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 => { + const response = await KibanaServices.get().http.fetch( + `${CASES_URL}/${caseId}/comments`, + { + method: 'POST', + asResponse: true, + body: JSON.stringify(newComment), + } + ); await throwIfNotOk(response.response); - return convertToCamelCase(response.body!); + return convertToCamelCase(decodeCommentResponse(response.body)); }; -export const updateComment = async ( +export const patchComment = async ( commentId: string, commentUpdate: string, version: string ): Promise> => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}/comment/${commentId}`, { + const response = await KibanaServices.get().http.fetch(`${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>(response.body!); + return convertToCamelCase(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 | 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 } + | { 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 } + | { 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(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(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>>, - Dispatch> -] => { +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>(state.queryParams); - const [filterQuery, setFilters] = useState(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(action.payload), - }; case FETCH_SUCCESS: return { ...state, isLoading: false, isError: false, - newCase: getTypedPayload(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>] => { +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(action.payload), - }; case FETCH_SUCCESS: return { ...state, isLoading: false, isError: false, - newComment: getTypedPayload(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>] => { +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 | 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(action.payload), - }, + caseData: getTypedPayload(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(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 = (a: unknown): T => a as T; @@ -32,9 +46,20 @@ export const convertToCamelCase = (snakeCase: T): U => return acc; }, {} as U); -export const convertAllCasesToCamel = (snakeCases: AllCasesSnake): AllCases => ({ - cases: snakeCases.cases.map(snakeCase => convertToCamelCase(snakeCase)), +export const convertAllCasesToCamel = (snakeCases: CasesResponse): AllCases => ({ + cases: snakeCases.cases.map(snakeCase => convertToCamelCase(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(() => ( <> - - - - - {i18n.CREATE_TITLE} - - - - - - - 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({ + 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<{ }} /> - {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 = { 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; +} + +export const getActions = ({ + caseStatus, + dispatchUpdate, +}: GetActions): Array> => [ + { + 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 | EuiTableComputedColumnType; +export type CasesColumns = + | EuiTableFieldDataColumnType + | EuiTableComputedColumnType + | EuiTableActionsColumnType; -const renderStringField = (field: string, dataTestSubj: string) => - field != null ? {field} : 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 = () => {1}; +TempNumberComponent.displayName = 'TempNumberComponent'; + +export const getCasesColumns = ( + actions: Array> +): CasesColumns[] => [ { name: i18n.NAME, render: (theCase: Case) => { - if (theCase.caseId != null && theCase.title != null) { - return {theCase.title}; + if (theCase.id != null && theCase.title != null) { + const caseDetailsLinkComponent = ( + {theCase.title} + ); + return theCase.state === 'open' ? ( + caseDetailsLinkComponent + ) : ( + <> + + {caseDetailsLinkComponent} + {i18n.CLOSED} + + + ); + } + return getEmptyTagValue(); + }, + }, + { + field: 'createdBy', + name: i18n.REPORTER, + render: (createdBy: Case['createdBy']) => { + if (createdBy != null) { + return ( + <> + + {createdBy.username} + + ); } 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 ( - - ); - } - 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', () => { ); 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) => ( + + ), + [selectedCases, filterOptions.state] + ); + const sorting: EuiTableSortingType = { sort: { field: queryParams.sortField, direction: queryParams.sortOrder }, }; + const euiBasicTableSelectionProps = useMemo>( + () => ({ + 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 ( - - + <> + + + + -1} + /> + + + -1} + /> + + + + {i18n.CREATE_TITLE} + + + + + + + + {isCasesLoading && !isDataEmpty && } + - - {isLoading && isEmpty(data.cases) && ( - - )} - {!isLoading && !isEmpty(data.cases) && ( - <> - - - - - {i18n.SHOWING_CASES(data.total ?? 0)} - - - - - {i18n.NO_CASES}} - titleSize="xs" - body={i18n.NO_CASES_BODY} - actions={ - - {i18n.ADD_NEW_CASE} - - } - /> - } - onChange={tableOnChangeCallback} - pagination={memoizedPagination} - sorting={sorting} - /> - - )} - + {isCasesLoading && isDataEmpty ? ( +
+ +
+ ) : ( +
+ + + + + {i18n.SHOWING_CASES(data.total ?? 0)} + + + + + {i18n.SELECTED_CASES(selectedCases.length)} + + + {i18n.BULK_ACTIONS} + + + + + {i18n.NO_CASES}} + titleSize="xs" + body={i18n.NO_CASES_BODY} + actions={ + + {i18n.ADD_NEW_CASE} + + } + /> + } + onChange={tableOnChangeCallback} + pagination={memoizedPagination} + selection={euiBasicTableSelectionProps} + sorting={sorting} + /> +
+ )} + + ); }); 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) => 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 ( @@ -71,11 +82,32 @@ const CasesTableFiltersComponent = ({ - + {i18n.OPEN_CASES} + + + {i18n.CLOSED_CASES} + + {}} + selectedOptions={[]} + options={[]} + optionsEmptyLabel={i18n.NO_REPORTERS_AVAILABLE} + /> + 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; + // dispatchToaster: Dispatch; + // reFetchCases: (refreshPrePackagedCase?: boolean) => void; + selectedCases: Case[]; + caseStatus: string; +} + +export const getBulkItems = ({ + // cases, + closePopover, + caseStatus, + // dispatch, + // dispatchToaster, + // reFetchCases, + selectedCases, +}: GetBulkItems) => { + return [ + caseStatus === 'open' ? ( + { + closePopover(); + // await deleteCasesAction(selectedCases, dispatch, dispatchToaster); + // reFetchCases(true); + }} + > + {i18n.BULK_ACTION_CLOSE_SELECTED} + + ) : ( + { + closePopover(); + // await deleteCasesAction(selectedCases, dispatch, dispatchToaster); + // reFetchCases(true); + }} + > + {i18n.BULK_ACTION_OPEN_SELECTED} + + ), + { + closePopover(); + // await deleteCasesAction(selectedCases, dispatch, dispatchToaster); + // reFetchCases(true); + }} + > + {i18n.BULK_ACTION_DELETE_SELECTED} + , + ]; +}; 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(({ 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(({ caseId, initialData }) => case 'title': const titleUpdate = getTypedPayload(updateValue); if (titleUpdate.length > 0) { - dispatchUpdateCaseProperty({ + updateCaseProperty({ updateKey: 'title', updateValue: titleUpdate, }); @@ -80,7 +77,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => case 'description': const descriptionUpdate = getTypedPayload(updateValue); if (descriptionUpdate.length > 0) { - dispatchUpdateCaseProperty({ + updateCaseProperty({ updateKey: 'description', updateValue: descriptionUpdate, }); @@ -88,15 +85,15 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => break; case 'tags': const tagsUpdate = getTypedPayload(updateValue); - dispatchUpdateCaseProperty({ + updateCaseProperty({ updateKey: 'tags', updateValue: tagsUpdate, }); break; case 'state': const stateUpdate = getTypedPayload(updateValue); - if (data.state !== updateValue) { - dispatchUpdateCaseProperty({ + if (caseData.state !== updateValue) { + updateCaseProperty({ updateKey: 'state', updateValue: stateUpdate, }); @@ -105,7 +102,7 @@ export const CaseComponent = React.memo(({ 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(({ caseId, initialData }) => titleNode={ } - title={data.title} + title={caseData.title} > @@ -160,10 +157,10 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => {i18n.STATUS} - {data.state} + {caseData.state} @@ -172,7 +169,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => @@ -184,10 +181,10 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => @@ -204,7 +201,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => @@ -213,11 +210,11 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => 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({ + 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 ; + if (caseData != null && caseData.id) { + return ; } + if (isCancel) { return ; } + return ( {isLoading && } 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 = { 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; + isLoading: boolean; +} + +export const OpenClosedStats = React.memo( + ({ caseCount, caseState, getCaseCount, isLoading }) => { + useEffect(() => { + getCaseCount(caseState); + }, [caseState]); + + const openClosedStats = useMemo( + () => [ + { + title: caseState === 'open' ? i18n.OPEN_CASES : i18n.CLOSED_CASES, + description: isLoading ? : caseCount[caseState], + }, + ], + [caseCount, caseState, isLoading] + ); + return ; + } +); + +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([]); @@ -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( () => ( { 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(() => , [data.caseId]); + const MarkdownNewComment = useMemo(() => , [caseData.id]); return ( <> {comments.map(comment => ( } - 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 = ({ { - 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 = ({ {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; -} = { - [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; -} = { - [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/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/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/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/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/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; +export type CaseRequest = rt.TypeOf; +export type CaseResponse = rt.TypeOf; +export type CasesResponse = rt.TypeOf; +export type CasePatchRequest = rt.TypeOf; 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; +export type CommentRequest = rt.TypeOf; +export type CommentResponse = rt.TypeOf; +export type AllCommentsResponse = rt.TypeOf; +export type CommentsResponse = rt.TypeOf; +export type CommentPatchRequest = rt.TypeOf; diff --git a/x-pack/plugins/case/server/constants.ts b/x-pack/plugins/case/common/api/cases/index.ts similarity index 67% rename from x-pack/plugins/case/server/constants.ts rename to x-pack/plugins/case/common/api/cases/index.ts index 276dcd135254a..83e249e3257c4 100644 --- a/x-pack/plugins/case/server/constants.ts +++ b/x-pack/plugins/case/common/api/cases/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 * 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 = ( + runtimeType: Type, + 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( + '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; 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().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> = [ { - 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> = [ { - 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; 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; @@ -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 - ) - ); + 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; 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; @@ -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; 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; @@ -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; @@ -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; 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 = 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; @@ -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; -export type CommentAttributes = TypeOf & SavedObjectAttributes; -export type NewCommentType = TypeOf; -export type SavedObjectsFindOptionsType = TypeOf; -export type UpdatedCaseTyped = TypeOf; -export type UpdatedCommentType = TypeOf; -export type UserType = TypeOf; - -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 = { - -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 { - [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; - 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 = 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; - 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 { @@ -59,7 +71,7 @@ export function wrapError(error: any): CustomHttpResponseOptions }; } -export const formatAllCases = (cases: SavedObjectsFindResponse): AllCases => ({ +export const transformCases = (cases: SavedObjectsFindResponse): CasesResponse => ({ page: cases.page, per_page: cases.per_page, total: cases.total, @@ -68,27 +80,24 @@ export const formatAllCases = (cases: SavedObjectsFindResponse): export const flattenCaseSavedObjects = ( savedObjects: SavedObjectsFindResponse['saved_objects'] -): FlattenedCaseSavedObject[] => - savedObjects.reduce( - (acc: FlattenedCaseSavedObject[], savedObject: SavedObject) => { - return [...acc, flattenCaseSavedObject(savedObject, [])]; - }, - [] - ); +): CaseResponse[] => + savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject) => { + return [...acc, flattenCaseSavedObject(savedObject, [])]; + }, []); export const flattenCaseSavedObject = ( savedObject: SavedObject, - comments: Array> -): FlattenedCaseSavedObject => ({ - case_id: savedObject.id, - version: savedObject.version ? savedObject.version : '0', + comments: Array> = [] +): CaseResponse => ({ + id: savedObject.id, + version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), ...savedObject.attributes, }); -export const formatAllComments = ( +export const transformComments = ( comments: SavedObjectsFindResponse -): 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['saved_objects'] -): FlattenedCommentSavedObject[] => - savedObjects.reduce( - (acc: FlattenedCommentSavedObject[], savedObject: SavedObject) => { - return [...acc, flattenCommentSavedObject(savedObject)]; - }, - [] - ); +): CommentResponse[] => + savedObjects.reduce((acc: CommentResponse[], savedObject: SavedObject) => { + return [...acc, flattenCommentSavedObject(savedObject)]; + }, []); export const flattenCommentSavedObject = ( savedObject: SavedObject -): 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; } interface UpdateCommentArgs extends ClientArgs { commentId: string; - updatedAttributes: UpdatedCommentType; + updatedAttributes: Partial; } interface GetUserArgs { @@ -68,15 +67,15 @@ export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; getAllCases(args: GetCasesArgs): Promise>; - getAllCaseComments(args: GetCaseArgs): Promise>; + getAllCaseComments(args: GetCommentsArgs): Promise>; getCase(args: GetCaseArgs): Promise>; getComment(args: GetCommentArgs): Promise>; getTags(args: ClientArgs): Promise; getUser(args: GetUserArgs): Promise; postNewCase(args: PostCaseArgs): Promise>; postNewComment(args: PostCommentArgs): Promise>; - updateCase(args: UpdateCaseArgs): Promise>; - updateComment(args: UpdateCommentArgs): Promise>; + patchCase(args: PatchCaseArgs): Promise>; + patchComment(args: UpdateCommentArgs): Promise>; } 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>) 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 = ( + context: ISearchContext +) => { + const syncStrategyProvider = context.getSearchStrategy(SYNC_SEARCH_STRATEGY); + const { search: syncSearch } = syncStrategyProvider(context); + + const search: ISearch = ( + request: IEnhancedEsSearchRequest, + options + ) => { + const params: EnhancedSearchParams = { + ignoreThrottled: !context.core.uiSettings.get('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 { + 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 = ( + context: ISearchContext, + caller: APICaller +) => { + const search: ISearch = 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; + + 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) { + 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 ( - - {columnConfigurations.map(columnConfiguration => { - if (isTimestampLogColumnConfiguration(columnConfiguration)) { - const column = logEntryColumnsById[columnConfiguration.timestampColumn.id]; - const columnWidth = columnWidths[columnConfiguration.timestampColumn.id]; - - return ( - - {isTimestampColumn(column) ? ( - - ) : null} - - ); - } else if (isMessageLogColumnConfiguration(columnConfiguration)) { - const column = logEntryColumnsById[columnConfiguration.messageColumn.id]; - const columnWidth = columnWidths[columnConfiguration.messageColumn.id]; - - return ( - - {column ? ( - - ) : null} - - ); - } else if (isFieldLogColumnConfiguration(columnConfiguration)) { - const column = logEntryColumnsById[columnConfiguration.fieldColumn.id]; - const columnWidth = columnWidths[columnConfiguration.fieldColumn.id]; - - return ( - - {column ? ( - - ) : null} - - ); + 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 ( + - - - - ); -}; + {columnConfigurations.map(columnConfiguration => { + if (isTimestampLogColumnConfiguration(columnConfiguration)) { + const column = logEntryColumnsById[columnConfiguration.timestampColumn.id]; + const columnWidth = columnWidths[columnConfiguration.timestampColumn.id]; + + return ( + + {isTimestampColumn(column) ? ( + + ) : null} + + ); + } else if (isMessageLogColumnConfiguration(columnConfiguration)) { + const column = logEntryColumnsById[columnConfiguration.messageColumn.id]; + const columnWidth = columnWidths[columnConfiguration.messageColumn.id]; + + return ( + + {column ? ( + + ) : null} + + ); + } else if (isFieldLogColumnConfiguration(columnConfiguration)) { + const column = logEntryColumnsById[columnConfiguration.fieldColumn.id]; + const columnWidth = columnWidths[columnConfiguration.fieldColumn.id]; + + return ( + + {column ? ( + + ) : null} + + ); + } + })} + + + + + ); + } +); 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( + const availableOptions = useMemo( () => 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; +type DatasetOptionProps = EuiComboBoxOptionOption; export const DatasetsSelector: React.FunctionComponent<{ availableDatasets: string[]; 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" > @@ -473,6 +474,7 @@ Array [ style="font-size: 14px; display: inline-block;" > ; + 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(); 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" > { }); }; - 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 { }); }; - 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 { }); }; - 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 { ); } - 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 { ) ); - 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 = ({ // States for choosing all indices, or a subset, including caching previously chosen subset list const [isAllIndices, setIsAllIndices] = useState(!Boolean(config.indices)); const [indicesSelection, setIndicesSelection] = useState([...indices]); - const [indicesOptions, setIndicesOptions] = useState( + const [indicesOptions, setIndicesOptions] = useState( indices.map( - (index): Option => ({ + (index): EuiSelectableOption => ({ label: index, checked: isAllIndices || @@ -210,7 +210,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ 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 = ({ { // 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 = ({ }} showGutter={false} minLines={6} - aria-label={ - - } + 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 = // States for choosing all indices, or a subset, including caching previously chosen subset list const [isAllIndices, setIsAllIndices] = useState(!Boolean(restoreIndices)); - const [indicesOptions, setIndicesOptions] = useState( + const [indicesOptions, setIndicesOptions] = useState( snapshotIndices.map( - (index): Option => ({ + (index): EuiSelectableOption => ({ label: index, checked: isAllIndices || @@ -230,7 +230,7 @@ export const RestoreSnapshotStepLogistics: React.FunctionComponent = { // 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 = { // 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 = ({ setOptions={{ maxLines: Infinity }} value={JSON.stringify(serializedRestoreSettings, null, 2)} editorProps={{ $blockScrolling: Infinity }} - aria-label={ - - } + aria-label={i18n.translate( + 'xpack.snapshotRestore.restoreForm.stepReview.jsonTab.jsonAriaLabel', + { defaultMessage: 'Restore settings to be executed' } + )} /> ); 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 = ( showGutter={false} minLines={6} maxLines={15} - aria-label={ - - } + 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 = ({ policy }) => { maxLines={12} wrapEnabled={true} showGutter={false} - aria-label={ - - } + aria-label={i18n.translate( + 'xpack.snapshotRestore.policyDetails.lastFailure.detailsAriaLabel', + { + defaultMessage: `Last failure details for policy '{name}'`, + values: { name }, + } + )} /> 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 = ({ }} showGutter={false} minLines={6} - aria-label={ - - } + }, + } + )} /> ); 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" > { 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 { } )} onChange={this.onFileUpload} - accept={imageTypes} + accept={imageTypes.join(',')} /> ); 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(false); // in render section of component (false); uiSettings, charts, dataFieldsFormats, + metadata: { test: 'some value', fields: ['test'] }, }} > - + ``` @@ -677,6 +680,8 @@ AlertAdd Props definition: ``` interface AlertAddProps { consumer: string; + addFlyoutVisible: boolean; + setAddFlyoutVisibility: React.Dispatch>; 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>; +export interface AlertsContextValue> { reloadAlerts?: () => Promise; http: HttpSetup; alertTypeRegistry: TypeRegistry; actionTypeRegistry: TypeRegistry; uiSettings?: IUiSettingsClient; - toastNotifications?: Pick< + toastNotifications: Pick< ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' >; charts?: ChartsPluginSetup; dataFieldsFormats?: Pick; + 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 ( + { + 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; + 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; + http: HttpSetup; + alertTypeRegistry: TypeRegistry; + actionTypeRegistry: TypeRegistry; + uiSettings?: IUiSettingsClient; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + charts?: ChartsPluginSetup; + dataFieldsFormats?: Pick; +} +``` + +|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(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 + setAddFlyoutVisibility(true)} +> + + + +// in render section of component + + + +``` + +ConnectorAddFlyout Props definition: +``` +export interface ConnectorAddFlyoutProps { + addFlyoutVisible: boolean; + setAddFlyoutVisibility: React.Dispatch>; + 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; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + capabilities: ApplicationStart['capabilities']; + reloadConnectors?: () => Promise; +} +``` + +|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(false); + +// load required dependancied +const { http, triggers_actions_ui, toastNotifications, capabilities } = useKibana().services; + +// UI control item for open flyout + setEditFlyoutVisibility(true)} +> + + + +// in render section of component + + + + +``` + +ConnectorEditFlyout Props definition: +``` +export interface ConnectorEditProps { + initialConnector: ActionConnectorTableItem; + editFlyoutVisible: boolean; + setEditFlyoutVisibility: React.Dispatch>; +} +``` + +|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; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + capabilities: ApplicationStart['capabilities']; + reloadConnectors?: () => Promise; +} +``` + +|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 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(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 { 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(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 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>([]); - const [indexOptions, setIndexOptions] = useState([]); + const [indexOptions, setIndexOptions] = useState([]); const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); const [isIndiciesLoading, setIsIndiciesLoading] = useState(false); @@ -143,7 +143,8 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent 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 { + 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>; - setAddFlyoutVisibility: React.Dispatch>; - actionTypesIndex: Record | undefined; - reloadConnectors: () => Promise; + http: HttpSetup; + actionTypeRegistry: TypeRegistry; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + capabilities: ApplicationStart['capabilities']; + reloadConnectors?: () => Promise; } const ActionsConnectorsContext = createContext(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> { reloadAlerts?: () => Promise; http: HttpSetup; alertTypeRegistry: TypeRegistry; actionTypeRegistry: TypeRegistry; - 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(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: () => , + }; + + 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; + + 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( + { + 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; + 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(false); + const [activeActionItem, setActiveActionItem] = useState( + undefined + ); + const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState(true); + const [connectors, setConnectors] = useState([]); + const [isLoadingActionTypes, setIsLoadingActionTypes] = useState(false); + const [actionTypesIndex, setActionTypesIndex] = useState(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, 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; + + 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 ( + + + + + + +
+ +
+
+
+
+ } + extraAction={ + { + 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" + > + + + + } + labelAppend={ + { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); + }} + > + + + } + > + { + setActionIdByIndex(selectedOptions[0].id ?? '', index); + }} + isClearable={false} + /> + + + + + {ParamsFieldsComponent ? ( + + ) : null} + + ); + }; + + 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 ( + + + + + + +
+ +
+
+
+
+ } + extraAction={ + { + 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" + > + + } + actions={[ + { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); + }} + > + + , + ]} + /> + + ); + }; + + 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] ? ( + addActionType(item)} + > + + + ) : null; + }) + : null; + + return ( + + {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); + })} + + {isAddActionPanelOpen === false ? ( + setIsAddActionPanelOpen(true)} + > + + + ) : null} + {isAddActionPanelOpen ? ( + + +
+ +
+
+ + + {isLoadingActionTypes ? ( + + + + ) : ( + actionTypeNodes + )} + +
+ ) : null} + {actionTypesIndex && activeActionItem ? ( + { + connectors.push(savedAction); + setActionIdByIndex(savedAction.id, activeActionItem.index); + }} + actionTypeRegistry={actionTypeRegistry} + http={http} + toastNotifications={toastNotifications} + /> + ) : null} +
+ ); +}; 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( {}, - 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(() => {}); }, @@ -83,12 +72,17 @@ describe('connector_add_flyout', () => { > ); - 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; + 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(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( - - {}, - editFlyoutVisible: false, - setEditFlyoutVisibility: state => {}, - actionTypesIndex: { - 'my-action-type': { id: 'my-action-type', name: 'test', enabled: true }, + { + return new Promise(() => {}); + }, + }} + > + {}} + actionTypes={[ + { + id: actionType.id, + enabled: true, + name: 'Test', }, - reloadConnectors: () => { - return new Promise(() => {}); - }, - }} - > - - - + ]} + /> + ); 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>; + 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(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(false); const closeFlyout = useCallback(() => { @@ -79,10 +89,7 @@ export const ConnectorAddFlyout = () => { let actionTypeModel; if (!actionType) { currentForm = ( - + ); } else { actionTypeModel = actionTypeRegistry.get(actionType.id); @@ -108,17 +115,19 @@ export const ConnectorAddFlyout = () => { const onActionConnectorSave = async (): Promise => 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( - {}, - editFlyoutVisible: false, - setEditFlyoutVisibility: state => {}, - actionTypesIndex: { - 'my-action-type': { id: 'my-action-type', name: 'test', enabled: true }, - }, - reloadConnectors: () => { - return new Promise(() => {}); - }, - }} - > - {}} - actionType={actionType} - http={deps.http} - actionTypeRegistry={deps.actionTypeRegistry} - alertTypeRegistry={deps.alertTypeRegistry} - toastNotifications={deps.toastNotifications} - /> - + {}} + 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>; postSaveEventHandler?: (savedAction: ActionConnector) => void; http: HttpSetup; - alertTypeRegistry: TypeRegistry; actionTypeRegistry: TypeRegistry; 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', () => { {}, - 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(() => {}); }, }} > - + {}} + /> ); 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>; } -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 => 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} - + {editedConnectorItem ? ( ) : null} 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 = () => { + const alertsContext = useAlertsContext(); + const { metadata } = alertsContext; + + return ( + + + + ); +}; + describe('alert_add', () => { let deps: any; let wrapper: ReactWrapper; @@ -41,7 +58,7 @@ describe('alert_add', () => { validate: (): ValidationResult => { return { errors: {} }; }, - alertParamsExpression: () => , + 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'] }, }} > - {}} - /> + {}} /> ); // 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; - 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; - 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( + { + return new Promise(() => {}); + }, + http: deps!.http, + actionTypeRegistry: deps!.actionTypeRegistry, + alertTypeRegistry: deps!.alertTypeRegistry, + toastNotifications: deps!.toastNotifications, + uiSettings: deps!.uiSettings, + }} + > + {}} + errors={{ name: [] }} + serverError={null} + /> + + ); + await act(async () => { - if (deps) { - wrapper = mountWithIntl( - { - return new Promise(() => {}); - }, - http: deps.http, - actionTypeRegistry: deps.actionTypeRegistry, - alertTypeRegistry: deps.alertTypeRegistry, - toastNotifications: deps.toastNotifications, - uiSettings: deps.uiSettings, - }} - > - {}} - errors={{ name: [] }} - serverError={null} - /> - - ); - } + 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; - 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( + { + return new Promise(() => {}); + }, + http: deps!.http, + actionTypeRegistry: deps!.actionTypeRegistry, + alertTypeRegistry: deps!.alertTypeRegistry, + toastNotifications: deps!.toastNotifications, + uiSettings: deps!.uiSettings, + }} + > + {}} + errors={{ name: [] }} + serverError={null} + /> + + ); + await act(async () => { - if (deps) { - wrapper = mountWithIntl( - { - return new Promise(() => {}); - }, - http: deps.http, - actionTypeRegistry: deps.actionTypeRegistry, - alertTypeRegistry: deps.alertTypeRegistry, - toastNotifications: deps.toastNotifications, - uiSettings: deps.uiSettings, - }} - > - {}} - errors={{ name: [] }} - serverError={null} - /> - - ); - } + 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) { - 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(false); - const [isLoadingActionTypes, setIsLoadingActionTypes] = useState(false); - const [actionTypesIndex, setActionTypesIndex] = useState(undefined); const [alertTypesIndex, setAlertTypesIndex] = useState(undefined); const [alertInterval, setAlertInterval] = useState( alert.schedule.interval ? parseInt(alert.schedule.interval.replace(/^[A-Za-z]+$/, ''), 0) : 1 @@ -124,39 +101,7 @@ export const AlertForm = ({ const [alertThrottleUnit, setAlertThrottleUnit] = useState( alert.throttle ? alert.throttle.replace((alertThrottle ?? '').toString(), '') : 'm' ); - const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState(true); - const [connectors, setConnectors] = useState([]); const [defaultActionGroupId, setDefaultActionGroupId] = useState(undefined); - const [activeActionItem, setActiveActionItem] = useState( - 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, 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 ( addActionType(item)} - > - - - ); - }); - - 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 ( - - - - - - -
- -
-
-
- - } - extraAction={ - { - 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" - > - - - - } - labelAppend={ - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > - - - } - > - { - setActionProperty('id', selectedOptions[0].id, index); - }} - isClearable={false} - /> - - - - - {ParamsFieldsComponent ? ( - - ) : null} -
- ); - }; - - 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 ( - - - - - - -
- -
-
-
- - } - extraAction={ - { - 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" - > - - } - actions={[ - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > - - , - ]} - /> -
- ); - }; - - const selectedGroupActions = ( - - {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); - })} - - {isAddActionPanelOpen === false ? ( - setIsAddActionPanelOpen(true)} - > - - - ) : null} - - ); - const alertTypeDetails = ( @@ -639,31 +217,27 @@ export const AlertForm = ({ /> ) : null} - {selectedGroupActions} - {isAddActionPanelOpen ? ( - - -
- -
-
- - - {isLoadingActionTypes ? ( - - - - ) : ( - actionTypeNodes - )} - -
+ {defaultActionGroupId ? ( + 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}
); @@ -862,22 +436,6 @@ export const AlertForm = ({ )} - {actionTypesIndex && activeActionItem ? ( - { - connectors.push(savedAction); - setActionProperty('id', savedAction.id, activeActionItem.index); - }} - actionTypeRegistry={actionTypeRegistry} - alertTypeRegistry={alertTypeRegistry} - http={http} - toastNotifications={toastNotifications} - /> - ) : null} ); }; 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(` { ); expect(wrapper.find('[data-test-subj="availablefieldsOptionsComboBox"]')) .toMatchInlineSnapshot(` - + /> `); }); 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; } -export type Start = void; +export interface TriggersAndActionsUIPublicPluginStart { + actionTypeRegistry: TypeRegistry; + alertTypeRegistry: TypeRegistry; +} interface PluginsStart { data: DataPublicPluginStart; @@ -30,7 +33,9 @@ interface PluginsStart { management: ManagementStart; } -export class Plugin implements CorePlugin { +export class Plugin + implements + CorePlugin { private actionTypeRegistry: TypeRegistry; private alertTypeRegistry: TypeRegistry; @@ -57,44 +62,46 @@ export class Plugin implements CorePlugin { + 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/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} > = ({ errors={errors} > { 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/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; 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; 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"