diff --git a/.ci/Jenkinsfile_flaky b/.ci/Jenkinsfile_flaky index 8121405e5ae249..370643789c2cde 100644 --- a/.ci/Jenkinsfile_flaky +++ b/.ci/Jenkinsfile_flaky @@ -4,7 +4,6 @@ library 'kibana-pipeline-library' kibanaLibrary.load() def TASK_PARAM = params.TASK ?: params.CI_GROUP - // Looks like 'oss:ciGroup:1', 'oss:firefoxSmoke' def JOB_PARTS = TASK_PARAM.split(':') def IS_XPACK = JOB_PARTS[0] == 'xpack' @@ -111,6 +110,8 @@ def getWorkerFromParams(isXpack, job, ciGroup) { return kibanaPipeline.scriptTaskDocker('Jest Integration Tests', 'test/scripts/test/jest_integration.sh') } else if (job == 'apiIntegration') { return kibanaPipeline.scriptTask('API Integration Tests', 'test/scripts/test/api_integration.sh') + } else if (job == 'pluginFunctional') { + return kibanaPipeline.functionalTestProcess('oss-pluginFunctional', './test/scripts/jenkins_plugin_functional.sh') } else { return kibanaPipeline.ossCiGroupProcess(ciGroup) } diff --git a/.ci/end2end.groovy b/.ci/end2end.groovy index 87b64437deafcd..f1095f8035b6c4 100644 --- a/.ci/end2end.groovy +++ b/.ci/end2end.groovy @@ -13,12 +13,12 @@ pipeline { BASE_DIR = 'src/github.com/elastic/kibana' HOME = "${env.WORKSPACE}" E2E_DIR = 'x-pack/plugins/apm/e2e' - PIPELINE_LOG_LEVEL = 'DEBUG' + PIPELINE_LOG_LEVEL = 'INFO' KBN_OPTIMIZER_THEMES = 'v7light' } options { timeout(time: 1, unit: 'HOURS') - buildDiscarder(logRotator(numToKeepStr: '40', artifactNumToKeepStr: '20', daysToKeepStr: '30')) + buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '10', daysToKeepStr: '30')) timestamps() ansiColor('xterm') disableResume() diff --git a/dev_docs/tutorials/building_a_plugin.mdx b/dev_docs/tutorials/building_a_plugin.mdx index cee5a9a399de5b..e751ce7d01b165 100644 --- a/dev_docs/tutorials/building_a_plugin.mdx +++ b/dev_docs/tutorials/building_a_plugin.mdx @@ -4,7 +4,7 @@ slug: /kibana-dev-docs/tutorials/build-a-plugin title: Kibana plugin tutorial summary: Anatomy of a Kibana plugin and how to build one date: 2021-02-05 -tags: ['kibana','onboarding', 'dev', 'tutorials'] +tags: ['kibana', 'onboarding', 'dev', 'tutorials'] --- Prereading material: @@ -14,7 +14,7 @@ Prereading material: ## The anatomy of a plugin Plugins are defined as classes and present themselves to Kibana through a simple wrapper function. A plugin can have browser-side code, server-side code, -or both. There is no architectural difference between a plugin in the browser and a plugin on the server. In both places, you describe your plugin similarly, +or both. There is no architectural difference between a plugin in the browser and a plugin on the server. In both places, you describe your plugin similarly, and you interact with Core and other plugins in the same way. The basic file structure of a Kibana plugin named demo that has both client-side and server-side code would be: @@ -33,7 +33,7 @@ plugins/ index.ts [6] ``` -### [1] kibana.json +### [1] kibana.json `kibana.json` is a static manifest file that is used to identify the plugin and to specify if this plugin has server-side code, browser-side code, or both: @@ -42,14 +42,33 @@ plugins/ "id": "demo", "version": "kibana", "server": true, - "ui": true + "ui": true, + "owner": { [1] + "name": "App Services", + "githubTeam": "kibana-app-services" + }, + "description": "This plugin extends Kibana by doing xyz, and allows other plugins to extend Kibana by offering abc functionality. It also exposes some helper utilities that do efg", [2] + "requiredPlugins": ["data"], [3] + "optionalPlugins": ["alerting"] [4] + "requiredBundles": ["anotherPlugin"] [5] } ``` +[1], [2]: Every internal plugin should fill in the owner and description properties. + +[3], [4]: Any plugin that you have a dependency on should be listed in `requiredPlugins` or `optionalPlugins`. Doing this will ensure that you have access to that plugin's start and setup contract inside your own plugin's start and setup lifecycle methods. If a plugin you optionally depend on is not installed or disabled, it will be undefined if you try to access it. If a plugin you require is not installed or disabled, kibana will fail to build. + +[5]: Don't worry too much about getting 5 right. The build optimizer will complain if any of these values are incorrect. + + + + You don't need to declare a dependency on a plugin if you only wish to access its types. + + ### [2] public/index.ts -`public/index.ts` is the entry point into the client-side code of this plugin. It must export a function named plugin, which will receive a standard set of - core capabilities as an argument. It should return an instance of its plugin class for Kibana to load. +`public/index.ts` is the entry point into the client-side code of this plugin. Everything exported from this file will be a part of the plugins . If the plugin only exists to export static utilities, consider using a package. Otherwise, this file must export a function named plugin, which will receive a standard set of +core capabilities as an argument. It should return an instance of its plugin class for Kibana to load. ``` import type { PluginInitializerContext } from 'kibana/server'; @@ -60,13 +79,32 @@ export function plugin(initializerContext: PluginInitializerContext) { } ``` + + +1. When possible, use + +``` +export type { AType } from '...'` +``` + +instead of + +``` +export { AType } from '...'`. +``` + +Using the non-`type` variation will increase the bundle size unnecessarily and may unwillingly provide access to the implementation of `AType` class. + +2. Don't use `export *` in these top level index.ts files + + + ### [3] public/plugin.ts `public/plugin.ts` is the client-side plugin definition itself. Technically speaking, it does not need to be a class or even a separate file from the entry - point, but all plugins at Elastic should be consistent in this way. +point, but all plugins at Elastic should be consistent in this way. - - ```ts +```ts import type { Plugin, PluginInitializerContext, CoreSetup, CoreStart } from 'kibana/server'; export class DemoPlugin implements Plugin { @@ -84,10 +122,9 @@ export class DemoPlugin implements Plugin { // called when plugin is torn down during Kibana's shutdown sequence } } - ``` - +``` -### [4] server/index.ts +### [4] server/index.ts `server/index.ts` is the entry-point into the server-side code of this plugin. It is identical in almost every way to the client-side entry-point: @@ -115,7 +152,7 @@ export class DemoPlugin implements Plugin { } ``` -Kibana does not impose any technical restrictions on how the the internals of a plugin are architected, though there are certain +Kibana does not impose any technical restrictions on how the the internals of a plugin are architected, though there are certain considerations related to how plugins integrate with core APIs and APIs exposed by other plugins that may greatly impact how they are built. ### [6] common/index.ts @@ -124,8 +161,8 @@ considerations related to how plugins integrate with core APIs and APIs exposed ## How plugin's interact with each other, and Core -The lifecycle-specific contracts exposed by core services are always passed as the first argument to the equivalent lifecycle function in a plugin. -For example, the core http service exposes a function createRouter to all plugin setup functions. To use this function to register an HTTP route handler, +The lifecycle-specific contracts exposed by core services are always passed as the first argument to the equivalent lifecycle function in a plugin. +For example, the core http service exposes a function createRouter to all plugin setup functions. To use this function to register an HTTP route handler, a plugin just accesses it off of the first argument: ```ts @@ -135,14 +172,16 @@ export class DemoPlugin { public setup(core: CoreSetup) { const router = core.http.createRouter(); // handler is called when '/path' resource is requested with `GET` method - router.get({ path: '/path', validate: false }, (context, req, res) => res.ok({ content: 'ok' })); + router.get({ path: '/path', validate: false }, (context, req, res) => + res.ok({ content: 'ok' }) + ); } } ``` Unlike core, capabilities exposed by plugins are not automatically injected into all plugins. Instead, if a plugin wishes to use the public interface provided by another plugin, it must first declare that plugin as a - dependency in it’s kibana.json manifest file. +dependency in it’s kibana.json manifest file. ** foobar plugin.ts: ** @@ -174,8 +213,8 @@ export class MyPlugin implements Plugin { } } ``` -[1] We highly encourage plugin authors to explicitly declare public interfaces for their plugins. +[1] We highly encourage plugin authors to explicitly declare public interfaces for their plugins. ** demo kibana.json** @@ -194,7 +233,7 @@ With that specified in the plugin manifest, the appropriate interfaces are then import type { CoreSetup, CoreStart } from 'kibana/server'; import type { FoobarPluginSetup, FoobarPluginStart } from '../../foobar/server'; -interface DemoSetupPlugins { [1] +interface DemoSetupPlugins { [1] foobar: FoobarPluginSetup; } @@ -218,7 +257,7 @@ export class DemoPlugin { public stop() {} } ``` - + [1] The interface for plugin’s dependencies must be manually composed. You can do this by importing the appropriate type from the plugin and constructing an interface where the property name is the plugin’s ID. [2] These manually constructed types should then be used to specify the type of the second argument to the plugin. diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationstart.geturlforapp.md b/docs/development/core/public/kibana-plugin-core-public.applicationstart.geturlforapp.md index 1eaf00c7a678d6..6229aeb9238e8d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationstart.geturlforapp.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationstart.geturlforapp.md @@ -16,6 +16,7 @@ Note that when generating absolute urls, the origin (protocol, host and port) ar getUrlForApp(appId: string, options?: { path?: string; absolute?: boolean; + deepLinkId?: string; }): string; ``` @@ -24,7 +25,7 @@ getUrlForApp(appId: string, options?: { | Parameter | Type | Description | | --- | --- | --- | | appId | string | | -| options | {
path?: string;
absolute?: boolean;
} | | +| options | {
path?: string;
absolute?: boolean;
deepLinkId?: string;
} | | Returns: diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.md index c6e00842a31e6a..2c03db82ba683a 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.md @@ -21,6 +21,7 @@ export interface ExpressionFunctionDefinitions | [derivative](./kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.derivative.md) | ExpressionFunctionDerivative | | | [font](./kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.font.md) | ExpressionFunctionFont | | | [moving\_average](./kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.moving_average.md) | ExpressionFunctionMovingAverage | | +| [overall\_metric](./kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.overall_metric.md) | ExpressionFunctionOverallMetric | | | [theme](./kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.theme.md) | ExpressionFunctionTheme | | | [var\_set](./kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.var_set.md) | ExpressionFunctionVarSet | | | [var](./kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.var.md) | ExpressionFunctionVar | | diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.overall_metric.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.overall_metric.md new file mode 100644 index 00000000000000..8685788a2f3512 --- /dev/null +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.overall_metric.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-public](./kibana-plugin-plugins-expressions-public.md) > [ExpressionFunctionDefinitions](./kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.md) > [overall\_metric](./kibana-plugin-plugins-expressions-public.expressionfunctiondefinitions.overall_metric.md) + +## ExpressionFunctionDefinitions.overall\_metric property + +Signature: + +```typescript +overall_metric: ExpressionFunctionOverallMetric; +``` diff --git a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.md b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.md index 219678244951b4..f55fed99e1d3d4 100644 --- a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.md +++ b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.md @@ -21,6 +21,7 @@ export interface ExpressionFunctionDefinitions | [derivative](./kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.derivative.md) | ExpressionFunctionDerivative | | | [font](./kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.font.md) | ExpressionFunctionFont | | | [moving\_average](./kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.moving_average.md) | ExpressionFunctionMovingAverage | | +| [overall\_metric](./kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.overall_metric.md) | ExpressionFunctionOverallMetric | | | [theme](./kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.theme.md) | ExpressionFunctionTheme | | | [var\_set](./kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.var_set.md) | ExpressionFunctionVarSet | | | [var](./kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.var.md) | ExpressionFunctionVar | | diff --git a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.overall_metric.md b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.overall_metric.md new file mode 100644 index 00000000000000..b8564a696e6e48 --- /dev/null +++ b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.overall_metric.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-server](./kibana-plugin-plugins-expressions-server.md) > [ExpressionFunctionDefinitions](./kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.md) > [overall\_metric](./kibana-plugin-plugins-expressions-server.expressionfunctiondefinitions.overall_metric.md) + +## ExpressionFunctionDefinitions.overall\_metric property + +Signature: + +```typescript +overall_metric: ExpressionFunctionOverallMetric; +``` diff --git a/docs/discover/search-sessions.asciidoc b/docs/discover/search-sessions.asciidoc index fec1b8b26dd741..b503e8cfba3b40 100644 --- a/docs/discover/search-sessions.asciidoc +++ b/docs/discover/search-sessions.asciidoc @@ -68,3 +68,19 @@ behaves differently: * Relative dates are converted to absolute dates. * Panning and zooming is disabled for maps. * Changing a filter, query, or drilldown starts a new search session, which can be slow. + +[float] +==== Limitations + +Certain visualization features do not fully support background search sessions yet. If a dashboard using these features gets restored, +all panels using unsupported features won't load immediately, but instead send out additional data requests which can take a while to complete. +In this case a warning *Your search session is still running* will be shown. + +You can either wait for these additional requests to complete or come back to the dashboard later when all data requests have been finished. + +A panel on a dashboard can behave like this if one of the following features is used: +* *Lens* - A *top values* dimension with an enabled setting *Group other values as "Other"* (configurable in the *Advanced* section of the dimension) +* *Lens* - An *intervals* dimension is used +* *Aggregation based* visualizations - A *terms* aggregation is used with an enabled setting *Group other values in separate bucket* +* *Aggregation based* visualizations - A *histogram* aggregation is used +* *Maps* - Layers using joins, blended layers or tracks layers are used diff --git a/docs/user/alerting/domain-specific-rules.asciidoc b/docs/user/alerting/domain-specific-rules.asciidoc deleted file mode 100644 index f509f9e5288234..00000000000000 --- a/docs/user/alerting/domain-specific-rules.asciidoc +++ /dev/null @@ -1,20 +0,0 @@ -[role="xpack"] -[[domain-specific-rules]] -== Domain-specific rules - -For domain-specific rules, refer to the documentation for that app. -{kib} supports these rules: - -* {observability-guide}/create-alerts.html[Observability rules] -* {security-guide}/prebuilt-rules.html[Security rules] -* <> -* {ml-docs}/ml-configuring-alerts.html[{ml-cap} rules] beta:[] - -[NOTE] -============================================== -Some rule types are subscription features, while others are free features. -For a comparison of the Elastic subscription levels, -see {subscriptions}[the subscription page]. -============================================== - -include::map-rules/geo-rule-types.asciidoc[] diff --git a/docs/user/alerting/index.asciidoc b/docs/user/alerting/index.asciidoc index 68cf3ee070b089..9ab6a2dc46ebf2 100644 --- a/docs/user/alerting/index.asciidoc +++ b/docs/user/alerting/index.asciidoc @@ -3,6 +3,5 @@ include::alerting-setup.asciidoc[] include::create-and-manage-rules.asciidoc[] include::defining-rules.asciidoc[] include::rule-management.asciidoc[] -include::stack-rules.asciidoc[] -include::domain-specific-rules.asciidoc[] +include::rule-types.asciidoc[] include::alerting-troubleshooting.asciidoc[] diff --git a/docs/user/alerting/rule-types.asciidoc b/docs/user/alerting/rule-types.asciidoc new file mode 100644 index 00000000000000..bb840014fe80fb --- /dev/null +++ b/docs/user/alerting/rule-types.asciidoc @@ -0,0 +1,56 @@ +[role="xpack"] +[[rule-types]] +== Rule types + +A rule is a set of <>, <>, and <> that enable notifications. {kib} provides two types of rules: rules specific to the Elastic Stack and rules specific to a domain. + +[NOTE] +============================================== +Some rule types are subscription features, while others are free features. +For a comparison of the Elastic subscription levels, +see {subscriptions}[the subscription page]. +============================================== + +[float] +[[stack-rules]] +=== Stack rules + +<> are built into {kib}. To access the *Stack Rules* feature and create and edit rules, users require the `all` privilege. See <> for more information. + +[cols="2*<"] +|=== + +| <> +| Aggregate field values from documents using {es} queries, compare them to threshold values, and schedule actions to run when the thresholds are met. + +| <> +| Run a user-configured {es} query, compare the number of matches to a configured threshold, and schedule actions to run when the threshold condition is met. + +|=== + +[float] +[[domain-specific-rules]] +=== Domain rules + +Domain rules are registered by *Observability*, *Security*, <> and <>. + +[cols="2*<"] +|=== + +| {observability-guide}/create-alerts.html[Observability rules] +| Detect complex conditions in the *Logs*, *Metrics*, and *Uptime* apps. + +| {security-guide}/prebuilt-rules.html[Security rules] +| Detect suspicous source events with pre-built or custom rules and create alerts when a rule’s conditions are met. + +| <> +| Run an {es} query to determine if any documents are currently contained in any boundaries from a specified boundary index and generate alerts when a rule's conditions are met. + +| {ml-docs}/ml-configuring-alerts.html[{ml-cap} rules] beta:[] +| Run scheduled checks on an anomaly detection job to detect anomalies with certain conditions. If an anomaly meets the conditions, an alert is created and the associated action is triggered. + +|=== + +include::rule-types/index-threshold.asciidoc[] +include::rule-types/es-query.asciidoc[] +include::rule-types/geo-rule-types.asciidoc[] diff --git a/docs/user/alerting/stack-rules/es-query.asciidoc b/docs/user/alerting/rule-types/es-query.asciidoc similarity index 100% rename from docs/user/alerting/stack-rules/es-query.asciidoc rename to docs/user/alerting/rule-types/es-query.asciidoc diff --git a/docs/user/alerting/map-rules/geo-rule-types.asciidoc b/docs/user/alerting/rule-types/geo-rule-types.asciidoc similarity index 74% rename from docs/user/alerting/map-rules/geo-rule-types.asciidoc rename to docs/user/alerting/rule-types/geo-rule-types.asciidoc index eee7b592522054..244cf90c855a7e 100644 --- a/docs/user/alerting/map-rules/geo-rule-types.asciidoc +++ b/docs/user/alerting/rule-types/geo-rule-types.asciidoc @@ -1,16 +1,14 @@ [role="xpack"] [[geo-alerting]] -=== Geo rule type +=== Tracking containment -Alerting now includes one additional stack rule: <>. - -As with other stack rules, you need `all` access to the *Stack Rules* feature -to be able to create and edit a geo rule. -See <> for more information on configuring roles that provide access to this feature. +<> offers the Tracking containment rule type which runs an {es} query over indices to determine whether any +documents are currently contained within any boundaries from the specified boundary index. +In the event that an entity is contained within a boundary, an alert may be generated. [float] -==== Geo alerting requirements -To create a *Tracking containment* rule, the following requirements must be present: +==== Requirements +To create a Tracking containment rule, the following requirements must be present: - *Tracks index or index pattern*: An index containing a `geo_point` field, `date` field, and some form of entity identifier. An entity identifier is a `keyword` or `number` @@ -29,22 +27,12 @@ than the current time minus the amount of the interval. If data older than `now - ` is ingested, it won't trigger a rule. [float] -==== Creating a geo rule -Click the *Create* button in the <>. -Complete the <>. - -[role="screenshot"] -image::user/alerting/images/alert-types-tracking-select.png[Choosing a tracking rule type] +==== Create the rule -[float] -[[rule-type-tracking-containment]] -==== Tracking containment -The Tracking containment rule type runs an {es} query over indices, determining if any -documents are currently contained within any boundaries from the specified boundary index. -In the event that an entity is contained within a boundary, an alert may be generated. +Fill in the <>, then select Tracking containment. [float] -===== Defining the conditions +==== Define the conditions Tracking containment rules have 3 clauses that define the condition to detect, as well as 2 Kuery bars used to provide additional filtering context for each of the indices. @@ -61,6 +49,9 @@ Index (Boundary):: This clause requires an *index or index pattern*, a *`geo_sha identifying boundaries, and an optional *Human-readable boundary name* for better alerting messages. +[float] +==== Add action + Conditions for how a rule is tracked can be specified uniquely for each individual action. A rule can be triggered either when a containment condition is met or when an entity is no longer contained. diff --git a/docs/user/alerting/stack-rules/index-threshold.asciidoc b/docs/user/alerting/rule-types/index-threshold.asciidoc similarity index 100% rename from docs/user/alerting/stack-rules/index-threshold.asciidoc rename to docs/user/alerting/rule-types/index-threshold.asciidoc diff --git a/docs/user/alerting/stack-rules.asciidoc b/docs/user/alerting/stack-rules.asciidoc deleted file mode 100644 index 483834c78806e2..00000000000000 --- a/docs/user/alerting/stack-rules.asciidoc +++ /dev/null @@ -1,27 +0,0 @@ -[role="xpack"] -[[stack-rules]] -== Stack rule types - -Kibana provides two types of rules: - -* Stack rules, which are built into {kib} -* <>, which are registered by {kib} apps. - -{kib} provides two stack rules: - -* <> -* <> - -Users require the `all` privilege to access the *Stack Rules* feature and create and edit rules. -See <> for more information. - -[NOTE] -============================================== -Some rule types are subscription features, while others are free features. -For a comparison of the Elastic subscription levels, -see {subscriptions}[the subscription page]. -============================================== - - -include::stack-rules/index-threshold.asciidoc[] -include::stack-rules/es-query.asciidoc[] diff --git a/docs/user/dashboard/aggregation-reference.asciidoc b/docs/user/dashboard/aggregation-reference.asciidoc index 39e596df4af347..001114578a1cd0 100644 --- a/docs/user/dashboard/aggregation-reference.asciidoc +++ b/docs/user/dashboard/aggregation-reference.asciidoc @@ -23,7 +23,7 @@ This reference can help simplify the comparison if you need a specific feature. | Table with summary row ^| X -| +^| X | | | @@ -65,7 +65,7 @@ This reference can help simplify the comparison if you need a specific feature. | Heat map ^| X -| +^| X | | ^| X @@ -333,7 +333,7 @@ build their advanced visualization. | Math on aggregated data | -| +^| X ^| X ^| X ^| X @@ -352,6 +352,13 @@ build their advanced visualization. ^| X ^| X +| Time shifts +| +^| X +^| X +^| X +^| X + | Fully custom {es} queries | | diff --git a/docs/user/dashboard/create-panels-with-editors.asciidoc b/docs/user/dashboard/create-panels-with-editors.asciidoc index 17d3b5fb8a8a5a..77a4706e249fd8 100644 --- a/docs/user/dashboard/create-panels-with-editors.asciidoc +++ b/docs/user/dashboard/create-panels-with-editors.asciidoc @@ -30,13 +30,16 @@ [[lens-editor]] === Lens -*Lens* is the drag and drop editor that creates visualizations of your data. +*Lens* is the drag and drop editor that creates visualizations of your data, recommended for most +users. With *Lens*, you can: * Use the automatically generated suggestions to change the visualization type. * Create visualizations with multiple layers and indices. * Change the aggregation and labels to customize the data. +* Perform math on aggregations using *Formula*. +* Use time shifts to compare data at two times, such as month over month. [role="screenshot"] image:dashboard/images/lens_advanced_1_1.png[Lens] diff --git a/docs/user/dashboard/lens.asciidoc b/docs/user/dashboard/lens.asciidoc index 9f17a380bc209a..7927489c596d77 100644 --- a/docs/user/dashboard/lens.asciidoc +++ b/docs/user/dashboard/lens.asciidoc @@ -300,7 +300,9 @@ image::images/lens_missing_values_strategy.png[Lens Missing values strategies me [[is-it-possible-to-change-the-scale-of-Y-axis]] ===== Is it possible to statically define the scale of the y-axis in a visualization? -The ability to start the y-axis from another value than 0, or use a logarithmic scale, is unsupported in *Lens*. +Yes, you can set the bounds on bar, line and area chart types in Lens, unless using percentage mode. Bar +and area charts must have 0 in the bounds. Logarithmic scales are unsupported in *Lens*. +To set the y-axis bounds, click the icon representing the axis you want to customize. [float] [[is-it-possible-to-have-pagination-for-datatable]] diff --git a/docs/user/production-considerations/production.asciidoc b/docs/user/production-considerations/production.asciidoc index 1ffca4b6ae6ab1..b75b556588cfd2 100644 --- a/docs/user/production-considerations/production.asciidoc +++ b/docs/user/production-considerations/production.asciidoc @@ -122,8 +122,6 @@ active in case of failure from the currently used instance. Kibana can be configured to connect to multiple Elasticsearch nodes in the same cluster. In situations where a node becomes unavailable, Kibana will transparently connect to an available node and continue operating. Requests to available hosts will be routed in a round robin fashion. -Currently the Console application is limited to connecting to the first node listed. - In kibana.yml: [source,js] -------- diff --git a/package.json b/package.json index 513352db3f81bb..c9c6fa7f582c59 100644 --- a/package.json +++ b/package.json @@ -215,7 +215,6 @@ "cytoscape-dagre": "^2.2.2", "d3": "3.5.17", "d3-array": "1.2.4", - "d3-cloud": "1.2.5", "d3-scale": "1.0.7", "d3-shape": "^1.1.0", "d3-time": "^1.1.0", @@ -671,7 +670,7 @@ "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^90.0.0", + "chromedriver": "^91.0.1", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compression-webpack-plugin": "^4.0.0", @@ -839,4 +838,4 @@ "yargs": "^15.4.1", "zlib": "^1.0.5" } -} +} \ No newline at end of file diff --git a/packages/kbn-apm-utils/src/index.ts b/packages/kbn-apm-utils/src/index.ts index 384b6683199e5b..09a6989091f609 100644 --- a/packages/kbn-apm-utils/src/index.ts +++ b/packages/kbn-apm-utils/src/index.ts @@ -14,6 +14,7 @@ export interface SpanOptions { type?: string; subtype?: string; labels?: Record; + intercept?: boolean; } type Span = Exclude; @@ -36,23 +37,27 @@ export async function withSpan( ): Promise { const options = parseSpanOptions(optionsOrName); - const { name, type, subtype, labels } = options; + const { name, type, subtype, labels, intercept } = options; if (!agent.isStarted()) { return cb(); } + let createdSpan: Span | undefined; + // When a span starts, it's marked as the active span in its context. // When it ends, it's not untracked, which means that if a span // starts directly after this one ends, the newly started span is a // child of this span, even though it should be a sibling. // To mitigate this, we queue a microtask by awaiting a promise. - await Promise.resolve(); + if (!intercept) { + await Promise.resolve(); - const span = agent.startSpan(name); + createdSpan = agent.startSpan(name) ?? undefined; - if (!span) { - return cb(); + if (!createdSpan) { + return cb(); + } } // If a span is created in the same context as the span that we just @@ -61,33 +66,51 @@ export async function withSpan( // mitigate this we create a new context. return runInNewContext(() => { + const promise = cb(createdSpan); + + let span: Span | undefined = createdSpan; + + if (intercept) { + span = agent.currentSpan ?? undefined; + } + + if (!span) { + return promise; + } + + const targetedSpan = span; + + if (name) { + targetedSpan.name = name; + } + // @ts-ignore if (type) { - span.type = type; + targetedSpan.type = type; } if (subtype) { - span.subtype = subtype; + targetedSpan.subtype = subtype; } if (labels) { - span.addLabels(labels); + targetedSpan.addLabels(labels); } - return cb(span) + return promise .then((res) => { - if (!span.outcome || span.outcome === 'unknown') { - span.outcome = 'success'; + if (!targetedSpan.outcome || targetedSpan.outcome === 'unknown') { + targetedSpan.outcome = 'success'; } return res; }) .catch((err) => { - if (!span.outcome || span.outcome === 'unknown') { - span.outcome = 'failure'; + if (!targetedSpan.outcome || targetedSpan.outcome === 'unknown') { + targetedSpan.outcome = 'failure'; } throw err; }) .finally(() => { - span.end(); + targetedSpan.end(); }); }); } diff --git a/packages/kbn-test/src/jest/utils/router_helpers.tsx b/packages/kbn-test/src/jest/utils/router_helpers.tsx index e2245440274d19..85ef27488a4ce9 100644 --- a/packages/kbn-test/src/jest/utils/router_helpers.tsx +++ b/packages/kbn-test/src/jest/utils/router_helpers.tsx @@ -8,18 +8,39 @@ import React, { Component, ComponentType } from 'react'; import { MemoryRouter, Route, withRouter } from 'react-router-dom'; -import * as H from 'history'; +import { History, LocationDescriptor } from 'history'; -export const WithMemoryRouter = (initialEntries: string[] = ['/'], initialIndex: number = 0) => ( - WrappedComponent: ComponentType -) => (props: any) => ( +const stringifyPath = (path: LocationDescriptor): string => { + if (typeof path === 'string') { + return path; + } + + return path.pathname || '/'; +}; + +const locationDescriptorToRoutePath = ( + paths: LocationDescriptor | LocationDescriptor[] +): string | string[] => { + if (Array.isArray(paths)) { + return paths.map((path: LocationDescriptor) => { + return stringifyPath(path); + }); + } + + return stringifyPath(paths); +}; + +export const WithMemoryRouter = ( + initialEntries: LocationDescriptor[] = ['/'], + initialIndex: number = 0 +) => (WrappedComponent: ComponentType) => (props: any) => ( ); export const WithRoute = ( - componentRoutePath: string | string[] = '/', + componentRoutePath: LocationDescriptor | LocationDescriptor[] = ['/'], onRouter = (router: any) => {} ) => (WrappedComponent: ComponentType) => { // Create a class component that will catch the router @@ -40,16 +61,16 @@ export const WithRoute = ( return (props: any) => ( } /> ); }; interface Router { - history: Partial; + history: Partial; route: { - location: H.Location; + location: LocationDescriptor; }; } diff --git a/packages/kbn-test/src/jest/utils/testbed/types.ts b/packages/kbn-test/src/jest/utils/testbed/types.ts index fdc000215c4f19..bba504951c0bc6 100644 --- a/packages/kbn-test/src/jest/utils/testbed/types.ts +++ b/packages/kbn-test/src/jest/utils/testbed/types.ts @@ -8,6 +8,7 @@ import { Store } from 'redux'; import { ReactWrapper } from 'enzyme'; +import { LocationDescriptor } from 'history'; export type SetupFunc = (props?: any) => TestBed | Promise>; @@ -161,11 +162,11 @@ export interface MemoryRouterConfig { /** Flag to add or not the `MemoryRouter`. If set to `false`, there won't be any router and the component won't be wrapped on a ``. */ wrapComponent?: boolean; /** The React Router **initial entries** setting ([see documentation](https://github.com/ReactTraining/react-router/blob/master/packages/react-router/docs/api/MemoryRouter.md)) */ - initialEntries?: string[]; + initialEntries?: LocationDescriptor[]; /** The React Router **initial index** setting ([see documentation](https://github.com/ReactTraining/react-router/blob/master/packages/react-router/docs/api/MemoryRouter.md)) */ initialIndex?: number; /** The route **path** for the mounted component (defaults to `"/"`) */ - componentRoutePath?: string | string[]; + componentRoutePath?: LocationDescriptor | LocationDescriptor[]; /** A callBack that will be called with the React Router instance once mounted */ onRouter?: (router: any) => void; } diff --git a/packages/kbn-test/src/kbn_client/import_export/parse_archive.test.ts b/packages/kbn-test/src/kbn_client/import_export/parse_archive.test.ts new file mode 100644 index 00000000000000..25651a0dd21902 --- /dev/null +++ b/packages/kbn-test/src/kbn_client/import_export/parse_archive.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { parseArchive } from './parse_archive'; + +jest.mock('fs/promises', () => ({ + readFile: jest.fn(), +})); + +const mockReadFile = jest.requireMock('fs/promises').readFile; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('parses archives with \\n', async () => { + mockReadFile.mockResolvedValue( + `{ + "foo": "abc" + }\n\n{ + "foo": "xyz" + }` + ); + + const archive = await parseArchive('mock'); + expect(archive).toMatchInlineSnapshot(` + Array [ + Object { + "foo": "abc", + }, + Object { + "foo": "xyz", + }, + ] + `); +}); + +it('parses archives with \\r\\n', async () => { + mockReadFile.mockResolvedValue( + `{ + "foo": "123" + }\r\n\r\n{ + "foo": "456" + }` + ); + + const archive = await parseArchive('mock'); + expect(archive).toMatchInlineSnapshot(` + Array [ + Object { + "foo": "123", + }, + Object { + "foo": "456", + }, + ] + `); +}); diff --git a/packages/kbn-test/src/kbn_client/import_export/parse_archive.ts b/packages/kbn-test/src/kbn_client/import_export/parse_archive.ts new file mode 100644 index 00000000000000..b6b85ba521525b --- /dev/null +++ b/packages/kbn-test/src/kbn_client/import_export/parse_archive.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Fs from 'fs/promises'; + +export interface SavedObject { + id: string; + type: string; + [key: string]: unknown; +} + +export async function parseArchive(path: string): Promise { + return (await Fs.readFile(path, 'utf-8')) + .split(/\r?\n\r?\n/) + .filter((line) => !!line) + .map((line) => JSON.parse(line)); +} diff --git a/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts b/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts index 88953cdbaed7c9..4adae7d1cd031e 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts @@ -16,25 +16,12 @@ import { ToolingLog, isAxiosResponseError, createFailError, REPO_ROOT } from '@k import { KbnClientRequester, uriencode, ReqOptions } from './kbn_client_requester'; import { KbnClientSavedObjects } from './kbn_client_saved_objects'; +import { parseArchive } from './import_export/parse_archive'; interface ImportApiResponse { success: boolean; [key: string]: unknown; } - -interface SavedObject { - id: string; - type: string; - [key: string]: unknown; -} - -async function parseArchive(path: string): Promise { - return (await Fs.readFile(path, 'utf-8')) - .split('\n\n') - .filter((line) => !!line) - .map((line) => JSON.parse(line)); -} - export class KbnClientImportExport { constructor( public readonly log: ToolingLog, diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 5658d3f6260772..3ed164088bf5c7 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -497,6 +497,56 @@ describe('#start()', () => { expect(getUrlForApp('app1', { path: 'deep/link///' })).toBe('/base-path/app/app1/deep/link'); }); + describe('deepLinkId option', () => { + it('ignores the deepLinkId parameter if it is unknown', async () => { + service.setup(setupDeps); + + service.setup(setupDeps); + const { getUrlForApp } = await service.start(startDeps); + + expect(getUrlForApp('app1', { deepLinkId: 'unkown-deep-link' })).toBe( + '/base-path/app/app1' + ); + }); + + it('creates URLs with deepLinkId parameter', async () => { + const { register } = service.setup(setupDeps); + + register( + Symbol(), + createApp({ + id: 'app1', + appRoute: '/custom/app-path', + deepLinks: [{ id: 'dl1', title: 'deep link 1', path: '/deep-link' }], + }) + ); + + const { getUrlForApp } = await service.start(startDeps); + + expect(getUrlForApp('app1', { deepLinkId: 'dl1' })).toBe( + '/base-path/custom/app-path/deep-link' + ); + }); + + it('creates URLs with deepLinkId and path parameters', async () => { + const { register } = service.setup(setupDeps); + + register( + Symbol(), + createApp({ + id: 'app1', + appRoute: '/custom/app-path', + deepLinks: [{ id: 'dl1', title: 'deep link 1', path: '/deep-link' }], + }) + ); + + const { getUrlForApp } = await service.start(startDeps); + expect(getUrlForApp('app1', { deepLinkId: 'dl1', path: 'foo/bar' })).toBe( + '/base-path/custom/app-path/deep-link/foo/bar' + ); + }); + }); + it('does not append trailing slash if hash is provided in path parameter', async () => { service.setup(setupDeps); const { getUrlForApp } = await service.start(startDeps); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 32d45b32c32ffd..8c6090caabce19 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -282,8 +282,19 @@ export class ApplicationService { history: this.history!, getUrlForApp: ( appId, - { path, absolute = false }: { path?: string; absolute?: boolean } = {} + { + path, + absolute = false, + deepLinkId, + }: { path?: string; absolute?: boolean; deepLinkId?: string } = {} ) => { + if (deepLinkId) { + const deepLinkPath = getAppDeepLinkPath(availableMounters, appId, deepLinkId); + if (deepLinkPath) { + path = appendAppPath(deepLinkPath, path); + } + } + const relUrl = http.basePath.prepend(getAppUrl(availableMounters, appId, path)); return absolute ? relativeToAbsolute(relUrl) : relUrl; }, diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 60b0dbf158dd91..5803f2e3779abc 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -780,7 +780,10 @@ export interface ApplicationStart { * @param options.path - optional path inside application to deep link to * @param options.absolute - if true, will returns an absolute url instead of a relative one */ - getUrlForApp(appId: string, options?: { path?: string; absolute?: boolean }): string; + getUrlForApp( + appId: string, + options?: { path?: string; absolute?: boolean; deepLinkId?: string } + ): string; /** * An observable that emits the current application id and each subsequent id update. diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 53428edf4b345f..06277d9351922c 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -142,7 +142,7 @@ export class DocLinksService { dataStreams: `${ELASTICSEARCH_DOCS}data-streams.html`, indexModules: `${ELASTICSEARCH_DOCS}index-modules.html`, indexSettings: `${ELASTICSEARCH_DOCS}index-modules.html#index-modules-settings`, - indexTemplates: `${ELASTICSEARCH_DOCS}indices-templates.html`, + indexTemplates: `${ELASTICSEARCH_DOCS}index-templates.html`, mapping: `${ELASTICSEARCH_DOCS}mapping.html`, mappingAnalyzer: `${ELASTICSEARCH_DOCS}analyzer.html`, mappingCoerce: `${ELASTICSEARCH_DOCS}coerce.html`, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 235110aeb4633c..d3426b50f76143 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -150,6 +150,7 @@ export interface ApplicationStart { getUrlForApp(appId: string, options?: { path?: string; absolute?: boolean; + deepLinkId?: string; }): string; navigateToApp(appId: string, options?: NavigateToAppOptions): Promise; navigateToUrl(url: string): Promise; diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/archives/8.0.0_oss_sample_saved_objects.zip b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/8.0.0_oss_sample_saved_objects.zip deleted file mode 100644 index abb8dd2b6d491c..00000000000000 Binary files a/src/core/server/saved_objects/migrationsv2/integration_tests/archives/8.0.0_oss_sample_saved_objects.zip and /dev/null differ diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/archives/8.0.0_v1_migrations_sample_data_saved_objects.zip b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/8.0.0_v1_migrations_sample_data_saved_objects.zip new file mode 100644 index 00000000000000..ff02fcf204845d Binary files /dev/null and b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/8.0.0_v1_migrations_sample_data_saved_objects.zip differ diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts index f9d8e7cc4fbaab..f4e0dd8fffcab1 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts @@ -21,13 +21,37 @@ import { Root } from '../../../root'; const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo.version; -const logFilePath = Path.join(__dirname, 'migration_test_kibana.log'); +const logFilePath = Path.join(__dirname, 'migration_test_kibana_from_v1.log'); const asyncUnlink = Util.promisify(Fs.unlink); async function removeLogFile() { // ignore errors if it doesn't exist await asyncUnlink(logFilePath).catch(() => void 0); } +const assertMigratedDocuments = (arr: any[], target: any[]) => target.every((v) => arr.includes(v)); + +function sortByTypeAndId(a: { type: string; id: string }, b: { type: string; id: string }) { + return a.type.localeCompare(b.type) || a.id.localeCompare(b.id); +} + +async function fetchDocuments(esClient: ElasticsearchClient, index: string) { + const { body } = await esClient.search({ + index, + body: { + query: { + match_all: {}, + }, + _source: ['type', 'id'], + }, + }); + + return body.hits.hits + .map((h) => ({ + ...h._source, + id: h._id, + })) + .sort(sortByTypeAndId); +} describe('migration v2', () => { let esServer: kbnTestServer.TestElasticsearchUtils; @@ -40,7 +64,7 @@ describe('migration v2', () => { adjustTimeout: (t: number) => jest.setTimeout(t), settings: { es: { - license: 'trial', + license: 'basic', dataArchive, }, }, @@ -51,8 +75,8 @@ describe('migration v2', () => { migrations: { skip: false, enableV2: true, - // There are 53 docs in fixtures. Batch size configured to enforce 3 migration steps. - batchSize: 20, + // There are 40 docs in fixtures. Batch size configured to enforce 3 migration steps. + batchSize: 15, }, logging: { appenders: { @@ -85,8 +109,7 @@ describe('migration v2', () => { coreStart = start; esClient = coreStart.elasticsearch.client.asInternalUser; }); - - await Promise.all([startEsPromise, startKibanaPromise]); + return await Promise.all([startEsPromise, startKibanaPromise]); }; const getExpectedVersionPerType = () => @@ -192,15 +215,19 @@ describe('migration v2', () => { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/91107 - describe.skip('migrating from the same Kibana version', () => { + describe('migrating from the same Kibana version that used v1 migrations', () => { + const originalIndex = `.kibana_1`; // v1 migrations index const migratedIndex = `.kibana_${kibanaVersion}_001`; beforeAll(async () => { await removeLogFile(); await startServers({ - oss: true, - dataArchive: Path.join(__dirname, 'archives', '8.0.0_oss_sample_saved_objects.zip'), + oss: false, + dataArchive: Path.join( + __dirname, + 'archives', + '8.0.0_v1_migrations_sample_data_saved_objects.zip' + ), }); }); @@ -215,7 +242,6 @@ describe('migration v2', () => { }, { ignore: [404] } ); - const response = body[migratedIndex]; expect(response).toBeDefined(); @@ -225,17 +251,23 @@ describe('migration v2', () => { ]); }); - it('copies all the document of the previous index to the new one', async () => { + it('copies the documents from the previous index to the new one', async () => { + // original assertion on document count comparison (how atteched are we to this assertion?) const migratedIndexResponse = await esClient.count({ index: migratedIndex, }); const oldIndexResponse = await esClient.count({ - index: '.kibana_1', + index: originalIndex, }); // Use a >= comparison since once Kibana has started it might create new // documents like telemetry tasks expect(migratedIndexResponse.body.count).toBeGreaterThanOrEqual(oldIndexResponse.body.count); + + // new assertion against a document array comparison + const originalDocs = await fetchDocuments(esClient, originalIndex); + const migratedDocs = await fetchDocuments(esClient, migratedIndex); + expect(assertMigratedDocuments(migratedDocs, originalDocs)); }); it('migrates the documents to the highest version', async () => { diff --git a/src/dev/typescript/build_ts_refs.ts b/src/dev/typescript/build_ts_refs.ts index 2e25827996e453..26425b7a3e61df 100644 --- a/src/dev/typescript/build_ts_refs.ts +++ b/src/dev/typescript/build_ts_refs.ts @@ -13,12 +13,20 @@ import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; export const REF_CONFIG_PATHS = [Path.resolve(REPO_ROOT, 'tsconfig.refs.json')]; -export async function buildAllTsRefs(log: ToolingLog) { +export async function buildAllTsRefs(log: ToolingLog): Promise<{ failed: boolean }> { for (const path of REF_CONFIG_PATHS) { const relative = Path.relative(REPO_ROOT, path); log.debug(`Building TypeScript projects refs for ${relative}...`); - await execa(require.resolve('typescript/bin/tsc'), ['-b', relative, '--pretty'], { - cwd: REPO_ROOT, - }); + const { failed, stdout } = await execa( + require.resolve('typescript/bin/tsc'), + ['-b', relative, '--pretty'], + { + cwd: REPO_ROOT, + reject: false, + } + ); + log.info(stdout); + if (failed) return { failed }; } + return { failed: false }; } diff --git a/src/dev/typescript/run_type_check_cli.ts b/src/dev/typescript/run_type_check_cli.ts index f95c230f44b9e4..d9e9eb036fe0f2 100644 --- a/src/dev/typescript/run_type_check_cli.ts +++ b/src/dev/typescript/run_type_check_cli.ts @@ -69,7 +69,11 @@ export async function runTypeCheckCli() { process.exit(); } - await buildAllTsRefs(log); + const { failed } = await buildAllTsRefs(log); + if (failed) { + log.error('Unable to build TS project refs'); + process.exit(1); + } const tscArgs = [ // composite project cannot be used with --noEmit diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts index 2aa0d346afe343..523bbe1f010181 100644 --- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts @@ -174,6 +174,57 @@ const nestedTermResponse = { status: 200, }; +const exhaustiveNestedTermResponse = { + took: 10, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: 14005, + max_score: 0, + hits: [], + }, + aggregations: { + '1': { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 8325, + buckets: [ + { + '2': { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'ios', doc_count: 2850 }, + { key: 'win xp', doc_count: 2830 }, + { key: '__missing__', doc_count: 1430 }, + ], + }, + key: 'US-with-dash', + doc_count: 2850, + }, + { + '2': { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'ios', doc_count: 1850 }, + { key: 'win xp', doc_count: 1830 }, + { key: '__missing__', doc_count: 130 }, + ], + }, + key: 'IN-with-dash', + doc_count: 2830, + }, + ], + }, + }, + status: 200, +}; + const nestedTermResponseNoResults = { took: 10, timed_out: false, @@ -326,6 +377,17 @@ describe('Terms Agg Other bucket helper', () => { } }); + test('does not build query if sum_other_doc_count is 0 (exhaustive terms)', () => { + const aggConfigs = getAggConfigs(nestedTerm.aggs); + expect( + buildOtherBucketAgg( + aggConfigs, + aggConfigs.aggs[1] as IBucketAggConfig, + exhaustiveNestedTermResponse + ) + ).toBeFalsy(); + }); + test('excludes exists filter for scripted fields', () => { const aggConfigs = getAggConfigs(nestedTerm.aggs); aggConfigs.aggs[1].params.field.scripted = true; diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts index 372d487bcf7a39..2a1cd873f62822 100644 --- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts +++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts @@ -156,6 +156,7 @@ export const buildOtherBucketAgg = ( }; let noAggBucketResults = false; + let exhaustiveBuckets = true; // recursively create filters for all parent aggregation buckets const walkBucketTree = ( @@ -175,6 +176,9 @@ export const buildOtherBucketAgg = ( const newAggIndex = aggIndex + 1; const newAgg = bucketAggs[newAggIndex]; const currentAgg = bucketAggs[aggIndex]; + if (aggIndex === index && agg && agg.sum_other_doc_count > 0) { + exhaustiveBuckets = false; + } if (aggIndex < index) { each(agg.buckets, (bucket: any, bucketObjKey) => { const bucketKey = currentAgg.getKey( @@ -223,7 +227,7 @@ export const buildOtherBucketAgg = ( walkBucketTree(0, response.aggregations, bucketAggs[0].id, [], ''); // bail if there were no bucket results - if (noAggBucketResults) { + if (noAggBucketResults || exhaustiveBuckets) { return false; } diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx index 14086e6ee81370..1352081eaa30bc 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx @@ -11,9 +11,7 @@ import React, { createContext, useContext } from 'react'; import { useRequest } from '../../../public'; -import { Error as CustomError } from './section_error'; - -import { Privileges } from '../types'; +import { Privileges, Error as CustomError } from '../types'; interface Authorization { isLoading: boolean; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/index.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/index.ts index b1306028737243..f8eb7e3c7c0c83 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/index.ts +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/index.ts @@ -16,4 +16,6 @@ export { WithPrivileges } from './with_privileges'; export { NotAuthorizedSection } from './not_authorized_section'; -export { Error, SectionError } from './section_error'; +export { SectionError } from './section_error'; + +export { PageError } from './page_error'; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/page_error.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/page_error.tsx new file mode 100644 index 00000000000000..0a27b4098681b6 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/page_error.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiSpacer, EuiEmptyPrompt, EuiPageContent } from '@elastic/eui'; +import React from 'react'; +import { APP_WRAPPER_CLASS } from '../../../../../../src/core/public'; +import { Error } from '../types'; + +interface Props { + title: React.ReactNode; + error: Error; + actions?: JSX.Element; + isCentered?: boolean; +} + +/* + * A reusable component to handle full page errors. + * This is based on Kibana design guidelines related + * to the new management navigation structure. + * In some scenarios, it replaces the usage of . + */ + +export const PageError: React.FunctionComponent = ({ + title, + error, + actions, + isCentered, + ...rest +}) => { + const { + error: errorString, + cause, // wrapEsError() on the server adds a "cause" array + message, + } = error; + + const errorContent = ( + + {title}} + body={ + <> + {cause ? message || errorString :

{message || errorString}

} + {cause && ( + <> + +
    + {cause.map((causeMsg, i) => ( +
  • {causeMsg}
  • + ))} +
+ + )} + + } + iconType="alert" + actions={actions} + {...rest} + /> +
+ ); + + if (isCentered) { + return
{errorContent}
; + } + + return errorContent; +}; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/section_error.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/section_error.tsx index c0b3533c8594b4..a1652b4e153f58 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/section_error.tsx +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/section_error.tsx @@ -8,12 +8,7 @@ import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import React, { Fragment } from 'react'; - -export interface Error { - error: string; - cause?: string[]; - message?: string; -} +import { Error } from '../types'; interface Props { title: React.ReactNode; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/index.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/index.ts index 089dc890c3e6cf..e63d98512a2cd8 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/index.ts +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/index.ts @@ -12,8 +12,8 @@ export { AuthorizationProvider, AuthorizationContext, SectionError, - Error, + PageError, useAuthorizationContext, } from './components'; -export { Privileges, MissingPrivileges } from './types'; +export { Privileges, MissingPrivileges, Error } from './types'; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/types.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/types.ts index b10318aa415b34..70b54b0b6e425e 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/types.ts +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/types.ts @@ -14,3 +14,9 @@ export interface Privileges { hasAllPrivileges: boolean; missingPrivileges: MissingPrivileges; } + +export interface Error { + error: string; + cause?: string[]; + message?: string; +} diff --git a/src/plugins/es_ui_shared/public/authorization/index.ts b/src/plugins/es_ui_shared/public/authorization/index.ts index 483fffd9c48595..f68ad3da2a4b54 100644 --- a/src/plugins/es_ui_shared/public/authorization/index.ts +++ b/src/plugins/es_ui_shared/public/authorization/index.ts @@ -14,6 +14,7 @@ export { NotAuthorizedSection, Privileges, SectionError, + PageError, useAuthorizationContext, WithPrivileges, } from '../../__packages_do_not_import__/authorization'; diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index b46a23994fe935..7b9013c043a0e1 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -40,6 +40,7 @@ export { Privileges, MissingPrivileges, SectionError, + PageError, Error, useAuthorizationContext, } from './authorization'; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index 181bd9959c1bbd..fb334afb22b137 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -18,7 +18,7 @@ const DEFAULT_OPTIONS = { stripEmptyFields: true, }; -interface UseFormReturn { +export interface UseFormReturn { form: FormHook; } diff --git a/src/plugins/expressions/common/execution/execution.test.ts b/src/plugins/expressions/common/execution/execution.test.ts index 69687f75f30982..feff425cc48edd 100644 --- a/src/plugins/expressions/common/execution/execution.test.ts +++ b/src/plugins/expressions/common/execution/execution.test.ts @@ -834,8 +834,8 @@ describe('Execution', () => { expect((chain[0].arguments.val[0] as ExpressionAstExpression).chain[0].debug!.args).toEqual( { - name: 'foo', - value: 5, + name: ['foo'], + value: [5], } ); }); diff --git a/src/plugins/expressions/common/expression_functions/specs/index.ts b/src/plugins/expressions/common/expression_functions/specs/index.ts index 20a6f9aac45674..c6d89f41d0e0d3 100644 --- a/src/plugins/expressions/common/expression_functions/specs/index.ts +++ b/src/plugins/expressions/common/expression_functions/specs/index.ts @@ -12,6 +12,7 @@ export * from './var_set'; export * from './var'; export * from './theme'; export * from './cumulative_sum'; +export * from './overall_metric'; export * from './derivative'; export * from './moving_average'; export * from './ui_setting'; diff --git a/src/plugins/expressions/common/expression_functions/specs/overall_metric.ts b/src/plugins/expressions/common/expression_functions/specs/overall_metric.ts new file mode 100644 index 00000000000000..e42112d3a23ed9 --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/overall_metric.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../types'; +import { Datatable } from '../../expression_types'; +import { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers'; + +export interface OverallMetricArgs { + by?: string[]; + inputColumnId: string; + outputColumnId: string; + outputColumnName?: string; + metric: 'sum' | 'min' | 'max' | 'average'; +} + +export type ExpressionFunctionOverallMetric = ExpressionFunctionDefinition< + 'overall_metric', + Datatable, + OverallMetricArgs, + Datatable +>; + +function getValueAsNumberArray(value: unknown) { + if (Array.isArray(value)) { + return value.map((innerVal) => Number(innerVal)); + } else { + return [Number(value)]; + } +} + +/** + * Calculates the overall metric of a specified column in the data table. + * + * Also supports multiple series in a single data table - use the `by` argument + * to specify the columns to split the calculation by. + * For each unique combination of all `by` columns a separate overall metric will be calculated. + * The order of rows won't be changed - this function is not modifying any existing columns, it's only + * adding the specified `outputColumnId` column to every row of the table without adding or removing rows. + * + * Behavior: + * * Will write the overall metric of `inputColumnId` into `outputColumnId` + * * If provided will use `outputColumnName` as name for the newly created column. Otherwise falls back to `outputColumnId` + * * Each cell will contain the calculated metric based on the values of all cells belonging to the current series. + * + * Edge cases: + * * Will return the input table if `inputColumnId` does not exist + * * Will throw an error if `outputColumnId` exists already in provided data table + * * If the row value contains `null` or `undefined`, it will be ignored and overwritten with the overall metric of + * all cells of the same series. + * * For all values besides `null` and `undefined`, the value will be cast to a number before it's added to the + * overall metric of the current series - if this results in `NaN` (like in case of objects), all cells of the + * current series will be set to `NaN`. + * * To determine separate series defined by the `by` columns, the values of these columns will be cast to strings + * before comparison. If the values are objects, the return value of their `toString` method will be used for comparison. + * Missing values (`null` and `undefined`) will be treated as empty strings. + */ +export const overallMetric: ExpressionFunctionOverallMetric = { + name: 'overall_metric', + type: 'datatable', + + inputTypes: ['datatable'], + + help: i18n.translate('expressions.functions.overallMetric.help', { + defaultMessage: 'Calculates the overall sum, min, max or average of a column in a data table', + }), + + args: { + by: { + help: i18n.translate('expressions.functions.overallMetric.args.byHelpText', { + defaultMessage: 'Column to split the overall calculation by', + }), + multi: true, + types: ['string'], + required: false, + }, + metric: { + help: i18n.translate('expressions.functions.overallMetric.metricHelpText', { + defaultMessage: 'Metric to calculate', + }), + types: ['string'], + options: ['sum', 'min', 'max', 'average'], + }, + inputColumnId: { + help: i18n.translate('expressions.functions.overallMetric.args.inputColumnIdHelpText', { + defaultMessage: 'Column to calculate the overall metric of', + }), + types: ['string'], + required: true, + }, + outputColumnId: { + help: i18n.translate('expressions.functions.overallMetric.args.outputColumnIdHelpText', { + defaultMessage: 'Column to store the resulting overall metric in', + }), + types: ['string'], + required: true, + }, + outputColumnName: { + help: i18n.translate('expressions.functions.overallMetric.args.outputColumnNameHelpText', { + defaultMessage: 'Name of the column to store the resulting overall metric in', + }), + types: ['string'], + required: false, + }, + }, + + fn(input, { by, inputColumnId, outputColumnId, outputColumnName, metric }) { + const resultColumns = buildResultColumns( + input, + outputColumnId, + inputColumnId, + outputColumnName + ); + + if (!resultColumns) { + return input; + } + + const accumulators: Partial> = {}; + const valueCounter: Partial> = {}; + input.rows.forEach((row) => { + const bucketIdentifier = getBucketIdentifier(row, by); + const accumulatorValue = accumulators[bucketIdentifier] ?? 0; + + const currentValue = row[inputColumnId]; + if (currentValue != null) { + const currentNumberValues = getValueAsNumberArray(currentValue); + switch (metric) { + case 'average': + valueCounter[bucketIdentifier] = + (valueCounter[bucketIdentifier] ?? 0) + currentNumberValues.length; + case 'sum': + accumulators[bucketIdentifier] = + accumulatorValue + currentNumberValues.reduce((a, b) => a + b, 0); + break; + case 'min': + accumulators[bucketIdentifier] = Math.min(accumulatorValue, ...currentNumberValues); + break; + case 'max': + accumulators[bucketIdentifier] = Math.max(accumulatorValue, ...currentNumberValues); + break; + } + } + }); + if (metric === 'average') { + Object.keys(accumulators).forEach((bucketIdentifier) => { + accumulators[bucketIdentifier] = + accumulators[bucketIdentifier]! / valueCounter[bucketIdentifier]!; + }); + } + return { + ...input, + columns: resultColumns, + rows: input.rows.map((row) => { + const newRow = { ...row }; + const bucketIdentifier = getBucketIdentifier(row, by); + newRow[outputColumnId] = accumulators[bucketIdentifier]; + + return newRow; + }), + }; + }, +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/overall_metric.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/overall_metric.test.ts new file mode 100644 index 00000000000000..30354c4e54dc76 --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/tests/overall_metric.test.ts @@ -0,0 +1,450 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { functionWrapper } from './utils'; +import { ExecutionContext } from '../../../execution/types'; +import { Datatable } from '../../../expression_types/specs/datatable'; +import { overallMetric, OverallMetricArgs } from '../overall_metric'; + +describe('interpreter/functions#overall_metric', () => { + const fn = functionWrapper(overallMetric); + const runFn = (input: Datatable, args: OverallMetricArgs) => + fn(input, args, {} as ExecutionContext) as Datatable; + + it('calculates overall sum', () => { + const result = runFn( + { + type: 'datatable', + columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], + rows: [{ val: 5 }, { val: 7 }, { val: 3 }, { val: 2 }], + }, + { inputColumnId: 'val', outputColumnId: 'output', metric: 'sum' } + ); + expect(result.columns).toContainEqual({ + id: 'output', + name: 'output', + meta: { type: 'number' }, + }); + expect(result.rows.map((row) => row.output)).toEqual([17, 17, 17, 17]); + }); + + it('ignores null or undefined', () => { + const result = runFn( + { + type: 'datatable', + columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], + rows: [{}, { val: null }, { val: undefined }, { val: 1 }, { val: 5 }], + }, + { inputColumnId: 'val', outputColumnId: 'output', metric: 'average' } + ); + expect(result.columns).toContainEqual({ + id: 'output', + name: 'output', + meta: { type: 'number' }, + }); + expect(result.rows.map((row) => row.output)).toEqual([3, 3, 3, 3, 3]); + }); + + it('calculates overall sum for multiple series', () => { + const result = runFn( + { + type: 'datatable', + columns: [ + { id: 'val', name: 'val', meta: { type: 'number' } }, + { id: 'split', name: 'split', meta: { type: 'string' } }, + ], + rows: [ + { val: 1, split: 'A' }, + { val: 2, split: 'B' }, + { val: 3, split: 'B' }, + { val: 4, split: 'A' }, + { val: 5, split: 'A' }, + { val: 6, split: 'A' }, + { val: 7, split: 'B' }, + { val: 8, split: 'B' }, + ], + }, + { inputColumnId: 'val', outputColumnId: 'output', by: ['split'], metric: 'sum' } + ); + + expect(result.rows.map((row) => row.output)).toEqual([ + 1 + 4 + 5 + 6, + 2 + 3 + 7 + 8, + 2 + 3 + 7 + 8, + 1 + 4 + 5 + 6, + 1 + 4 + 5 + 6, + 1 + 4 + 5 + 6, + 2 + 3 + 7 + 8, + 2 + 3 + 7 + 8, + ]); + }); + + it('treats missing split column as separate series', () => { + const result = runFn( + { + type: 'datatable', + columns: [ + { id: 'val', name: 'val', meta: { type: 'number' } }, + { id: 'split', name: 'split', meta: { type: 'string' } }, + ], + rows: [ + { val: 1, split: 'A' }, + { val: 2, split: 'B' }, + { val: 3 }, + { val: 4, split: 'A' }, + { val: 5 }, + { val: 6, split: 'A' }, + { val: 7, split: 'B' }, + { val: 8, split: 'B' }, + ], + }, + { inputColumnId: 'val', outputColumnId: 'output', by: ['split'], metric: 'sum' } + ); + expect(result.rows.map((row) => row.output)).toEqual([ + 1 + 4 + 6, + 2 + 7 + 8, + 3 + 5, + 1 + 4 + 6, + 3 + 5, + 1 + 4 + 6, + 2 + 7 + 8, + 2 + 7 + 8, + ]); + }); + + it('treats null like undefined and empty string for split columns', () => { + const table: Datatable = { + type: 'datatable', + columns: [ + { id: 'val', name: 'val', meta: { type: 'number' } }, + { id: 'split', name: 'split', meta: { type: 'string' } }, + ], + rows: [ + { val: 1, split: 'A' }, + { val: 2, split: 'B' }, + { val: 3 }, + { val: 4, split: 'A' }, + { val: 5 }, + { val: 6, split: 'A' }, + { val: 7, split: null }, + { val: 8, split: 'B' }, + { val: 9, split: '' }, + ], + }; + + const result = runFn(table, { + inputColumnId: 'val', + outputColumnId: 'output', + by: ['split'], + metric: 'sum', + }); + expect(result.rows.map((row) => row.output)).toEqual([ + 1 + 4 + 6, + 2 + 8, + 3 + 5 + 7 + 9, + 1 + 4 + 6, + 3 + 5 + 7 + 9, + 1 + 4 + 6, + 3 + 5 + 7 + 9, + 2 + 8, + 3 + 5 + 7 + 9, + ]); + + const result2 = runFn(table, { + inputColumnId: 'val', + outputColumnId: 'output', + by: ['split'], + metric: 'max', + }); + expect(result2.rows.map((row) => row.output)).toEqual([6, 8, 9, 6, 9, 6, 9, 8, 9]); + }); + + it('handles array values', () => { + const result = runFn( + { + type: 'datatable', + columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], + rows: [{ val: 5 }, { val: [7, 10] }, { val: [3, 1] }, { val: 2 }], + }, + { inputColumnId: 'val', outputColumnId: 'output', metric: 'sum' } + ); + expect(result.columns).toContainEqual({ + id: 'output', + name: 'output', + meta: { type: 'number' }, + }); + expect(result.rows.map((row) => row.output)).toEqual([28, 28, 28, 28]); + }); + + it('takes array values into account for average calculation', () => { + const result = runFn( + { + type: 'datatable', + columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], + rows: [{ val: [3, 4] }, { val: 2 }], + }, + { inputColumnId: 'val', outputColumnId: 'output', metric: 'average' } + ); + expect(result.columns).toContainEqual({ + id: 'output', + name: 'output', + meta: { type: 'number' }, + }); + expect(result.rows.map((row) => row.output)).toEqual([3, 3]); + }); + + it('handles array values for split columns', () => { + const table: Datatable = { + type: 'datatable', + columns: [ + { id: 'val', name: 'val', meta: { type: 'number' } }, + { id: 'split', name: 'split', meta: { type: 'string' } }, + ], + rows: [ + { val: 1, split: 'A' }, + { val: [2, 11], split: 'B' }, + { val: 3 }, + { val: 4, split: 'A' }, + { val: 5 }, + { val: 6, split: 'A' }, + { val: 7, split: null }, + { val: 8, split: 'B' }, + { val: [9, 99], split: '' }, + ], + }; + + const result = runFn(table, { + inputColumnId: 'val', + outputColumnId: 'output', + by: ['split'], + metric: 'sum', + }); + expect(result.rows.map((row) => row.output)).toEqual([ + 1 + 4 + 6, + 2 + 11 + 8, + 3 + 5 + 7 + 9 + 99, + 1 + 4 + 6, + 3 + 5 + 7 + 9 + 99, + 1 + 4 + 6, + 3 + 5 + 7 + 9 + 99, + 2 + 11 + 8, + 3 + 5 + 7 + 9 + 99, + ]); + + const result2 = runFn(table, { + inputColumnId: 'val', + outputColumnId: 'output', + by: ['split'], + metric: 'max', + }); + expect(result2.rows.map((row) => row.output)).toEqual([6, 11, 99, 6, 99, 6, 99, 11, 99]); + }); + + it('calculates cumulative sum for multiple series by multiple split columns', () => { + const result = runFn( + { + type: 'datatable', + columns: [ + { id: 'val', name: 'val', meta: { type: 'number' } }, + { id: 'split', name: 'split', meta: { type: 'string' } }, + { id: 'split2', name: 'split2', meta: { type: 'string' } }, + ], + rows: [ + { val: 1, split: 'A', split2: 'C' }, + { val: 2, split: 'B', split2: 'C' }, + { val: 3, split2: 'C' }, + { val: 4, split: 'A', split2: 'C' }, + { val: 5 }, + { val: 6, split: 'A', split2: 'D' }, + { val: 7, split: 'B', split2: 'D' }, + { val: 8, split: 'B', split2: 'D' }, + ], + }, + { inputColumnId: 'val', outputColumnId: 'output', by: ['split', 'split2'], metric: 'sum' } + ); + expect(result.rows.map((row) => row.output)).toEqual([1 + 4, 2, 3, 1 + 4, 5, 6, 7 + 8, 7 + 8]); + }); + + it('splits separate series by the string representation of the cell values', () => { + const result = runFn( + { + type: 'datatable', + columns: [ + { id: 'val', name: 'val', meta: { type: 'number' } }, + { id: 'split', name: 'split', meta: { type: 'string' } }, + ], + rows: [ + { val: 1, split: { anObj: 3 } }, + { val: 2, split: { anotherObj: 5 } }, + { val: 10, split: 5 }, + { val: 11, split: '5' }, + ], + }, + { inputColumnId: 'val', outputColumnId: 'output', by: ['split'], metric: 'sum' } + ); + + expect(result.rows.map((row) => row.output)).toEqual([1 + 2, 1 + 2, 10 + 11, 10 + 11]); + }); + + it('casts values to number before calculating cumulative sum', () => { + const result = runFn( + { + type: 'datatable', + columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], + rows: [{ val: 5 }, { val: '7' }, { val: '3' }, { val: 2 }], + }, + { inputColumnId: 'val', outputColumnId: 'output', metric: 'max' } + ); + expect(result.rows.map((row) => row.output)).toEqual([7, 7, 7, 7]); + }); + + it('casts values to number before calculating metric for NaN like values', () => { + const result = runFn( + { + type: 'datatable', + columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], + rows: [{ val: 5 }, { val: '7' }, { val: {} }, { val: 2 }], + }, + { inputColumnId: 'val', outputColumnId: 'output', metric: 'min' } + ); + expect(result.rows.map((row) => row.output)).toEqual([NaN, NaN, NaN, NaN]); + }); + + it('skips undefined and null values', () => { + const result = runFn( + { + type: 'datatable', + columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], + rows: [ + { val: null }, + { val: 7 }, + { val: undefined }, + { val: undefined }, + { val: undefined }, + { val: undefined }, + { val: '3' }, + { val: 2 }, + { val: null }, + ], + }, + { inputColumnId: 'val', outputColumnId: 'output', metric: 'average' } + ); + expect(result.rows.map((row) => row.output)).toEqual([4, 4, 4, 4, 4, 4, 4, 4, 4]); + }); + + it('copies over meta information from the source column', () => { + const result = runFn( + { + type: 'datatable', + columns: [ + { + id: 'val', + name: 'val', + meta: { + type: 'number', + + field: 'afield', + index: 'anindex', + params: { id: 'number', params: { pattern: '000' } }, + source: 'synthetic', + sourceParams: { + some: 'params', + }, + }, + }, + ], + rows: [{ val: 5 }], + }, + { inputColumnId: 'val', outputColumnId: 'output', metric: 'sum' } + ); + expect(result.columns).toContainEqual({ + id: 'output', + name: 'output', + meta: { + type: 'number', + + field: 'afield', + index: 'anindex', + params: { id: 'number', params: { pattern: '000' } }, + source: 'synthetic', + sourceParams: { + some: 'params', + }, + }, + }); + }); + + it('sets output name on output column if specified', () => { + const result = runFn( + { + type: 'datatable', + columns: [ + { + id: 'val', + name: 'val', + meta: { + type: 'number', + }, + }, + ], + rows: [{ val: 5 }], + }, + { + inputColumnId: 'val', + outputColumnId: 'output', + outputColumnName: 'Output name', + metric: 'min', + } + ); + expect(result.columns).toContainEqual({ + id: 'output', + name: 'Output name', + meta: { type: 'number' }, + }); + }); + + it('returns source table if input column does not exist', () => { + const input: Datatable = { + type: 'datatable', + columns: [ + { + id: 'val', + name: 'val', + meta: { + type: 'number', + }, + }, + ], + rows: [{ val: 5 }], + }; + expect( + runFn(input, { inputColumnId: 'nonexisting', outputColumnId: 'output', metric: 'sum' }) + ).toBe(input); + }); + + it('throws an error if output column exists already', () => { + expect(() => + runFn( + { + type: 'datatable', + columns: [ + { + id: 'val', + name: 'val', + meta: { + type: 'number', + }, + }, + ], + rows: [{ val: 5 }], + }, + { inputColumnId: 'val', outputColumnId: 'val', metric: 'max' } + ) + ).toThrow(); + }); +}); diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/var_set.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/var_set.test.ts index 0a9f022ce89cad..cdcae61215fa42 100644 --- a/src/plugins/expressions/common/expression_functions/specs/tests/var_set.test.ts +++ b/src/plugins/expressions/common/expression_functions/specs/tests/var_set.test.ts @@ -9,6 +9,8 @@ import { functionWrapper } from './utils'; import { variableSet } from '../var_set'; import { ExecutionContext } from '../../../execution/types'; +import { createUnitTestExecutor } from '../../../test_helpers'; +import { first } from 'rxjs/operators'; describe('expression_functions', () => { describe('var_set', () => { @@ -32,21 +34,49 @@ describe('expression_functions', () => { }); it('updates a variable', () => { - const actual = fn(input, { name: 'test', value: 2 }, context); + const actual = fn(input, { name: ['test'], value: [2] }, context); expect(variables.test).toEqual(2); expect(actual).toEqual(input); }); it('sets a new variable', () => { - const actual = fn(input, { name: 'new', value: 3 }, context); + const actual = fn(input, { name: ['new'], value: [3] }, context); expect(variables.new).toEqual(3); expect(actual).toEqual(input); }); it('stores context if value is not set', () => { - const actual = fn(input, { name: 'test' }, context); + const actual = fn(input, { name: ['test'], value: [] }, context); expect(variables.test).toEqual(input); expect(actual).toEqual(input); }); + + it('sets multiple variables', () => { + const actual = fn(input, { name: ['new1', 'new2', 'new3'], value: [1, , 3] }, context); + expect(variables.new1).toEqual(1); + expect(variables.new2).toEqual(input); + expect(variables.new3).toEqual(3); + expect(actual).toEqual(input); + }); + + describe('running function thru executor', () => { + const executor = createUnitTestExecutor(); + executor.registerFunction(variableSet); + + it('sets the variables', async () => { + const vars = {}; + const result = await executor + .run('var_set name=test1 name=test2 value=1', 2, { variables: vars }) + .pipe(first()) + .toPromise(); + + expect(result).toEqual(2); + + expect(vars).toEqual({ + test1: 1, + test2: 2, + }); + }); + }); }); }); diff --git a/src/plugins/expressions/common/expression_functions/specs/var_set.ts b/src/plugins/expressions/common/expression_functions/specs/var_set.ts index 490c7781a01a1e..f3ac6a2ab80d4a 100644 --- a/src/plugins/expressions/common/expression_functions/specs/var_set.ts +++ b/src/plugins/expressions/common/expression_functions/specs/var_set.ts @@ -10,8 +10,8 @@ import { i18n } from '@kbn/i18n'; import { ExpressionFunctionDefinition } from '../types'; interface Arguments { - name: string; - value?: any; + name: string[]; + value: any[]; } export type ExpressionFunctionVarSet = ExpressionFunctionDefinition< @@ -31,12 +31,14 @@ export const variableSet: ExpressionFunctionVarSet = { types: ['string'], aliases: ['_'], required: true, + multi: true, help: i18n.translate('expressions.functions.varset.name.help', { defaultMessage: 'Specify the name of the variable.', }), }, value: { aliases: ['val'], + multi: true, help: i18n.translate('expressions.functions.varset.val.help', { defaultMessage: 'Specify the value for the variable. When unspecified, the input context is used.', @@ -45,7 +47,9 @@ export const variableSet: ExpressionFunctionVarSet = { }, fn(input, args, context) { const variables: Record = context.variables; - variables[args.name] = args.value === undefined ? input : args.value; + args.name.forEach((name, i) => { + variables[name] = args.value[i] === undefined ? input : args.value[i]; + }); return input; }, }; diff --git a/src/plugins/expressions/common/expression_functions/types.ts b/src/plugins/expressions/common/expression_functions/types.ts index e1378a27bdfc29..0ec61b39608a05 100644 --- a/src/plugins/expressions/common/expression_functions/types.ts +++ b/src/plugins/expressions/common/expression_functions/types.ts @@ -18,6 +18,7 @@ import { ExpressionFunctionCumulativeSum, ExpressionFunctionDerivative, ExpressionFunctionMovingAverage, + ExpressionFunctionOverallMetric, } from './specs'; import { ExpressionAstFunction } from '../ast'; import { PersistableStateDefinition } from '../../../kibana_utils/common'; @@ -119,6 +120,7 @@ export interface ExpressionFunctionDefinitions { var: ExpressionFunctionVar; theme: ExpressionFunctionTheme; cumulative_sum: ExpressionFunctionCumulativeSum; + overall_metric: ExpressionFunctionOverallMetric; derivative: ExpressionFunctionDerivative; moving_average: ExpressionFunctionMovingAverage; } diff --git a/src/plugins/expressions/common/service/expressions_services.ts b/src/plugins/expressions/common/service/expressions_services.ts index a8839c9b0d71e1..f7afc12aa96bad 100644 --- a/src/plugins/expressions/common/service/expressions_services.ts +++ b/src/plugins/expressions/common/service/expressions_services.ts @@ -29,6 +29,7 @@ import { derivative, movingAverage, mapColumn, + overallMetric, math, } from '../expression_functions'; @@ -340,6 +341,7 @@ export class ExpressionsService implements PersistableStateService [ defaultMessage: '[eCommerce] Top Selling Products', }), visState: - '{"title":"[eCommerce] Top Selling Products","type":"tagcloud","params":{"scale":"linear","orientation":"single","minFontSize":18,"maxFontSize":72,"showLabel":false},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"products.product_name.keyword","size":7,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', + '{"title":"[eCommerce] Top Selling Products","type":"tagcloud","params":{"scale":"linear","orientation":"single","minFontSize":18,"maxFontSize":72,"showLabel":false,"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"products.product_name.keyword","size":7,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', uiStateJSON: '{}', description: '', version: 1, diff --git a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts index 05a3d012d707c1..816322dbe5299c 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts @@ -242,7 +242,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[Flights] Destination Weather', }), visState: - '{"title":"[Flights] Destination Weather","type":"tagcloud","params":{"scale":"linear","orientation":"single","minFontSize":18,"maxFontSize":72,"showLabel":false},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"DestWeather","size":10,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', + '{"title":"[Flights] Destination Weather","type":"tagcloud","params":{"scale":"linear","orientation":"single","minFontSize":18,"maxFontSize":72,"showLabel":false,"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"DestWeather","size":10,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', uiStateJSON: '{}', description: '', version: 1, diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap index 21248ac9d1dc0b..38a9e470144168 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap @@ -14,46 +14,46 @@ exports[`CreateIndexPatternWizard defaults to the loading state 1`] = ` exports[`CreateIndexPatternWizard renders index pattern step when there are indices 1`] = ` - -
- - + + - + } + showSystemIndices={true} + /> - -
- - + + - + } + showSystemIndices={true} + /> - -
- - + + - + } + /> - -
- - + + - + } + showSystemIndices={true} + /> - -
- - + + - + } + showSystemIndices={true} + /> } > -
- -

+ Create test index pattern - - - - Beta - - -

-
- + + + + + } + > +
-
- - -
+ Create test index pattern + + + + + + } + responsive={true} > -

- - multiple - , - "single": - filebeat-4-3-22 - , - "star": - filebeat-* - , - } - } +

+ - - An index pattern can match a single source, for example, - - - - - filebeat-4-3-22 - - - - - , or - - multiple - - data sources, - - + +
- - - filebeat-* - - - - - . - - -
- +

+ Create test index pattern + + + + Beta + + +

+ +
+
+
+ +
- - -

-
- - -
- -
- Test prompt -
-
+

+ + multiple + , + "single": + filebeat-4-3-22 + , + "star": + filebeat-* + , + } + } + > + + An index pattern can match a single source, for example, + + + + + filebeat-4-3-22 + + + + + , or + + multiple + + data sources, + + + + + filebeat-* + + + + + . + + +
+ + + +

+
+
+ +
+ +
+ Test prompt +
+
+
+ +
+
`; @@ -146,100 +203,145 @@ exports[`Header should render normally 1`] = ` } indexPatternName="test index pattern" > -
- -

+ Create test index pattern -

-
- + } + > +
-
- - -
+ Create test index pattern + + } + responsive={true} > -

- - multiple - , - "single": - filebeat-4-3-22 - , - "star": - filebeat-* - , - } - } +

+ - - An index pattern can match a single source, for example, - - - - - filebeat-4-3-22 - - - - - , or - - multiple - - data sources, - - + +
- - - filebeat-* - - - - - . - - -
- +

+ Create test index pattern +

+ +
+
+
+ +
- - -

-
- -
+

+ + multiple + , + "single": + filebeat-4-3-22 + , + "star": + filebeat-* + , + } + } + > + + An index pattern can match a single source, for example, + + + + + filebeat-4-3-22 + + + + + , or + + multiple + + data sources, + + + + + filebeat-* + + + + + . + + +
+ + + +

+
+ +
+ + +
+
`; @@ -254,99 +356,144 @@ exports[`Header should render without including system indices 1`] = ` } indexPatternName="test index pattern" > -
- -

+ Create test index pattern -

-
- + } + > +
-
- - -
+ Create test index pattern + + } + responsive={true} > -

- - multiple - , - "single": - filebeat-4-3-22 - , - "star": - filebeat-* - , - } - } +

+ - - An index pattern can match a single source, for example, - - - - - filebeat-4-3-22 - - - - - , or - - multiple - - data sources, - - + +
- - - filebeat-* - - - - - . - - -
- +

+ Create test index pattern +

+ +
+
+
+ +
- - -

-
- -
+

+ + multiple + , + "single": + filebeat-4-3-22 + , + "star": + filebeat-* + , + } + } + > + + An index pattern can match a single source, for example, + + + + + filebeat-4-3-22 + + + + + , or + + multiple + + data sources, + + + + + filebeat-* + + + + + . + + +
+ + + +

+
+ +
+ + +
+
`; diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.tsx index a7e3b2ded75dc6..c708bd3cac33e7 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.tsx @@ -8,7 +8,7 @@ import React from 'react'; -import { EuiBetaBadge, EuiSpacer, EuiTitle, EuiText, EuiCode, EuiLink } from '@elastic/eui'; +import { EuiBetaBadge, EuiCode, EuiLink, EuiPageHeader, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -39,9 +39,9 @@ export const Header = ({ changeTitle(createIndexPatternHeader); return ( -
- -

+ {createIndexPatternHeader} {isBeta ? ( <> @@ -53,9 +53,10 @@ export const Header = ({ /> ) : null} -

-
- + + } + bottomBorder + >

) : null} -

+ ); }; diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx index 633906feb785b4..5bc53105dbcf87 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx @@ -6,17 +6,12 @@ * Side Public License, v 1. */ -import React, { ReactElement, Component } from 'react'; - -import { - EuiGlobalToastList, - EuiGlobalToastListToast, - EuiPageContent, - EuiHorizontalRule, -} from '@elastic/eui'; +import React, { Component, ReactElement } from 'react'; + +import { EuiGlobalToastList, EuiGlobalToastListToast, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; import { DocLinksStart } from 'src/core/public'; import { StepIndexPattern } from './components/step_index_pattern'; import { StepTimeField } from './components/step_time_field'; @@ -227,9 +222,9 @@ export class CreateIndexPatternWizard extends Component< const initialQuery = new URLSearchParams(location.search).get('id') || undefined; return ( - + <> {header} - + - + ); } if (step === 2) { return ( - + <> {header} - + - + ); } diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/create_edit_field/create_edit_field.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/create_edit_field/create_edit_field.tsx index 5aa9853c5e766f..0c0adc6dd50295 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/create_edit_field/create_edit_field.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/create_edit_field/create_edit_field.tsx @@ -7,15 +7,15 @@ */ import React from 'react'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; -import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { IndexPattern, IndexPatternField } from '../../../../../../plugins/data/public'; import { useKibana } from '../../../../../../plugins/kibana_react/public'; import { IndexPatternManagmentContext } from '../../../types'; import { IndexHeader } from '../index_header'; -import { TAB_SCRIPTED_FIELDS, TAB_INDEXED_FIELDS } from '../constants'; +import { TAB_INDEXED_FIELDS, TAB_SCRIPTED_FIELDS } from '../constants'; import { FieldEditor } from '../../field_editor'; @@ -76,26 +76,18 @@ export const CreateEditField = withRouter( if (spec) { return ( - - - - - - - - - - + <> + + + + ); } else { return <>; diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx index e314c00bc8176f..6609605da87d19 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx @@ -17,7 +17,6 @@ import { EuiText, EuiLink, EuiCallOut, - EuiPanel, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -145,15 +144,13 @@ export const EditIndexPattern = withRouter( const kibana = useKibana(); const docsUrl = kibana.services.docLinks!.links.elasticsearch.mapping; return ( - -
- - +
+ {showTagsSection && ( {Boolean(indexPattern.timeFieldName) && ( @@ -193,19 +190,19 @@ export const EditIndexPattern = withRouter( )} - - { - setFields(indexPattern.getNonScriptedFields()); - }} - /> -
- +
+ + { + setFields(indexPattern.getNonScriptedFields()); + }} + /> +
); } ); diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/index_header/index_header.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/index_header/index_header.tsx index 482cd574c8f1d6..c141c228a68f25 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/index_header/index_header.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/index_header/index_header.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFlexGroup, EuiToolTip, EuiFlexItem, EuiTitle, EuiButtonIcon } from '@elastic/eui'; +import { EuiButtonIcon, EuiPageHeader, EuiToolTip } from '@elastic/eui'; import { IIndexPattern } from 'src/plugins/data/public'; interface IndexHeaderProps { @@ -40,50 +40,42 @@ const removeTooltip = i18n.translate('indexPatternManagement.editIndexPattern.re defaultMessage: 'Remove index pattern.', }); -export function IndexHeader({ +export const IndexHeader: React.FC = ({ defaultIndex, indexPattern, setDefault, deleteIndexPatternClick, -}: IndexHeaderProps) { + children, +}) => { return ( - - - -

{indexPattern.title}

-
-
- - - {defaultIndex !== indexPattern.id && setDefault && ( - - - - - - )} - - {deleteIndexPatternClick && ( - - - - - - )} - - -
+ {indexPattern.title}} + rightSideItems={[ + defaultIndex !== indexPattern.id && setDefault && ( + + + + ), + deleteIndexPatternClick && ( + + + + ), + ].filter(Boolean)} + > + {children} + ); -} +}; diff --git a/src/plugins/index_pattern_management/public/components/index_pattern_table/empty_index_pattern_prompt/__snapshots__/empty_index_pattern_prompt.test.tsx.snap b/src/plugins/index_pattern_management/public/components/index_pattern_table/empty_index_pattern_prompt/__snapshots__/empty_index_pattern_prompt.test.tsx.snap index c5e6d1220d8bf8..bc69fa29e69044 100644 --- a/src/plugins/index_pattern_management/public/components/index_pattern_table/empty_index_pattern_prompt/__snapshots__/empty_index_pattern_prompt.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/index_pattern_table/empty_index_pattern_prompt/__snapshots__/empty_index_pattern_prompt.test.tsx.snap @@ -3,9 +3,11 @@ exports[`EmptyIndexPatternPrompt should render normally 1`] = ` diff --git a/src/plugins/index_pattern_management/public/components/index_pattern_table/empty_state/__snapshots__/empty_state.test.tsx.snap b/src/plugins/index_pattern_management/public/components/index_pattern_table/empty_state/__snapshots__/empty_state.test.tsx.snap index 1310488c65fab8..957c94c80680d9 100644 --- a/src/plugins/index_pattern_management/public/components/index_pattern_table/empty_state/__snapshots__/empty_state.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/index_pattern_table/empty_state/__snapshots__/empty_state.test.tsx.snap @@ -4,9 +4,11 @@ exports[`EmptyState should render normally 1`] = ` diff --git a/src/plugins/index_pattern_management/public/components/index_pattern_table/empty_state/empty_state.tsx b/src/plugins/index_pattern_management/public/components/index_pattern_table/empty_state/empty_state.tsx index 240e732752916c..c05f6a1f193b7a 100644 --- a/src/plugins/index_pattern_management/public/components/index_pattern_table/empty_state/empty_state.tsx +++ b/src/plugins/index_pattern_management/public/components/index_pattern_table/empty_state/empty_state.tsx @@ -63,8 +63,10 @@ export const EmptyState = ({ diff --git a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx index f018294f27c84a..6bd06528084ce9 100644 --- a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx +++ b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx @@ -8,24 +8,20 @@ import { EuiBadge, + EuiBadgeGroup, EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, EuiInMemoryTable, + EuiPageHeader, EuiSpacer, - EuiText, - EuiBadgeGroup, - EuiPageContent, - EuiTitle, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; -import React, { useState, useEffect } from 'react'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; +import React, { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { reactRouterNavigate, useKibana } from '../../../../../plugins/kibana_react/public'; import { IndexPatternManagmentContext } from '../../types'; import { CreateButton } from '../create_button'; -import { IndexPatternTableItem, IndexPatternCreationOption } from '../types'; +import { IndexPatternCreationOption, IndexPatternTableItem } from '../types'; import { getIndexPatterns } from '../utils'; import { getListBreadcrumbs } from '../breadcrumbs'; import { EmptyState } from './empty_state'; @@ -54,10 +50,6 @@ const search = { }, }; -const ariaRegion = i18n.translate('indexPatternManagement.editIndexPatternLiveRegionAriaLabel', { - defaultMessage: 'Index patterns', -}); - const title = i18n.translate('indexPatternManagement.indexPatternTable.title', { defaultMessage: 'Index patterns', }); @@ -197,25 +189,21 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { } return ( - - - - -

{title}

-
- - -

- -

-
-
- {createButton} -
- +
+ + } + bottomBorder + rightSideItems={[createButton]} + /> + + + { sorting={sorting} search={search} /> - +
); }; diff --git a/src/plugins/newsfeed/kibana.json b/src/plugins/newsfeed/kibana.json index b9f37b67f6921b..0e7ae7cd11c35f 100644 --- a/src/plugins/newsfeed/kibana.json +++ b/src/plugins/newsfeed/kibana.json @@ -2,5 +2,6 @@ "id": "newsfeed", "version": "kibana", "server": true, - "ui": true + "ui": true, + "requiredPlugins": ["screenshotMode"] } diff --git a/src/plugins/newsfeed/public/lib/api.test.mocks.ts b/src/plugins/newsfeed/public/lib/api.test.mocks.ts index 677bc203cbef3f..8ac66eae6c2f6c 100644 --- a/src/plugins/newsfeed/public/lib/api.test.mocks.ts +++ b/src/plugins/newsfeed/public/lib/api.test.mocks.ts @@ -8,6 +8,7 @@ import { storageMock } from './storage.mock'; import { driverMock } from './driver.mock'; +import { NeverFetchNewsfeedApiDriver } from './never_fetch_driver'; export const storageInstanceMock = storageMock.create(); jest.doMock('./storage', () => ({ @@ -18,3 +19,7 @@ export const driverInstanceMock = driverMock.create(); jest.doMock('./driver', () => ({ NewsfeedApiDriver: jest.fn().mockImplementation(() => driverInstanceMock), })); + +jest.doMock('./never_fetch_driver', () => ({ + NeverFetchNewsfeedApiDriver: jest.fn(() => new NeverFetchNewsfeedApiDriver()), +})); diff --git a/src/plugins/newsfeed/public/lib/api.test.ts b/src/plugins/newsfeed/public/lib/api.test.ts index a4894573932e6c..58d06e72cd77c4 100644 --- a/src/plugins/newsfeed/public/lib/api.test.ts +++ b/src/plugins/newsfeed/public/lib/api.test.ts @@ -7,12 +7,16 @@ */ import { driverInstanceMock, storageInstanceMock } from './api.test.mocks'; + import moment from 'moment'; import { getApi } from './api'; import { TestScheduler } from 'rxjs/testing'; import { FetchResult, NewsfeedPluginBrowserConfig } from '../types'; import { take } from 'rxjs/operators'; +import { NewsfeedApiDriver as MockNewsfeedApiDriver } from './driver'; +import { NeverFetchNewsfeedApiDriver as MockNeverFetchNewsfeedApiDriver } from './never_fetch_driver'; + const kibanaVersion = '8.0.0'; const newsfeedId = 'test'; @@ -46,6 +50,8 @@ describe('getApi', () => { afterEach(() => { storageInstanceMock.isAnyUnread$.mockReset(); driverInstanceMock.fetchNewsfeedItems.mockReset(); + (MockNewsfeedApiDriver as jest.Mock).mockClear(); + (MockNeverFetchNewsfeedApiDriver as jest.Mock).mockClear(); }); it('merges the newsfeed and unread observables', () => { @@ -60,7 +66,7 @@ describe('getApi', () => { a: createFetchResult({ feedItems: ['item' as any] }), }) ); - const api = getApi(createConfig(1000), kibanaVersion, newsfeedId); + const api = getApi(createConfig(1000), kibanaVersion, newsfeedId, false); expectObservable(api.fetchResults$.pipe(take(1))).toBe('(a|)', { a: createFetchResult({ @@ -83,7 +89,7 @@ describe('getApi', () => { a: createFetchResult({ feedItems: ['item' as any] }), }) ); - const api = getApi(createConfig(2), kibanaVersion, newsfeedId); + const api = getApi(createConfig(2), kibanaVersion, newsfeedId, false); expectObservable(api.fetchResults$.pipe(take(2))).toBe('a-(b|)', { a: createFetchResult({ @@ -111,7 +117,7 @@ describe('getApi', () => { a: createFetchResult({}), }) ); - const api = getApi(createConfig(10), kibanaVersion, newsfeedId); + const api = getApi(createConfig(10), kibanaVersion, newsfeedId, false); expectObservable(api.fetchResults$.pipe(take(2))).toBe('a--(b|)', { a: createFetchResult({ @@ -123,4 +129,16 @@ describe('getApi', () => { }); }); }); + + it('uses the news feed API driver if in not screenshot mode', () => { + getApi(createConfig(10), kibanaVersion, newsfeedId, false); + expect(MockNewsfeedApiDriver).toHaveBeenCalled(); + expect(MockNeverFetchNewsfeedApiDriver).not.toHaveBeenCalled(); + }); + + it('uses the never fetch news feed API driver if in not screenshot mode', () => { + getApi(createConfig(10), kibanaVersion, newsfeedId, true); + expect(MockNewsfeedApiDriver).not.toHaveBeenCalled(); + expect(MockNeverFetchNewsfeedApiDriver).toHaveBeenCalled(); + }); }); diff --git a/src/plugins/newsfeed/public/lib/api.ts b/src/plugins/newsfeed/public/lib/api.ts index 4fbbd8687b73fc..7aafc9fd276250 100644 --- a/src/plugins/newsfeed/public/lib/api.ts +++ b/src/plugins/newsfeed/public/lib/api.ts @@ -11,6 +11,7 @@ import { map, catchError, filter, mergeMap, tap } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import { FetchResult, NewsfeedPluginBrowserConfig } from '../types'; import { NewsfeedApiDriver } from './driver'; +import { NeverFetchNewsfeedApiDriver } from './never_fetch_driver'; import { NewsfeedStorage } from './storage'; export enum NewsfeedApiEndpoint { @@ -40,13 +41,23 @@ export interface NewsfeedApi { export function getApi( config: NewsfeedPluginBrowserConfig, kibanaVersion: string, - newsfeedId: string + newsfeedId: string, + isScreenshotMode: boolean ): NewsfeedApi { - const userLanguage = i18n.getLocale(); - const fetchInterval = config.fetchInterval.asMilliseconds(); - const mainInterval = config.mainInterval.asMilliseconds(); const storage = new NewsfeedStorage(newsfeedId); - const driver = new NewsfeedApiDriver(kibanaVersion, userLanguage, fetchInterval, storage); + const mainInterval = config.mainInterval.asMilliseconds(); + + const createNewsfeedApiDriver = () => { + if (isScreenshotMode) { + return new NeverFetchNewsfeedApiDriver(); + } + + const userLanguage = i18n.getLocale(); + const fetchInterval = config.fetchInterval.asMilliseconds(); + return new NewsfeedApiDriver(kibanaVersion, userLanguage, fetchInterval, storage); + }; + + const driver = createNewsfeedApiDriver(); const results$ = timer(0, mainInterval).pipe( filter(() => driver.shouldFetch()), diff --git a/src/plugins/newsfeed/public/lib/driver.ts b/src/plugins/newsfeed/public/lib/driver.ts index 0efa981e8c89d6..1762c4a4287844 100644 --- a/src/plugins/newsfeed/public/lib/driver.ts +++ b/src/plugins/newsfeed/public/lib/driver.ts @@ -10,6 +10,7 @@ import moment from 'moment'; import * as Rx from 'rxjs'; import { NEWSFEED_DEFAULT_SERVICE_BASE_URL } from '../../common/constants'; import { ApiItem, FetchResult, NewsfeedPluginBrowserConfig } from '../types'; +import { INewsfeedApiDriver } from './types'; import { convertItems } from './convert_items'; import type { NewsfeedStorage } from './storage'; @@ -19,7 +20,7 @@ interface NewsfeedResponse { items: ApiItem[]; } -export class NewsfeedApiDriver { +export class NewsfeedApiDriver implements INewsfeedApiDriver { private readonly kibanaVersion: string; private readonly loadedTime = moment().utc(); // the date is compared to time in UTC format coming from the service diff --git a/src/plugins/newsfeed/public/lib/never_fetch_driver.ts b/src/plugins/newsfeed/public/lib/never_fetch_driver.ts new file mode 100644 index 00000000000000..e95ca9c2d499a7 --- /dev/null +++ b/src/plugins/newsfeed/public/lib/never_fetch_driver.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Observable } from 'rxjs'; +import { FetchResult } from '../types'; +import { INewsfeedApiDriver } from './types'; + +/** + * NewsfeedApiDriver variant that never fetches results. This is useful for instances where Kibana is started + * without any user interaction like when generating a PDF or PNG report. + */ +export class NeverFetchNewsfeedApiDriver implements INewsfeedApiDriver { + shouldFetch(): boolean { + return false; + } + + fetchNewsfeedItems(): Observable { + throw new Error('Not implemented!'); + } +} diff --git a/src/plugins/newsfeed/public/lib/types.ts b/src/plugins/newsfeed/public/lib/types.ts new file mode 100644 index 00000000000000..5a62a929eeb7ff --- /dev/null +++ b/src/plugins/newsfeed/public/lib/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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Observable } from 'rxjs'; +import type { FetchResult, NewsfeedPluginBrowserConfig } from '../types'; + +export interface INewsfeedApiDriver { + /** + * Check whether newsfeed items should be (re-)fetched + */ + shouldFetch(): boolean; + + fetchNewsfeedItems(config: NewsfeedPluginBrowserConfig['service']): Observable; +} diff --git a/src/plugins/newsfeed/public/plugin.test.ts b/src/plugins/newsfeed/public/plugin.test.ts new file mode 100644 index 00000000000000..4be69feb79f555 --- /dev/null +++ b/src/plugins/newsfeed/public/plugin.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { take } from 'rxjs/operators'; +import { coreMock } from '../../../core/public/mocks'; +import { NewsfeedPublicPlugin } from './plugin'; +import { NewsfeedApiEndpoint } from './lib/api'; + +describe('Newsfeed plugin', () => { + let plugin: NewsfeedPublicPlugin; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + plugin = new NewsfeedPublicPlugin(coreMock.createPluginInitializerContext()); + }); + + describe('#start', () => { + beforeEach(() => { + plugin.setup(coreMock.createSetup()); + }); + + beforeEach(() => { + /** + * We assume for these tests that the newsfeed stream exposed by start will fetch newsfeed items + * on the first tick for new subscribers + */ + jest.spyOn(window, 'fetch'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('base case', () => { + it('makes fetch requests', () => { + const startContract = plugin.start(coreMock.createStart(), { + screenshotMode: { isScreenshotMode: () => false }, + }); + const sub = startContract + .createNewsFeed$(NewsfeedApiEndpoint.KIBANA) // Any endpoint will do + .pipe(take(1)) + .subscribe(() => {}); + jest.runOnlyPendingTimers(); + expect(window.fetch).toHaveBeenCalled(); + sub.unsubscribe(); + }); + }); + + describe('when in screenshot mode', () => { + it('makes no fetch requests in screenshot mode', () => { + const startContract = plugin.start(coreMock.createStart(), { + screenshotMode: { isScreenshotMode: () => true }, + }); + const sub = startContract + .createNewsFeed$(NewsfeedApiEndpoint.KIBANA) // Any endpoint will do + .pipe(take(1)) + .subscribe(() => {}); + jest.runOnlyPendingTimers(); + expect(window.fetch).not.toHaveBeenCalled(); + sub.unsubscribe(); + }); + }); + }); +}); diff --git a/src/plugins/newsfeed/public/plugin.tsx b/src/plugins/newsfeed/public/plugin.tsx index fdda0a24b8bd56..656fc2ef00bb9f 100644 --- a/src/plugins/newsfeed/public/plugin.tsx +++ b/src/plugins/newsfeed/public/plugin.tsx @@ -13,7 +13,7 @@ import React from 'react'; import moment from 'moment'; import { I18nProvider } from '@kbn/i18n/react'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { NewsfeedPluginBrowserConfig } from './types'; +import { NewsfeedPluginBrowserConfig, NewsfeedPluginStartDependencies } from './types'; import { NewsfeedNavButton } from './components/newsfeed_header_nav_button'; import { getApi, NewsfeedApi, NewsfeedApiEndpoint } from './lib/api'; @@ -41,8 +41,10 @@ export class NewsfeedPublicPlugin return {}; } - public start(core: CoreStart) { - const api = this.createNewsfeedApi(this.config, NewsfeedApiEndpoint.KIBANA); + public start(core: CoreStart, { screenshotMode }: NewsfeedPluginStartDependencies) { + const isScreenshotMode = screenshotMode.isScreenshotMode(); + + const api = this.createNewsfeedApi(this.config, NewsfeedApiEndpoint.KIBANA, isScreenshotMode); core.chrome.navControls.registerRight({ order: 1000, mount: (target) => this.mount(api, target), @@ -56,7 +58,7 @@ export class NewsfeedPublicPlugin pathTemplate: `/${endpoint}/v{VERSION}.json`, }, }); - const { fetchResults$ } = this.createNewsfeedApi(config, endpoint); + const { fetchResults$ } = this.createNewsfeedApi(config, endpoint, isScreenshotMode); return fetchResults$; }, }; @@ -68,9 +70,10 @@ export class NewsfeedPublicPlugin private createNewsfeedApi( config: NewsfeedPluginBrowserConfig, - newsfeedId: NewsfeedApiEndpoint + newsfeedId: NewsfeedApiEndpoint, + isScreenshotMode: boolean ): NewsfeedApi { - const api = getApi(config, this.kibanaVersion, newsfeedId); + const api = getApi(config, this.kibanaVersion, newsfeedId, isScreenshotMode); return { markAsRead: api.markAsRead, fetchResults$: api.fetchResults$.pipe( diff --git a/src/plugins/newsfeed/public/types.ts b/src/plugins/newsfeed/public/types.ts index cca656565f4ca5..a7ff917f6f9750 100644 --- a/src/plugins/newsfeed/public/types.ts +++ b/src/plugins/newsfeed/public/types.ts @@ -7,6 +7,10 @@ */ import { Duration, Moment } from 'moment'; +import type { ScreenshotModePluginStart } from 'src/plugins/screenshot_mode/public'; +export interface NewsfeedPluginStartDependencies { + screenshotMode: ScreenshotModePluginStart; +} // Ideally, we may want to obtain the type from the configSchema and exposeToBrowser keys... export interface NewsfeedPluginBrowserConfig { diff --git a/src/plugins/newsfeed/tsconfig.json b/src/plugins/newsfeed/tsconfig.json index 66244a22336c77..18e6f2de1bc6fb 100644 --- a/src/plugins/newsfeed/tsconfig.json +++ b/src/plugins/newsfeed/tsconfig.json @@ -7,13 +7,9 @@ "declaration": true, "declarationMap": true }, - "include": [ - "public/**/*", - "server/**/*", - "common/*", - "../../../typings/**/*" - ], + "include": ["public/**/*", "server/**/*", "common/*", "../../../typings/**/*"], "references": [ - { "path": "../../core/tsconfig.json" } + { "path": "../../core/tsconfig.json" }, + { "path": "../screenshot_mode/tsconfig.json" } ] } diff --git a/src/plugins/presentation_util/public/mocks.ts b/src/plugins/presentation_util/public/mocks.ts new file mode 100644 index 00000000000000..91c461646c280c --- /dev/null +++ b/src/plugins/presentation_util/public/mocks.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreStart } from 'kibana/public'; +import { PresentationUtilPluginStart } from './types'; +import { pluginServices } from './services'; +import { registry } from './services/kibana'; + +const createStartContract = (coreStart: CoreStart): PresentationUtilPluginStart => { + pluginServices.setRegistry(registry.start({ coreStart, startPlugins: {} as any })); + + const startContract: PresentationUtilPluginStart = { + ContextProvider: pluginServices.getContextProvider(), + labsService: pluginServices.getServices().labs, + }; + return startContract; +}; + +export const presentationUtilPluginMock = { + createStartContract, +}; diff --git a/src/plugins/screenshot_mode/public/index.ts b/src/plugins/screenshot_mode/public/index.ts index a5ad37dd5b760d..012f57e837f416 100644 --- a/src/plugins/screenshot_mode/public/index.ts +++ b/src/plugins/screenshot_mode/public/index.ts @@ -18,4 +18,4 @@ export { KBN_SCREENSHOT_MODE_ENABLED_KEY, } from '../common'; -export { ScreenshotModePluginSetup } from './types'; +export { ScreenshotModePluginSetup, ScreenshotModePluginStart } from './types'; diff --git a/src/plugins/screenshot_mode/public/plugin.test.ts b/src/plugins/screenshot_mode/public/plugin.test.ts index 33ae5014668760..f2c0970d0ff60c 100644 --- a/src/plugins/screenshot_mode/public/plugin.test.ts +++ b/src/plugins/screenshot_mode/public/plugin.test.ts @@ -21,7 +21,7 @@ describe('Screenshot mode public', () => { setScreenshotModeDisabled(); }); - describe('setup contract', () => { + describe('public contract', () => { it('detects screenshot mode "true"', () => { setScreenshotModeEnabled(); const screenshotMode = plugin.setup(coreMock.createSetup()); @@ -34,10 +34,4 @@ describe('Screenshot mode public', () => { expect(screenshotMode.isScreenshotMode()).toBe(false); }); }); - - describe('start contract', () => { - it('returns nothing', () => { - expect(plugin.start(coreMock.createStart())).toBe(undefined); - }); - }); }); diff --git a/src/plugins/screenshot_mode/public/plugin.ts b/src/plugins/screenshot_mode/public/plugin.ts index 7a166566a0173b..a005bb7c3d055d 100644 --- a/src/plugins/screenshot_mode/public/plugin.ts +++ b/src/plugins/screenshot_mode/public/plugin.ts @@ -8,18 +8,22 @@ import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; -import { ScreenshotModePluginSetup } from './types'; +import { ScreenshotModePluginSetup, ScreenshotModePluginStart } from './types'; import { getScreenshotMode } from '../common'; export class ScreenshotModePlugin implements Plugin { + private publicContract = Object.freeze({ + isScreenshotMode: () => getScreenshotMode() === true, + }); + public setup(core: CoreSetup): ScreenshotModePluginSetup { - return { - isScreenshotMode: () => getScreenshotMode() === true, - }; + return this.publicContract; } - public start(core: CoreStart) {} + public start(core: CoreStart): ScreenshotModePluginStart { + return this.publicContract; + } public stop() {} } diff --git a/src/plugins/screenshot_mode/public/types.ts b/src/plugins/screenshot_mode/public/types.ts index 744ea8615f2a79..f6963de0cbd63f 100644 --- a/src/plugins/screenshot_mode/public/types.ts +++ b/src/plugins/screenshot_mode/public/types.ts @@ -15,3 +15,4 @@ export interface IScreenshotModeService { } export type ScreenshotModePluginSetup = IScreenshotModeService; +export type ScreenshotModePluginStart = IScreenshotModeService; diff --git a/src/plugins/security_oss/kibana.json b/src/plugins/security_oss/kibana.json index 70e37d586f1db3..c93b5c3b60714d 100644 --- a/src/plugins/security_oss/kibana.json +++ b/src/plugins/security_oss/kibana.json @@ -1,5 +1,10 @@ { "id": "securityOss", + "owner": { + "name": "Platform Security", + "githubTeam": "kibana-security" + }, + "description": "This plugin exposes a limited set of security functionality to OSS plugins.", "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["security"], diff --git a/src/plugins/spaces_oss/kibana.json b/src/plugins/spaces_oss/kibana.json index e048fb7ffb79c6..10127634618f1a 100644 --- a/src/plugins/spaces_oss/kibana.json +++ b/src/plugins/spaces_oss/kibana.json @@ -1,5 +1,10 @@ { "id": "spacesOss", + "owner": { + "name": "Platform Security", + "githubTeam": "kibana-security" + }, + "description": "This plugin exposes a limited set of spaces functionality to OSS plugins.", "version": "kibana", "server": false, "ui": true, diff --git a/src/plugins/vis_type_tagcloud/public/__snapshots__/tag_cloud_fn.test.ts.snap b/src/plugins/vis_type_tagcloud/public/__snapshots__/tag_cloud_fn.test.ts.snap index 17a91a4d43cc76..cbfece0b081c61 100644 --- a/src/plugins/vis_type_tagcloud/public/__snapshots__/tag_cloud_fn.test.ts.snap +++ b/src/plugins/vis_type_tagcloud/public/__snapshots__/tag_cloud_fn.test.ts.snap @@ -5,6 +5,7 @@ Object { "as": "tagloud_vis", "type": "render", "value": Object { + "syncColors": false, "visData": Object { "columns": Array [ Object { @@ -20,6 +21,12 @@ Object { "type": "datatable", }, "visParams": Object { + "bucket": Object { + "accessor": 1, + "format": Object { + "id": "number", + }, + }, "maxFontSize": 72, "metric": Object { "accessor": 0, @@ -29,6 +36,10 @@ Object { }, "minFontSize": 18, "orientation": "single", + "palette": Object { + "name": "default", + "type": "palette", + }, "scale": "linear", "showLabel": true, }, diff --git a/src/plugins/vis_type_tagcloud/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_tagcloud/public/__snapshots__/to_ast.test.ts.snap index a8bc0b4c51678a..fed6fb54288f27 100644 --- a/src/plugins/vis_type_tagcloud/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_type_tagcloud/public/__snapshots__/to_ast.test.ts.snap @@ -84,6 +84,9 @@ Object { "orientation": Array [ "single", ], + "palette": Array [ + "default", + ], "scale": Array [ "linear", ], diff --git a/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap b/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap deleted file mode 100644 index 88ed7c66a79a2b..00000000000000 --- a/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`tag cloud tests tagcloudscreenshot should render simple image 1`] = `"foobarfoobar"`; diff --git a/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud_visualization.test.js.snap b/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud_visualization.test.js.snap deleted file mode 100644 index d7707f64d8a4fc..00000000000000 --- a/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud_visualization.test.js.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TagCloudVisualizationTest TagCloudVisualization - basics simple draw 1`] = `"CNINUSDEBR"`; - -exports[`TagCloudVisualizationTest TagCloudVisualization - basics with param change 1`] = `"CNINUSDEBR"`; - -exports[`TagCloudVisualizationTest TagCloudVisualization - basics with resize 1`] = `"CNINUSDEBR"`; diff --git a/src/plugins/vis_type_tagcloud/public/components/feedback_message.js b/src/plugins/vis_type_tagcloud/public/components/feedback_message.js deleted file mode 100644 index 9e1d66b0a2faae..00000000000000 --- a/src/plugins/vis_type_tagcloud/public/components/feedback_message.js +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { Component, Fragment } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiIconTip } from '@elastic/eui'; - -export class FeedbackMessage extends Component { - constructor() { - super(); - this.state = { shouldShowTruncate: false, shouldShowIncomplete: false }; - } - - render() { - if (!this.state.shouldShowTruncate && !this.state.shouldShowIncomplete) { - return ''; - } - - return ( - - {this.state.shouldShowTruncate && ( -

- -

- )} - {this.state.shouldShowIncomplete && ( -

- -

- )} -
- } - /> - ); - } -} diff --git a/src/plugins/vis_type_tagcloud/public/components/get_tag_cloud_options.tsx b/src/plugins/vis_type_tagcloud/public/components/get_tag_cloud_options.tsx new file mode 100644 index 00000000000000..82663bbf7070ca --- /dev/null +++ b/src/plugins/vis_type_tagcloud/public/components/get_tag_cloud_options.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { lazy } from 'react'; +import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; +import { TagCloudVisParams, TagCloudTypeProps } from '../types'; + +const TagCloudOptionsLazy = lazy(() => import('./tag_cloud_options')); + +export const getTagCloudOptions = ({ palettes }: TagCloudTypeProps) => ( + props: VisEditorOptionsProps +) => ; diff --git a/src/plugins/vis_type_tagcloud/public/components/label.js b/src/plugins/vis_type_tagcloud/public/components/label.js deleted file mode 100644 index 028a001cfbe634..00000000000000 --- a/src/plugins/vis_type_tagcloud/public/components/label.js +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { Component } from 'react'; - -export class Label extends Component { - constructor() { - super(); - this.state = { label: '', shouldShowLabel: true }; - } - - render() { - return ( -
- {this.state.label} -
- ); - } -} diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.js b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.js deleted file mode 100644 index 254d210eebf376..00000000000000 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.js +++ /dev/null @@ -1,409 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import d3 from 'd3'; -import d3TagCloud from 'd3-cloud'; -import { EventEmitter } from 'events'; - -const ORIENTATIONS = { - single: () => 0, - 'right angled': (tag) => { - return hashWithinRange(tag.text, 2) * 90; - }, - multiple: (tag) => { - return hashWithinRange(tag.text, 12) * 15 - 90; //fan out 12 * 15 degrees over top-right and bottom-right quadrant (=-90 deg offset) - }, -}; -const D3_SCALING_FUNCTIONS = { - linear: () => d3.scale.linear(), - log: () => d3.scale.log(), - 'square root': () => d3.scale.sqrt(), -}; - -export class TagCloud extends EventEmitter { - constructor(domNode, colorScale) { - super(); - - //DOM - this._element = domNode; - this._d3SvgContainer = d3.select(this._element).append('svg'); - this._svgGroup = this._d3SvgContainer.append('g'); - this._size = [1, 1]; - this.resize(); - - //SETTING (non-configurable) - /** - * the fontFamily should be set explicitly for calculating a layout - * and to avoid words overlapping - */ - this._fontFamily = 'Inter UI, sans-serif'; - this._fontStyle = 'normal'; - this._fontWeight = 'normal'; - this._spiral = 'archimedean'; //layout shape - this._timeInterval = 1000; //time allowed for layout algorithm - this._padding = 5; - - //OPTIONS - this._orientation = 'single'; - this._minFontSize = 10; - this._maxFontSize = 36; - this._textScale = 'linear'; - this._optionsAsString = null; - - //DATA - this._words = null; - - //UTIL - this._colorScale = colorScale; - this._setTimeoutId = null; - this._pendingJob = null; - this._layoutIsUpdating = null; - this._allInViewBox = false; - this._DOMisUpdating = false; - } - - setOptions(options) { - if (JSON.stringify(options) === this._optionsAsString) { - return; - } - this._optionsAsString = JSON.stringify(options); - this._orientation = options.orientation; - this._minFontSize = Math.min(options.minFontSize, options.maxFontSize); - this._maxFontSize = Math.max(options.minFontSize, options.maxFontSize); - this._textScale = options.scale; - this._invalidate(false); - } - - resize() { - const newWidth = this._element.offsetWidth; - const newHeight = this._element.offsetHeight; - - if (newWidth === this._size[0] && newHeight === this._size[1]) { - return; - } - - const wasInside = this._size[0] >= this._cloudWidth && this._size[1] >= this._cloudHeight; - const willBeInside = this._cloudWidth <= newWidth && this._cloudHeight <= newHeight; - this._size[0] = newWidth; - this._size[1] = newHeight; - if (wasInside && willBeInside && this._allInViewBox) { - this._invalidate(true); - } else { - this._invalidate(false); - } - } - - setData(data) { - this._words = data; - this._invalidate(false); - } - - destroy() { - clearTimeout(this._setTimeoutId); - this._element.innerHTML = ''; - } - - getStatus() { - return this._allInViewBox ? TagCloud.STATUS.COMPLETE : TagCloud.STATUS.INCOMPLETE; - } - - _updateContainerSize() { - this._d3SvgContainer.attr('width', this._size[0]); - this._d3SvgContainer.attr('height', this._size[1]); - this._svgGroup.attr('width', this._size[0]); - this._svgGroup.attr('height', this._size[1]); - } - - _isJobRunning() { - return this._setTimeoutId || this._layoutIsUpdating || this._DOMisUpdating; - } - - async _processPendingJob() { - if (!this._pendingJob) { - return; - } - - if (this._isJobRunning()) { - return; - } - - this._completedJob = null; - const job = await this._pickPendingJob(); - if (job.words.length) { - if (job.refreshLayout) { - await this._updateLayout(job); - } - await this._updateDOM(job); - const cloudBBox = this._svgGroup[0][0].getBBox(); - this._cloudWidth = cloudBBox.width; - this._cloudHeight = cloudBBox.height; - this._allInViewBox = - cloudBBox.x >= 0 && - cloudBBox.y >= 0 && - cloudBBox.x + cloudBBox.width <= this._element.offsetWidth && - cloudBBox.y + cloudBBox.height <= this._element.offsetHeight; - } else { - this._emptyDOM(job); - } - - if (this._pendingJob) { - this._processPendingJob(); //pick up next job - } else { - this._completedJob = job; - this.emit('renderComplete'); - } - } - - async _pickPendingJob() { - return await new Promise((resolve) => { - this._setTimeoutId = setTimeout(async () => { - const job = this._pendingJob; - this._pendingJob = null; - this._setTimeoutId = null; - resolve(job); - }, 0); - }); - } - - _emptyDOM() { - this._svgGroup.selectAll('text').remove(); - this._cloudWidth = 0; - this._cloudHeight = 0; - this._allInViewBox = true; - this._DOMisUpdating = false; - } - - async _updateDOM(job) { - const canSkipDomUpdate = this._pendingJob || this._setTimeoutId; - if (canSkipDomUpdate) { - this._DOMisUpdating = false; - return; - } - - this._DOMisUpdating = true; - const affineTransform = positionWord.bind( - null, - this._element.offsetWidth / 2, - this._element.offsetHeight / 2 - ); - const svgTextNodes = this._svgGroup.selectAll('text'); - const stage = svgTextNodes.data(job.words, getText); - - await new Promise((resolve) => { - const enterSelection = stage.enter(); - const enteringTags = enterSelection.append('text'); - enteringTags.style('font-size', getSizeInPixels); - enteringTags.style('font-style', this._fontStyle); - enteringTags.style('font-weight', () => this._fontWeight); - enteringTags.style('font-family', () => this._fontFamily); - enteringTags.style('fill', this.getFill.bind(this)); - enteringTags.attr('text-anchor', () => 'middle'); - enteringTags.attr('transform', affineTransform); - enteringTags.attr('data-test-subj', getDisplayText); - enteringTags.text(getDisplayText); - - const self = this; - enteringTags.on({ - click: function (event) { - self.emit('select', event); - }, - mouseover: function () { - d3.select(this).style('cursor', 'pointer'); - }, - mouseout: function () { - d3.select(this).style('cursor', 'default'); - }, - }); - - const movingTags = stage.transition(); - movingTags.duration(600); - movingTags.style('font-size', getSizeInPixels); - movingTags.style('font-style', this._fontStyle); - movingTags.style('font-weight', () => this._fontWeight); - movingTags.style('font-family', () => this._fontFamily); - movingTags.attr('transform', affineTransform); - - const exitingTags = stage.exit(); - const exitTransition = exitingTags.transition(); - exitTransition.duration(200); - exitingTags.style('fill-opacity', 1e-6); - exitingTags.attr('font-size', 1); - exitingTags.remove(); - - let exits = 0; - let moves = 0; - const resolveWhenDone = () => { - if (exits === 0 && moves === 0) { - this._DOMisUpdating = false; - resolve(true); - } - }; - exitTransition.each(() => exits++); - exitTransition.each('end', () => { - exits--; - resolveWhenDone(); - }); - movingTags.each(() => moves++); - movingTags.each('end', () => { - moves--; - resolveWhenDone(); - }); - }); - } - - _makeTextSizeMapper() { - const mapSizeToFontSize = D3_SCALING_FUNCTIONS[this._textScale](); - const range = - this._words.length === 1 - ? [this._maxFontSize, this._maxFontSize] - : [this._minFontSize, this._maxFontSize]; - mapSizeToFontSize.range(range); - if (this._words) { - mapSizeToFontSize.domain(d3.extent(this._words, getValue)); - } - return mapSizeToFontSize; - } - - _makeNewJob() { - return { - refreshLayout: true, - size: this._size.slice(), - words: this._words, - }; - } - - _makeJobPreservingLayout() { - return { - refreshLayout: false, - size: this._size.slice(), - words: this._completedJob.words.map((tag) => { - return { - x: tag.x, - y: tag.y, - rotate: tag.rotate, - size: tag.size, - rawText: tag.rawText || tag.text, - displayText: tag.displayText, - meta: tag.meta, - }; - }), - }; - } - - _invalidate(keepLayout) { - if (!this._words) { - return; - } - - this._updateContainerSize(); - - const canReuseLayout = keepLayout && !this._isJobRunning() && this._completedJob; - this._pendingJob = canReuseLayout ? this._makeJobPreservingLayout() : this._makeNewJob(); - this._processPendingJob(); - } - - async _updateLayout(job) { - if (job.size[0] <= 0 || job.size[1] <= 0) { - // If either width or height isn't above 0 we don't relayout anything, - // since the d3-cloud will be stuck in an infinite loop otherwise. - return; - } - - const mapSizeToFontSize = this._makeTextSizeMapper(); - const tagCloudLayoutGenerator = d3TagCloud(); - tagCloudLayoutGenerator.size(job.size); - tagCloudLayoutGenerator.padding(this._padding); - tagCloudLayoutGenerator.rotate(ORIENTATIONS[this._orientation]); - tagCloudLayoutGenerator.font(this._fontFamily); - tagCloudLayoutGenerator.fontStyle(this._fontStyle); - tagCloudLayoutGenerator.fontWeight(this._fontWeight); - tagCloudLayoutGenerator.fontSize((tag) => mapSizeToFontSize(tag.value)); - tagCloudLayoutGenerator.random(seed); - tagCloudLayoutGenerator.spiral(this._spiral); - tagCloudLayoutGenerator.words(job.words); - tagCloudLayoutGenerator.text(getDisplayText); - tagCloudLayoutGenerator.timeInterval(this._timeInterval); - - this._layoutIsUpdating = true; - await new Promise((resolve) => { - tagCloudLayoutGenerator.on('end', () => { - this._layoutIsUpdating = false; - resolve(true); - }); - tagCloudLayoutGenerator.start(); - }); - } - - /** - * Returns debug info. For debugging only. - * @return {*} - */ - getDebugInfo() { - const debug = {}; - debug.positions = this._completedJob - ? this._completedJob.words.map((tag) => { - return { - displayText: tag.displayText, - rawText: tag.rawText || tag.text, - x: tag.x, - y: tag.y, - rotate: tag.rotate, - }; - }) - : []; - debug.size = { - width: this._size[0], - height: this._size[1], - }; - return debug; - } - - getFill(tag) { - return this._colorScale(tag.text); - } -} - -TagCloud.STATUS = { COMPLETE: 0, INCOMPLETE: 1 }; - -function seed() { - return 0.5; //constant seed (not random) to ensure constant layouts for identical data -} - -function getText(word) { - return word.rawText; -} - -function getDisplayText(word) { - return word.displayText; -} - -function positionWord(xTranslate, yTranslate, word) { - if (isNaN(word.x) || isNaN(word.y) || isNaN(word.rotate)) { - //move off-screen - return `translate(${xTranslate * 3}, ${yTranslate * 3})rotate(0)`; - } - - return `translate(${word.x + xTranslate}, ${word.y + yTranslate})rotate(${word.rotate})`; -} - -function getValue(tag) { - return tag.value; -} - -function getSizeInPixels(tag) { - return `${tag.size}px`; -} - -function hashWithinRange(str, max) { - str = JSON.stringify(str); - let hash = 0; - for (const ch of str) { - hash = (hash * 31 + ch.charCodeAt(0)) % max; - } - return Math.abs(hash) % max; -} diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.scss b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.scss index 37867f1ed1c178..51b5e9dedd8442 100644 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.scss +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.scss @@ -5,18 +5,14 @@ // tgcChart__legend--small // tgcChart__legend-isLoading -.tgcChart__container, .tgcChart__wrapper { +.tgcChart__wrapper { flex: 1 1 0; display: flex; + flex-direction: column; } -.tgcChart { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - overflow: hidden; +.tgcChart__wrapper text { + cursor: pointer; } .tgcChart__label { @@ -24,3 +20,7 @@ text-align: center; font-weight: $euiFontWeightBold; } + +.tgcChart__warning { + width: $euiSize; +} diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.test.js b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.test.js deleted file mode 100644 index eb575457146c5d..00000000000000 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.test.js +++ /dev/null @@ -1,507 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import _ from 'lodash'; -import d3 from 'd3'; -import 'jest-canvas-mock'; - -import { fromNode, delay } from 'bluebird'; -import { TagCloud } from './tag_cloud'; -import { setHTMLElementOffset, setSVGElementGetBBox } from '@kbn/test/jest'; - -describe('tag cloud tests', () => { - let SVGElementGetBBoxSpyInstance; - let HTMLElementOffsetMockInstance; - - beforeEach(() => { - setupDOM(); - }); - - afterEach(() => { - SVGElementGetBBoxSpyInstance.mockRestore(); - HTMLElementOffsetMockInstance.mockRestore(); - }); - - const minValue = 1; - const maxValue = 9; - const midValue = (minValue + maxValue) / 2; - const baseTest = { - data: [ - { rawText: 'foo', displayText: 'foo', value: minValue }, - { rawText: 'bar', displayText: 'bar', value: midValue }, - { rawText: 'foobar', displayText: 'foobar', value: maxValue }, - ], - options: { - orientation: 'single', - scale: 'linear', - minFontSize: 10, - maxFontSize: 36, - }, - expected: [ - { - text: 'foo', - fontSize: '10px', - }, - { - text: 'bar', - fontSize: '23px', - }, - { - text: 'foobar', - fontSize: '36px', - }, - ], - }; - - const singleLayoutTest = _.cloneDeep(baseTest); - - const rightAngleLayoutTest = _.cloneDeep(baseTest); - rightAngleLayoutTest.options.orientation = 'right angled'; - - const multiLayoutTest = _.cloneDeep(baseTest); - multiLayoutTest.options.orientation = 'multiple'; - - const mapWithLog = d3.scale.log(); - mapWithLog.range([baseTest.options.minFontSize, baseTest.options.maxFontSize]); - mapWithLog.domain([minValue, maxValue]); - const logScaleTest = _.cloneDeep(baseTest); - logScaleTest.options.scale = 'log'; - logScaleTest.expected[1].fontSize = Math.round(mapWithLog(midValue)) + 'px'; - - const mapWithSqrt = d3.scale.sqrt(); - mapWithSqrt.range([baseTest.options.minFontSize, baseTest.options.maxFontSize]); - mapWithSqrt.domain([minValue, maxValue]); - const sqrtScaleTest = _.cloneDeep(baseTest); - sqrtScaleTest.options.scale = 'square root'; - sqrtScaleTest.expected[1].fontSize = Math.round(mapWithSqrt(midValue)) + 'px'; - - const biggerFontTest = _.cloneDeep(baseTest); - biggerFontTest.options.minFontSize = 36; - biggerFontTest.options.maxFontSize = 72; - biggerFontTest.expected[0].fontSize = '36px'; - biggerFontTest.expected[1].fontSize = '54px'; - biggerFontTest.expected[2].fontSize = '72px'; - - const trimDataTest = _.cloneDeep(baseTest); - trimDataTest.data.splice(1, 1); - trimDataTest.expected.splice(1, 1); - - let domNode; - let tagCloud; - - const colorScale = d3.scale - .ordinal() - .range(['#00a69b', '#57c17b', '#6f87d8', '#663db8', '#bc52bc', '#9e3533', '#daa05d']); - - function setupDOM() { - domNode = document.createElement('div'); - SVGElementGetBBoxSpyInstance = setSVGElementGetBBox(); - HTMLElementOffsetMockInstance = setHTMLElementOffset(512, 512); - - document.body.appendChild(domNode); - } - - function teardownDOM() { - domNode.innerHTML = ''; - document.body.removeChild(domNode); - } - - [ - singleLayoutTest, - rightAngleLayoutTest, - multiLayoutTest, - logScaleTest, - sqrtScaleTest, - biggerFontTest, - trimDataTest, - ].forEach(function (currentTest) { - describe(`should position elements correctly for options: ${JSON.stringify( - currentTest.options - )}`, () => { - beforeEach(async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(currentTest.data); - tagCloud.setOptions(currentTest.options); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test( - 'completeness should be ok', - handleExpectedBlip(() => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE); - }) - ); - - test( - 'positions should be ok', - handleExpectedBlip(() => { - const textElements = domNode.querySelectorAll('text'); - verifyTagProperties(currentTest.expected, textElements, tagCloud); - }) - ); - }); - }); - - [5, 100, 200, 300, 500].forEach((timeout) => { - // FLAKY: https://github.com/elastic/kibana/issues/94043 - describe.skip(`should only send single renderComplete event at the very end, using ${timeout}ms timeout`, () => { - beforeEach(async () => { - //TagCloud takes at least 600ms to complete (due to d3 animation) - //renderComplete should only notify at the last one - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - - //this timeout modifies the settings before the cloud is rendered. - //the cloud needs to use the correct options - setTimeout(() => tagCloud.setOptions(logScaleTest.options), timeout); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test( - 'completeness should be ok', - handleExpectedBlip(() => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE); - }) - ); - - test( - 'positions should be ok', - handleExpectedBlip(() => { - const textElements = domNode.querySelectorAll('text'); - verifyTagProperties(logScaleTest.expected, textElements, tagCloud); - }) - ); - }); - }); - - describe('should use the latest state before notifying (when modifying options multiple times)', () => { - beforeEach(async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - tagCloud.setOptions(logScaleTest.options); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test( - 'completeness should be ok', - handleExpectedBlip(() => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE); - }) - ); - test( - 'positions should be ok', - handleExpectedBlip(() => { - const textElements = domNode.querySelectorAll('text'); - verifyTagProperties(logScaleTest.expected, textElements, tagCloud); - }) - ); - }); - - describe('should use the latest state before notifying (when modifying data multiple times)', () => { - beforeEach(async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - tagCloud.setData(trimDataTest.data); - - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test( - 'completeness should be ok', - handleExpectedBlip(() => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE); - }) - ); - test( - 'positions should be ok', - handleExpectedBlip(() => { - const textElements = domNode.querySelectorAll('text'); - verifyTagProperties(trimDataTest.expected, textElements, tagCloud); - }) - ); - }); - - describe('should not get multiple render-events', () => { - let counter; - beforeEach(() => { - counter = 0; - - return new Promise((resolve, reject) => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - - setTimeout(() => { - //this should be overridden by later changes - tagCloud.setData(sqrtScaleTest.data); - tagCloud.setOptions(sqrtScaleTest.options); - }, 100); - - setTimeout(() => { - //latest change - tagCloud.setData(logScaleTest.data); - tagCloud.setOptions(logScaleTest.options); - }, 300); - - tagCloud.on('renderComplete', function onRender() { - if (counter > 0) { - reject('Should not get multiple render events'); - } - counter += 1; - resolve(true); - }); - }); - }); - - afterEach(teardownDOM); - - test( - 'completeness should be ok', - handleExpectedBlip(() => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE); - }) - ); - test( - 'positions should be ok', - handleExpectedBlip(() => { - const textElements = domNode.querySelectorAll('text'); - verifyTagProperties(logScaleTest.expected, textElements, tagCloud); - }) - ); - }); - - describe('should show correct data when state-updates are interleaved with resize event', () => { - beforeEach(async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(logScaleTest.data); - tagCloud.setOptions(logScaleTest.options); - - await delay(1000); //let layout run - - SVGElementGetBBoxSpyInstance.mockRestore(); - SVGElementGetBBoxSpyInstance = setSVGElementGetBBox(600, 600); - - tagCloud.resize(); //triggers new layout - setTimeout(() => { - //change the options at the very end too - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - }, 200); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test( - 'completeness should be ok', - handleExpectedBlip(() => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE); - }) - ); - test( - 'positions should be ok', - handleExpectedBlip(() => { - const textElements = domNode.querySelectorAll('text'); - verifyTagProperties(baseTest.expected, textElements, tagCloud); - }) - ); - }); - - describe(`should not put elements in view when container is too small`, () => { - beforeEach(async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test('completeness should not be ok', () => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.INCOMPLETE); - }); - test('positions should not be ok', () => { - const textElements = domNode.querySelectorAll('text'); - for (let i = 0; i < textElements; i++) { - const bbox = textElements[i].getBoundingClientRect(); - verifyBbox(bbox, false, tagCloud); - } - }); - }); - - describe(`tags should fit after making container bigger`, () => { - beforeEach(async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - - //make bigger - tagCloud._size = [600, 600]; - tagCloud.resize(); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test( - 'completeness should be ok', - handleExpectedBlip(() => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE); - }) - ); - }); - - describe(`tags should no longer fit after making container smaller`, () => { - beforeEach(async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - - //make smaller - tagCloud._size = []; - tagCloud.resize(); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test('completeness should not be ok', () => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.INCOMPLETE); - }); - }); - - describe('tagcloudscreenshot', () => { - afterEach(teardownDOM); - - test('should render simple image', async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - - expect(domNode.innerHTML).toMatchSnapshot(); - }); - }); - - function verifyTagProperties(expectedValues, actualElements, tagCloud) { - expect(actualElements.length).toEqual(expectedValues.length); - expectedValues.forEach((test, index) => { - try { - expect(actualElements[index].style.fontSize).toEqual(test.fontSize); - } catch (e) { - throw new Error('fontsize is not correct: ' + e.message); - } - try { - expect(actualElements[index].innerHTML).toEqual(test.text); - } catch (e) { - throw new Error('fontsize is not correct: ' + e.message); - } - isInsideContainer(actualElements[index], tagCloud); - }); - } - - function isInsideContainer(actualElement, tagCloud) { - const bbox = actualElement.getBoundingClientRect(); - verifyBbox(bbox, true, tagCloud); - } - - function verifyBbox(bbox, shouldBeInside, tagCloud) { - const message = ` | bbox-of-tag: ${JSON.stringify([ - bbox.left, - bbox.top, - bbox.right, - bbox.bottom, - ])} vs - bbox-of-container: ${domNode.offsetWidth},${domNode.offsetHeight} - debugInfo: ${JSON.stringify(tagCloud.getDebugInfo())}`; - - try { - expect(bbox.top >= 0 && bbox.top <= domNode.offsetHeight).toBe(shouldBeInside); - } catch (e) { - throw new Error( - 'top boundary of tag should have been ' + (shouldBeInside ? 'inside' : 'outside') + message - ); - } - try { - expect(bbox.bottom >= 0 && bbox.bottom <= domNode.offsetHeight).toBe(shouldBeInside); - } catch (e) { - throw new Error( - 'bottom boundary of tag should have been ' + - (shouldBeInside ? 'inside' : 'outside') + - message - ); - } - try { - expect(bbox.left >= 0 && bbox.left <= domNode.offsetWidth).toBe(shouldBeInside); - } catch (e) { - throw new Error( - 'left boundary of tag should have been ' + (shouldBeInside ? 'inside' : 'outside') + message - ); - } - try { - expect(bbox.right >= 0 && bbox.right <= domNode.offsetWidth).toBe(shouldBeInside); - } catch (e) { - throw new Error( - 'right boundary of tag should have been ' + - (shouldBeInside ? 'inside' : 'outside') + - message - ); - } - } - - /** - * In CI, this entire suite "blips" about 1/5 times. - * This blip causes the majority of these tests fail for the exact same reason: One tag is centered inside the container, - * while the others are moved out. - * This has not been reproduced locally yet. - * It may be an issue with the 3rd party d3-cloud that snags. - * - * The test suite should continue to catch reliably catch regressions of other sorts: unexpected and other uncaught errors, - * scaling issues, ordering issues - * - */ - function shouldAssert() { - const debugInfo = tagCloud.getDebugInfo(); - const count = debugInfo.positions.length; - const largest = debugInfo.positions.pop(); //test suite puts largest tag at the end. - - const centered = largest[1] === 0 && largest[2] === 0; - const halfWidth = debugInfo.size.width / 2; - const halfHeight = debugInfo.size.height / 2; - const inside = debugInfo.positions.filter((position) => { - const x = position.x + halfWidth; - const y = position.y + halfHeight; - return 0 <= x && x <= debugInfo.size.width && 0 <= y && y <= debugInfo.size.height; - }); - - return centered && inside.length === count - 1; - } - - function handleExpectedBlip(assertion) { - return () => { - if (!shouldAssert()) { - return; - } - assertion(); - }; - } -}); diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.test.tsx b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.test.tsx new file mode 100644 index 00000000000000..b4d4e70d5ffe3e --- /dev/null +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.test.tsx @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { Wordcloud, Settings } from '@elastic/charts'; +import { chartPluginMock } from '../../../charts/public/mocks'; +import type { Datatable } from '../../../expressions/public'; +import { mount } from 'enzyme'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import TagCloudChart, { TagCloudChartProps } from './tag_cloud_chart'; +import { TagCloudVisParams } from '../types'; + +jest.mock('../services', () => ({ + getFormatService: jest.fn(() => { + return { + deserialize: jest.fn(), + }; + }), +})); + +const palettesRegistry = chartPluginMock.createPaletteRegistry(); +const visData = ({ + columns: [ + { + id: 'col-0', + name: 'geo.dest: Descending', + }, + { + id: 'col-1', + name: 'Count', + }, + ], + rows: [ + { 'col-0': 'CN', 'col-1': 26 }, + { 'col-0': 'IN', 'col-1': 17 }, + { 'col-0': 'US', 'col-1': 6 }, + { 'col-0': 'DE', 'col-1': 4 }, + { 'col-0': 'BR', 'col-1': 3 }, + ], +} as unknown) as Datatable; + +const visParams = { + bucket: { accessor: 0, format: {} }, + metric: { accessor: 1, format: {} }, + scale: 'linear', + orientation: 'single', + palette: { + type: 'palette', + name: 'default', + }, + minFontSize: 12, + maxFontSize: 70, + showLabel: true, +} as TagCloudVisParams; + +describe('TagCloudChart', function () { + let wrapperProps: TagCloudChartProps; + + beforeAll(() => { + wrapperProps = { + visData, + visParams, + palettesRegistry, + fireEvent: jest.fn(), + renderComplete: jest.fn(), + syncColors: false, + visType: 'tagcloud', + }; + }); + + it('renders the Wordcloud component', async () => { + const component = mount(); + expect(component.find(Wordcloud).length).toBe(1); + }); + + it('renders the label correctly', async () => { + const component = mount(); + const label = findTestSubject(component, 'tagCloudLabel'); + expect(label.text()).toEqual('geo.dest: Descending - Count'); + }); + + it('not renders the label if showLabel setting is off', async () => { + const newVisParams = { ...visParams, showLabel: false }; + const newProps = { ...wrapperProps, visParams: newVisParams }; + const component = mount(); + const label = findTestSubject(component, 'tagCloudLabel'); + expect(label.length).toBe(0); + }); + + it('receives the data on the correct format', () => { + const component = mount(); + expect(component.find(Wordcloud).prop('data')).toStrictEqual([ + { + color: 'black', + text: 'CN', + weight: 1, + }, + { + color: 'black', + text: 'IN', + weight: 0.6086956521739131, + }, + { + color: 'black', + text: 'US', + weight: 0.13043478260869565, + }, + { + color: 'black', + text: 'DE', + weight: 0.043478260869565216, + }, + { + color: 'black', + text: 'BR', + weight: 0, + }, + ]); + }); + + it('sets the angles correctly', async () => { + const newVisParams = { ...visParams, orientation: 'right angled' } as TagCloudVisParams; + const newProps = { ...wrapperProps, visParams: newVisParams }; + const component = mount(); + expect(component.find(Wordcloud).prop('endAngle')).toBe(90); + expect(component.find(Wordcloud).prop('angleCount')).toBe(2); + }); + + it('calls filter callback', () => { + const component = mount(); + component.find(Settings).prop('onElementClick')!([ + [ + { + text: 'BR', + weight: 0.17391304347826086, + color: '#d36086', + }, + { + specId: 'tagCloud', + key: 'tagCloud', + }, + ], + ]); + expect(wrapperProps.fireEvent).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx index f668e22815b60f..b89fe2fa90ede0 100644 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx @@ -6,64 +6,225 @@ * Side Public License, v 1. */ -import React, { useEffect, useMemo, useRef } from 'react'; -import { EuiResizeObserver } from '@elastic/eui'; +import React, { useCallback, useState, useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { throttle } from 'lodash'; - -import { TagCloudVisDependencies } from '../plugin'; +import { EuiIconTip, EuiResizeObserver } from '@elastic/eui'; +import { Chart, Settings, Wordcloud, RenderChangeListener } from '@elastic/charts'; +import type { PaletteRegistry } from '../../../charts/public'; +import type { IInterpreterRenderHandlers } from '../../../expressions/public'; +import { getFormatService } from '../services'; import { TagCloudVisRenderValue } from '../tag_cloud_fn'; -// @ts-ignore -import { TagCloudVisualization } from './tag_cloud_visualization'; import './tag_cloud.scss'; -type TagCloudChartProps = TagCloudVisDependencies & - TagCloudVisRenderValue & { - fireEvent: (event: any) => void; - renderComplete: () => void; - }; +const MAX_TAG_COUNT = 200; + +export type TagCloudChartProps = TagCloudVisRenderValue & { + fireEvent: IInterpreterRenderHandlers['event']; + renderComplete: IInterpreterRenderHandlers['done']; + palettesRegistry: PaletteRegistry; +}; + +const calculateWeight = (value: number, x1: number, y1: number, x2: number, y2: number) => + ((value - x1) * (y2 - x2)) / (y1 - x1) + x2; + +const getColor = ( + palettes: PaletteRegistry, + activePalette: string, + text: string, + values: string[], + syncColors: boolean +) => { + return palettes?.get(activePalette).getCategoricalColor( + [ + { + name: text, + rankAtDepth: values.length ? values.findIndex((name) => name === text) : 0, + totalSeriesAtDepth: values.length || 1, + }, + ], + { + maxDepth: 1, + totalSeries: values.length || 1, + behindText: false, + syncColors, + } + ); +}; + +const ORIENTATIONS = { + single: { + endAngle: 0, + angleCount: 360, + }, + 'right angled': { + endAngle: 90, + angleCount: 2, + }, + multiple: { + endAngle: -90, + angleCount: 12, + }, +}; export const TagCloudChart = ({ - colors, visData, visParams, + palettesRegistry, fireEvent, renderComplete, + syncColors, }: TagCloudChartProps) => { - const chartDiv = useRef(null); - const visController = useRef(null); + const [warning, setWarning] = useState(false); + const { bucket, metric, scale, palette, showLabel, orientation } = visParams; + const bucketFormatter = bucket ? getFormatService().deserialize(bucket.format) : null; - useEffect(() => { - if (chartDiv.current) { - visController.current = new TagCloudVisualization(chartDiv.current, colors, fireEvent); - } - return () => { - visController.current.destroy(); - visController.current = null; - }; - }, [colors, fireEvent]); - - useEffect(() => { - if (visController.current) { - visController.current.render(visData, visParams).then(renderComplete); - } - }, [visData, visParams, renderComplete]); + const tagCloudData = useMemo(() => { + const tagColumn = bucket ? visData.columns[bucket.accessor].id : -1; + const metricColumn = visData.columns[metric.accessor]?.id; + + const metrics = visData.rows.map((row) => row[metricColumn]); + const values = bucket ? visData.rows.map((row) => row[tagColumn]) : []; + const maxValue = Math.max(...metrics); + const minValue = Math.min(...metrics); + + return visData.rows.map((row) => { + const tag = row[tagColumn] === undefined ? 'all' : row[tagColumn]; + return { + text: (bucketFormatter ? bucketFormatter.convert(tag, 'text') : tag) as string, + weight: + tag === 'all' || visData.rows.length <= 1 + ? 1 + : calculateWeight(row[metricColumn], minValue, maxValue, 0, 1) || 0, + color: getColor(palettesRegistry, palette.name, tag, values, syncColors) || 'rgba(0,0,0,0)', + }; + }); + }, [ + bucket, + bucketFormatter, + metric.accessor, + palette.name, + palettesRegistry, + syncColors, + visData.columns, + visData.rows, + ]); + + const label = bucket + ? `${visData.columns[bucket.accessor].name} - ${visData.columns[metric.accessor].name}` + : ''; + + const onRenderChange = useCallback( + (isRendered) => { + if (isRendered) { + renderComplete(); + } + }, + [renderComplete] + ); - const updateChartSize = useMemo( + const updateChart = useMemo( () => throttle(() => { - if (visController.current) { - visController.current.render(visData, visParams).then(renderComplete); - } + setWarning(false); }, 300), - [renderComplete, visData, visParams] + [] + ); + + const handleWordClick = useCallback( + (d) => { + if (!bucket) { + return; + } + const termsBucket = visData.columns[bucket.accessor]; + const clickedValue = d[0][0].text; + + const rowIndex = visData.rows.findIndex((row) => { + const formattedValue = bucketFormatter + ? bucketFormatter.convert(row[termsBucket.id], 'text') + : row[termsBucket.id]; + return formattedValue === clickedValue; + }); + + if (rowIndex < 0) { + return; + } + + fireEvent({ + name: 'filterBucket', + data: { + data: [ + { + table: visData, + column: bucket.accessor, + row: rowIndex, + }, + ], + }, + }); + }, + [bucket, bucketFormatter, fireEvent, visData] ); return ( - + {(resizeRef) => ( -
-
+
+ + + { + setWarning(true); + }} + /> + + {label && showLabel && ( +
+ {label} +
+ )} + {warning && ( +
+ + } + /> +
+ )} + {tagCloudData.length > MAX_TAG_COUNT && ( +
+ + } + /> +
+ )}
)} diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx index d5e005a6386806..6682799a8038ad 100644 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx @@ -6,16 +6,22 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; -import { SelectOption, SwitchOption } from '../../../vis_default_editor/public'; +import type { PaletteRegistry } from '../../../charts/public'; +import { VisEditorOptionsProps } from '../../../visualizations/public'; +import { SelectOption, SwitchOption, PalettePicker } from '../../../vis_default_editor/public'; import { ValidatedDualRange } from '../../../kibana_react/public'; -import { TagCloudVisParams } from '../types'; +import { TagCloudVisParams, TagCloudTypeProps } from '../types'; import { collections } from './collections'; -function TagCloudOptions({ stateParams, setValue }: VisEditorOptionsProps) { +interface TagCloudOptionsProps + extends VisEditorOptionsProps, + TagCloudTypeProps {} + +function TagCloudOptions({ stateParams, setValue, palettes }: TagCloudOptionsProps) { + const [palettesRegistry, setPalettesRegistry] = useState(undefined); const handleFontSizeChange = ([minFontSize, maxFontSize]: [string | number, string | number]) => { setValue('minFontSize', Number(minFontSize)); setValue('maxFontSize', Number(maxFontSize)); @@ -24,6 +30,14 @@ function TagCloudOptions({ stateParams, setValue }: VisEditorOptionsProps { + const fetchPalettes = async () => { + const palettesService = await palettes?.getPalettes(); + setPalettesRegistry(palettesService); + }; + fetchPalettes(); + }, [palettes]); + return ( + {palettesRegistry && ( + { + setValue(paramName, value); + }} + /> + )} + { - if (!this._visParams.bucket) { - return; - } - - fireEvent({ - name: 'filterBucket', - data: { - data: [ - { - table: event.meta.data, - column: 0, - row: event.meta.rowIndex, - }, - ], - }, - }); - }); - this._renderComplete$ = Rx.fromEvent(this._tagCloud, 'renderComplete'); - - this._feedbackNode = document.createElement('div'); - this._containerNode.appendChild(this._feedbackNode); - this._feedbackMessage = React.createRef(); - render( - - - , - this._feedbackNode - ); - - this._labelNode = document.createElement('div'); - this._containerNode.appendChild(this._labelNode); - this._label = React.createRef(); - render(