diff --git a/.eslintrc.js b/.eslintrc.js index 7dd0860aac04e1..03a674993ab507 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -189,13 +189,6 @@ module.exports = { 'react-hooks/exhaustive-deps': 'off', }, }, - { - files: ['x-pack/legacy/plugins/uptime/**/*.{js,ts,tsx}'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - 'react-hooks/rules-of-hooks': 'off', - }, - }, /** * Files that require Apache 2.0 headers, settings diff --git a/NOTICE.txt b/NOTICE.txt index 230e5117460224..955c3127fa9559 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1,5 +1,5 @@ Kibana source code with Kibana X-Pack source code -Copyright 2012-2019 Elasticsearch B.V. +Copyright 2012-2020 Elasticsearch B.V. --- Pretty handling of logarithmic axes. diff --git a/docs/canvas/canvas-expression-lifecycle.asciidoc b/docs/canvas/canvas-expression-lifecycle.asciidoc new file mode 100644 index 00000000000000..895c1382c4d360 --- /dev/null +++ b/docs/canvas/canvas-expression-lifecycle.asciidoc @@ -0,0 +1,261 @@ +[role="xpack"] +[[canvas-expression-lifecycle]] +== Canvas expression lifecycle + +Elements in Canvas are all created using an *expression language* that defines how to retrieve, manipulate, and ultimately visualize data. The goal is to allow you to do most of what you need without understanding the *expression language*, but learning how it works unlocks a lot of Canvas's power. + + +[[canvas-expressions-always-start-with-a-function]] +=== Expressions always start with a function + +Expressions simply execute <> in a specific order, which produce some output value. That output can then be inserted into another function, and another after that, until it produces the output you need. + +To use demo dataset available in Canvas to produce a table, run the following expression: + +[source,text] +---- +filters +| demodata +| table +| render +---- + +This expression starts out with the <> function, which provides the value of any time filters or dropdown filters in the workpad. This is then inserted into <>, a function that returns exactly what you expect, demo data. Because the <> function receives the filter information from the <> function before it, it applies those filters to reduce the set of data it returns. We call the output from the previous function _context_. + +The filtered <> becomes the _context_ of the next function, <>, which creates a table visualization from this data set. The <> function isn’t strictly required, but by being explicit, you have the option of providing arguments to control things like the font used in the table. The output of the <> function becomes the _context_ of the <> function. Like the <>, the <> function isn’t required either, but it allows access to other arguments, such as styling the border of the element or injecting custom CSS. + + +[[canvas-function-arguments]] +=== Function arguments + +Let’s look at another expression, which uses the same <> function, but instead produces a pie chart. + +image::images/canvas-functions-can-take-arguments-pie-chart.png[Pie Chart, height=400] +[source,text] +---- +filters +| demodata +| pointseries color="state" size="max(price)" +| pie +| render +---- + +To produce a filtered set of random data, the expression uses the <> and <> functions. This time, however, the output becomes the context for the <> function, which is a way to aggregate your data, similar to how Elasticsearch works, but more generalized. In this case, the data is split up using the `color` and `size` dimensions, using arguments on the <> function. Each unique value in the state column will have an associated size value, which in this case, will be the maximum value of the price column. + +If the expression stopped there, it would produce a `pointseries` data type as the output of this expression. But instead of looking at the raw values, the result is inserted into the <> function, which will produce an output that will render a pie visualization. And just like before, this is inserted into the <> function, which is useful for its arguments. + +The end result is a simple pie chart that uses the default color palette, but the <> function can take additional arguments that control how it gets rendered. For example, you can provide a `hole` argument to turn your pie chart into a donut chart by changing the expression to: + + +image::images/canvas-functions-can-take-arguments-donut-chart.png[Donut Chart, height=400] +[source,text] +---- +filters +| demodata +| pointseries color="state" size="max(price)" +| pie hole=50 +| render +---- + + +[[canvas-aliases-and-unnamed-arguments]] +=== Aliases and unnamed arguments + +Argument definitions have one canonical name, which is always provided in the underlying code. When argument definitions are used in an expression, they often include aliases that make them easier or faster to type. + +For example, the <> function has 2 arguments: + +* `expression` - Produces a calculated value. +* `name` - The name of column. + +The `expression` argument includes some aliases, namely `exp`, `fn`, and `function`. That means that you can use any of those four options to provide that argument’s value. + +So `mapColumn name=newColumn fn={string example}` is equal to `mapColumn name=newColumn expression={string example}`. + +There’s also a special type of alias which allows you to leave off the argument’s name entirely. The alias for this is an underscore, which indicates that the argument is an _unnamed_ argument and can be provided without explicitly naming it in the expression. The `name` argument here uses the _unnamed_ alias, which means that you can further simplify our example to `mapColumn newColumn fn={string example}`. + +NOTE: There can only be one _unnamed_ argument for each function. + + +[[canvas-change-your-expression-change-your-output]] +=== Change your expression, change your output +You can substitute one function for another to change the output. For example, you could change the visualization by swapping out the <> function for another renderer, a function that returns a `render` data type. + +Let’s change that last pie chart into a bubble chart by replacing the <> function with the <> function. This is possible because both functions can accept a `pointseries` data type as their _context_. Switching the functions will work, but it won’t produce a useful visualization on its own since you don’t have the x-axis and y-axis defined. You will also need to modify the <> function to change its output. In this case, you can change the `size` argument to `y`, so the maximum price values are plotted on the y-axis, and add an `x` argument using the `@timestamp` field in the data to plot those values over time. This leaves you with the following expression and produces a bubble chart showing the max price of each state over time: + +image::images/canvas-change-your-expression-chart.png[Bubble Chart, height=400] +[source,text] +---- +filters +| demodata +| pointseries color="state" y="max(price)" x="@timestamp" +| plot +| render +---- + +Similar to the <> function, the <> function takes arguments that control the design elements of the visualization. As one example, passing a `legend` argument with a value of `false` to the function will hide the legend on the chart. + +image::images/canvas-change-your-expression-chart-no-legend.png[Bubble Chart Without Legend, height=400] +[source,text,subs=+quotes] +---- +filters +| demodata +| pointseries color="state" y="max(price)" x="@timestamp" +| plot *legend=false* +| render +---- + + +[[canvas-fetch-and-manipulate-data]] +=== Fetch and manipulate data +So far, you have only seen expressions as a way to produce visualizations, but that’s not really what’s happening. Expressions only produce data, which is then used to create something, which in the case of Canvas, means rendering an element. An element can be a visualization, driven by data, but it can also be something much simpler, like a static image. Either way, an expression is used to produce an output that is used to render the desired result. For example, here’s an expression that shows an image: + +[source,text] +---- +image dataurl=https://placekitten.com/160/160 mode="cover" +---- + +But as mentioned, this doesn’t actually _render that image_, but instead it _produces some output that can be used to render that image_. That’s an important distinction, and you can see the actual output by adding in the render function and telling it to produce debug output. For example: + +[source,text] +---- +image dataurl=https://placekitten.com/160/160 mode="cover" +| render as=debug +---- + +The follow appears as JSON output: + +[source,JSON] +---- +{ + "type": "image", + "mode": "cover", + "dataurl": "https://placekitten.com/160/160" +} +---- + +NOTE: You may need to expand the element’s size to see the whole output. + +Canvas uses this output’s data type to map to a specific renderer and passes the entire output into it. It’s up to the image render function to produce an image on the workpad’s page. In this case, the expression produces some JSON output, but expressions can also produce other, simpler data, like a string or a number. Typically, useful results use JSON. + +Canvas uses the output to render an element, but other applications can use expressions to do pretty much anything. As stated previously, expressions simply execute functions, and the functions are all written in Javascript. That means if you can do something in Javascript, you can do it with an expression. + +This can include: + +* Sending emails +* Sending notifications +* Reading from a file +* Writing to a file +* Controlling devices with WebUSB or Web Bluetooth +* Consuming external APIs + +If your Javascript works in the environment where the code will run, such as in Node.js or in a browser, you can do it with an expression. + +[[canvas-expressions-compose-functions-with-subexpressions]] +=== Compose functions with sub-expressions + +You may have noticed another syntax in examples from other sections, namely expressions inside of curly brackets. These are called sub-expressions, and they can be used to provide a calculated value to another expression, instead of just a static one. + +A simple example of this is when you upload your own images to a Canvas workpad. That upload becomes an asset, and that asset can be retrieved using the `asset` function. Usually you’ll just do this from the UI, adding an image element to the page and uploading your image from the control in the sidebar, or picking an existing asset from there as well. In both cases, the system will consume that asset via the `asset` function, and you’ll end up with an expression similar to this: + +[source,text] +---- +image dataurl={asset 3cb3ec3a-84d7-48fa-8709-274ad5cc9e0b} +---- + +Sub-expressions are executed before the function that uses them is executed. In this case, `asset` will be run first, it will produce a value, the base64-encoded value of the image and that value will be used as the value for the `dataurl` argument in the <> function. After the asset function executes, you will get the following output: + +[source,text] +---- +image dataurl="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0" +---- + +Since all of the sub-expressions are now resolved into actual values, the <> function can be executed to produce its JSON output, just as it’s explained previously. In the case of images, the ability to nest sub-expressions is particularly useful to show one of several images conditionally. For example, you could swap between two images based on some calculated value by mixing in the <> function, like in this example expression: + +[source,text] +---- +demodata +| image dataurl={ + if condition={getCell price | gte 100} + then={asset 3cb3ec3a-84d7-48fa-8709-274ad5cc9e0b} + else={asset cbc11a1f-8f25-4163-94b4-2c3a060192e7} +} +---- + +NOTE: The examples in this section can’t be copy and pasted directly, since the values used throughout will not exist in your workpad. + +Here, the expression to use for the value of the `condition` argument, `getCell price | gte 100`, runs first since it is nested deeper. + +The expression does the following: + +* Retrieves the value from the *price* column in the first row of the `demodata` data table +* Inputs the value to the `gte` function +* Compares the value to `100` +* Returns `true` if the value is 100 or greater, and `false` if the value is 100 or less + +That boolean value becomes the value for the `condition` argument. The output from the `then` expression is used as the output when `condition` is `true`. The output from the `else` expression is used when `condition` is false. In both cases, a base64-encoded image will be returned, and one of the two images will be displayed. + +You might be wondering how the <> function in the sub-expression accessed the data from the <> function, even though <> was not being directly inserted into <>. The answer is simple, but important to understand. When nested sub-expressions are executed, they automatically receive the same _context_, or output of the previous function that its parent function receives. In this specific expression, demodata’s data table is automatically provided to the nested expression’s `getCell` function, which allows that expression to pull out a value and compare it to another value. + +The passing of the _context_ is automatic, and it happens no matter how deeply you nest your sub-expressions. To demonstrate this, let’s modify the expression slightly to compare the value of the price against multiple conditions using the <> function. + +[source,text] +---- +demodata +| image dataurl={ + if condition={getCell price | all {gte 100} {neq 105}} + then={asset 3cb3ec3a-84d7-48fa-8709-274ad5cc9e0b} + else={asset cbc11a1f-8f25-4163-94b4-2c3a060192e7} +} +---- + +This time, `getCell price` is run, and the result is passed into the next function as the context. Then, each sub-expression of the <> function is run, with the context given to their parent, which in this case is the result of `getCell price`. If `all` of these sub-expressions evaluate to `true`, then the `if` condition argument will be true. + +Sub-expressions can seem a little foreign, especially if you aren’t a developer, but they’re worth getting familiar with, since they provide a ton of power and flexibility. Since you can nest any expression you want, you can also use this behavior to mix data from multiple indices, or even data from multiple sources. As an example, you could query an API for a value to use as part of the query provided to <>. + +This whole section is really just scratching the surface, but hopefully after reading it, you at least understand how to read expressions and make sense of what they are doing. With a little practice, you’ll get the hang of mixing _context_ and sub-expressions together to turn any input into your desired output. + +[[canvas-handling-context-and-argument-types]] +=== Handling context and argument types +If you look through the <>, you may notice that all of them define what a function accepts and what it returns. Additionally, every argument includes a type property that specifies the kind of data that can be used. These two types of values are actually the same, and can be used as a guide for how to deal with piping to other functions and using subexpressions for argument values. + +To explain how this works, consider the following expression from the previous section: + +[source,text] +---- +image dataurl={asset 3cb3ec3a-84d7-48fa-8709-274ad5cc9e0b} +---- + +If you <> for the `image` function, you’ll see that it accepts the `null` data type and returns an `image` data type. Accepting `null` effectively means that it does not use context at all, so if you insert anything to `image`, the value that was produced previously will be ignored. When the function executes, it will produce an `image` output, which is simply an object of type `image` that contains the information required to render an image. + +NOTE: The function does not render an image itself. + +As explained in the "<>" section, the output of an expression is just data. So the `image` type here is just a specific shape of data, not an actual image. + +Next, let’s take a look at the `asset` function. Like `image`, it accepts `null`, but it returns something different, a `string` in this case. Because `asset` will produce a string, its output can be used as the input for any function or argument that accepts a string. + +<> for the `dataurl` argument, its type is `string`, meaning it will accept any kind of string. There are some rules about the value of the string that the function itself enforces, but as far as the interpreter is concerned, that expression is valid because the argument accepts a string and the output of `asset` is a string. + +The interpreter also attempts to cast some input types into others, which allows you to use a string input even when the function or argument calls for a number. Keep in mind that it’s not able to convert any string value, but if the string is a number, it can easily be cast into a `number` type. Take the following expression for example: + +[source,text] +---- +string "0.4" +| revealImage image={asset asset-06511b39-ec44-408a-a5f3-abe2da44a426} +---- + +If you <> for the `revealImage` function, you’ll see that it accepts a `number` but the `string` function returns a `string` type. In this case, because the string value is a number, it can be converted into a `number` type and used without you having to do anything else. + +Most `primitive` types can be converted automatically, as you might expect. You just saw that a `string` can be cast into a `number`, but you can also pretty easily cast things into `boolean` too, and you can cast anything to `null`. + +There are other useful type casting options available. For example, something of type `datatable` can be cast to a type `pointseries` simply by only preserving specific columns from the data (namely x, y, size, color, and text). This allows you to treat your source data, which is generally of type `datatable`, like a `pointseries` type simply by convention. + +You can fetch data from Elasticsearch using `essql`, which allows you to aggregate the data, provide a custom name for the value, and insert that data directly to another function that only accepts `pointseries` even though `essql` will output a `datatable` type. This makes the following example expression valid: + +[source,text] +---- +essql "SELECT user AS x, sum(cost) AS y FROM index GROUP BY user" +| plot +---- + +In the docs you can see that `essql` returns a `datatable` type, but `plot` expects a `pointseries` context. This works because the `datatable` output will have the columns `x` and `y` as a result of using `AS` in the sql statement to name them. Because the data follows the convention of the `pointseries` data type, casting it into `pointseries` is possible, and it can be passed directly to `plot` as a result. diff --git a/docs/development/core/public/kibana-plugin-public.app.approute.md b/docs/development/core/public/kibana-plugin-public.app.approute.md new file mode 100644 index 00000000000000..7f35f4346b6b3a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.app.approute.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [App](./kibana-plugin-public.app.md) > [appRoute](./kibana-plugin-public.app.approute.md) + +## App.appRoute property + +Override the application's routing path from `/app/${id}`. Must be unique across registered applications. Should not include the base path from HTTP. + +Signature: + +```typescript +appRoute?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.app.md b/docs/development/core/public/kibana-plugin-public.app.md index edab4f88497f64..acf07cbf62e91e 100644 --- a/docs/development/core/public/kibana-plugin-public.app.md +++ b/docs/development/core/public/kibana-plugin-public.app.md @@ -16,6 +16,7 @@ export interface App extends AppBase | Property | Type | Description | | --- | --- | --- | +| [appRoute](./kibana-plugin-public.app.approute.md) | string | Override the application's routing path from /app/${id}. Must be unique across registered applications. Should not include the base path from HTTP. | | [chromeless](./kibana-plugin-public.app.chromeless.md) | boolean | Hide the UI chrome when the application is mounted. Defaults to false. Takes precedence over chrome service visibility settings. | | [mount](./kibana-plugin-public.app.mount.md) | AppMount | AppMountDeprecated | A mount function called when the user navigates to this app's route. May have signature of [AppMount](./kibana-plugin-public.appmount.md) or [AppMountDeprecated](./kibana-plugin-public.appmountdeprecated.md). | diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md index a1544373ee6980..7cd709d615729f 100644 --- a/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md @@ -4,7 +4,7 @@ ## AppMountParameters.appBasePath property -The base path for configuring the application's router. +The route path for configuring navigation to the application. This string should not include the base path from HTTP. Signature: @@ -22,6 +22,7 @@ export class MyPlugin implements Plugin { setup({ application }) { application.register({ id: 'my-app', + appRoute: '/my-app', async mount(params) { const { renderApp } = await import('./application'); return renderApp(params); diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.md index 8733f9cd4915d3..aa5ca93ed8ff03 100644 --- a/docs/development/core/public/kibana-plugin-public.appmountparameters.md +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.md @@ -15,6 +15,6 @@ export interface AppMountParameters | Property | Type | Description | | --- | --- | --- | -| [appBasePath](./kibana-plugin-public.appmountparameters.appbasepath.md) | string | The base path for configuring the application's router. | +| [appBasePath](./kibana-plugin-public.appmountparameters.appbasepath.md) | string | The route path for configuring navigation to the application. This string should not include the base path from HTTP. | | [element](./kibana-plugin-public.appmountparameters.element.md) | HTMLElement | The container element to render the application into. | diff --git a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md index a4fa3f17d0d94f..1ce18834f53196 100644 --- a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md +++ b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md @@ -9,5 +9,5 @@ Search for objects Signature: ```typescript -find: (options: Pick) => Promise>; +find: (options: Pick) => Promise>; ``` diff --git a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md index 3c4e33db4af91b..6033c667c1866c 100644 --- a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md +++ b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md @@ -20,7 +20,7 @@ export declare class SavedObjectsClient | [bulkGet](./kibana-plugin-public.savedobjectsclient.bulkget.md) | | (objects?: {
id: string;
type: string;
}[]) => Promise<SavedObjectsBatchResponse<SavedObjectAttributes>> | Returns an array of objects by id | | [create](./kibana-plugin-public.savedobjectsclient.create.md) | | <T extends SavedObjectAttributes>(type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise<SimpleSavedObject<T>> | Persists an object | | [delete](./kibana-plugin-public.savedobjectsclient.delete.md) | | (type: string, id: string) => Promise<{}> | Deletes an object | -| [find](./kibana-plugin-public.savedobjectsclient.find.md) | | <T extends SavedObjectAttributes>(options: Pick<SavedObjectFindOptionsServer, "search" | "filter" | "type" | "page" | "perPage" | "sortField" | "fields" | "searchFields" | "hasReference" | "defaultSearchOperator">) => Promise<SavedObjectsFindResponsePublic<T>> | Search for objects | +| [find](./kibana-plugin-public.savedobjectsclient.find.md) | | <T extends SavedObjectAttributes>(options: Pick<SavedObjectFindOptionsServer, "search" | "filter" | "type" | "page" | "fields" | "searchFields" | "defaultSearchOperator" | "hasReference" | "sortField" | "perPage">) => Promise<SavedObjectsFindResponsePublic<T>> | Search for objects | | [get](./kibana-plugin-public.savedobjectsclient.get.md) | | <T extends SavedObjectAttributes>(type: string, id: string) => Promise<SimpleSavedObject<T>> | Fetches a single object | ## Methods diff --git a/docs/development/core/server/kibana-plugin-server.basepath.get.md b/docs/development/core/server/kibana-plugin-server.basepath.get.md index 3f7895dd727996..6ef7022f10e624 100644 --- a/docs/development/core/server/kibana-plugin-server.basepath.get.md +++ b/docs/development/core/server/kibana-plugin-server.basepath.get.md @@ -1,13 +1,13 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [BasePath](./kibana-plugin-server.basepath.md) > [get](./kibana-plugin-server.basepath.get.md) - -## BasePath.get property - -returns `basePath` value, specific for an incoming request. - -Signature: - -```typescript -(request: KibanaRequest | LegacyRequest) => string; -``` + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [BasePath](./kibana-plugin-server.basepath.md) > [get](./kibana-plugin-server.basepath.get.md) + +## BasePath.get property + +returns `basePath` value, specific for an incoming request. + +Signature: + +```typescript +get: (request: KibanaRequest | LegacyRequest) => string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.basepath.set.md b/docs/development/core/server/kibana-plugin-server.basepath.set.md index 633765389e649b..56a7f644d34ccc 100644 --- a/docs/development/core/server/kibana-plugin-server.basepath.set.md +++ b/docs/development/core/server/kibana-plugin-server.basepath.set.md @@ -1,13 +1,13 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [BasePath](./kibana-plugin-server.basepath.md) > [set](./kibana-plugin-server.basepath.set.md) - -## BasePath.set property - -sets `basePath` value, specific for an incoming request. - -Signature: - -```typescript -(request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => void; -``` + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [BasePath](./kibana-plugin-server.basepath.md) > [set](./kibana-plugin-server.basepath.set.md) + +## BasePath.set property + +sets `basePath` value, specific for an incoming request. + +Signature: + +```typescript +set: (request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => void; +``` diff --git a/docs/development/core/server/kibana-plugin-server.irenderoptions.includeusersettings.md b/docs/development/core/server/kibana-plugin-server.irenderoptions.includeusersettings.md new file mode 100644 index 00000000000000..cedf3d27d0887d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.irenderoptions.includeusersettings.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IRenderOptions](./kibana-plugin-server.irenderoptions.md) > [includeUserSettings](./kibana-plugin-server.irenderoptions.includeusersettings.md) + +## IRenderOptions.includeUserSettings property + +Set whether to output user settings in the page metadata. `true` by default. + +Signature: + +```typescript +includeUserSettings?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.irenderoptions.md b/docs/development/core/server/kibana-plugin-server.irenderoptions.md new file mode 100644 index 00000000000000..34bed8b5e078c1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.irenderoptions.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IRenderOptions](./kibana-plugin-server.irenderoptions.md) + +## IRenderOptions interface + + +Signature: + +```typescript +export interface IRenderOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [includeUserSettings](./kibana-plugin-server.irenderoptions.includeusersettings.md) | boolean | Set whether to output user settings in the page metadata. true by default. | + diff --git a/docs/development/core/server/kibana-plugin-server.irouter.handlelegacyerrors.md b/docs/development/core/server/kibana-plugin-server.irouter.handlelegacyerrors.md index 238424b1df1d52..ff71f13466cf88 100644 --- a/docs/development/core/server/kibana-plugin-server.irouter.handlelegacyerrors.md +++ b/docs/development/core/server/kibana-plugin-server.irouter.handlelegacyerrors.md @@ -1,13 +1,13 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IRouter](./kibana-plugin-server.irouter.md) > [handleLegacyErrors](./kibana-plugin-server.irouter.handlelegacyerrors.md) - -## IRouter.handleLegacyErrors property - -Wrap a router handler to catch and converts legacy boom errors to proper custom errors. - -Signature: - -```typescript -(handler: RequestHandler) => RequestHandler; -``` + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IRouter](./kibana-plugin-server.irouter.md) > [handleLegacyErrors](./kibana-plugin-server.irouter.handlelegacyerrors.md) + +## IRouter.handleLegacyErrors property + +Wrap a router handler to catch and converts legacy boom errors to proper custom errors. + +Signature: + +```typescript +handleLegacyErrors: (handler: RequestHandler) => RequestHandler; +``` diff --git a/docs/development/core/server/kibana-plugin-server.iscopedrenderingclient.md b/docs/development/core/server/kibana-plugin-server.iscopedrenderingclient.md new file mode 100644 index 00000000000000..2e6daa58db25fa --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.iscopedrenderingclient.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IScopedRenderingClient](./kibana-plugin-server.iscopedrenderingclient.md) + +## IScopedRenderingClient interface + + +Signature: + +```typescript +export interface IScopedRenderingClient +``` + +## Methods + +| Method | Description | +| --- | --- | +| [render(options)](./kibana-plugin-server.iscopedrenderingclient.render.md) | Generate a KibanaResponse which renders an HTML page bootstrapped with the core bundle. Intended as a response body for HTTP route handlers. | + diff --git a/docs/development/core/server/kibana-plugin-server.iscopedrenderingclient.render.md b/docs/development/core/server/kibana-plugin-server.iscopedrenderingclient.render.md new file mode 100644 index 00000000000000..1bc78dd84571d7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.iscopedrenderingclient.render.md @@ -0,0 +1,41 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IScopedRenderingClient](./kibana-plugin-server.iscopedrenderingclient.md) > [render](./kibana-plugin-server.iscopedrenderingclient.render.md) + +## IScopedRenderingClient.render() method + +Generate a `KibanaResponse` which renders an HTML page bootstrapped with the `core` bundle. Intended as a response body for HTTP route handlers. + +Signature: + +```typescript +render(options?: IRenderOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| options | IRenderOptions | | + +Returns: + +`Promise` + +## Example + + +```ts +router.get( + { path: '/', validate: false }, + (context, request, response) => + response.ok({ + body: await context.core.rendering.render(), + headers: { + 'content-security-policy': context.core.http.csp.header, + }, + }) +); + +``` + diff --git a/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.core.md b/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.core.md index 09ebf1170715b8..c4c043a903d06c 100644 --- a/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.core.md +++ b/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.core.md @@ -7,7 +7,5 @@ Signature: ```typescript -core: InternalCoreSetup & { - plugins: PluginsServiceSetup; - }; +core: LegacyCoreSetup; ``` diff --git a/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.md b/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.md index 4475318522dfab..7961cedd2c0548 100644 --- a/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.md +++ b/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.md @@ -18,6 +18,6 @@ export interface LegacyServiceSetupDeps | Property | Type | Description | | --- | --- | --- | -| [core](./kibana-plugin-server.legacyservicesetupdeps.core.md) | InternalCoreSetup & {
plugins: PluginsServiceSetup;
} | | +| [core](./kibana-plugin-server.legacyservicesetupdeps.core.md) | LegacyCoreSetup | | | [plugins](./kibana-plugin-server.legacyservicesetupdeps.plugins.md) | Record<string, unknown> | | diff --git a/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.core.md b/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.core.md index c5cf473aaa01a0..47018f4594967f 100644 --- a/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.core.md +++ b/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.core.md @@ -7,7 +7,5 @@ Signature: ```typescript -core: InternalCoreStart & { - plugins: PluginsServiceStart; - }; +core: LegacyCoreStart; ``` diff --git a/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.md b/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.md index 801138b64e46a4..602fe5356d5251 100644 --- a/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.md +++ b/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.md @@ -18,6 +18,6 @@ export interface LegacyServiceStartDeps | Property | Type | Description | | --- | --- | --- | -| [core](./kibana-plugin-server.legacyservicestartdeps.core.md) | InternalCoreStart & {
plugins: PluginsServiceStart;
} | | +| [core](./kibana-plugin-server.legacyservicestartdeps.core.md) | LegacyCoreStart | | | [plugins](./kibana-plugin-server.legacyservicestartdeps.plugins.md) | Record<string, unknown> | | diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index 9c8aafb158bfdf..5e7f84c55244d4 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -70,7 +70,9 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IKibanaResponse](./kibana-plugin-server.ikibanaresponse.md) | A response data object, expected to returned as a result of [RequestHandler](./kibana-plugin-server.requesthandler.md) execution | | [IKibanaSocket](./kibana-plugin-server.ikibanasocket.md) | A tiny abstraction for TCP socket. | | [IndexSettingsDeprecationInfo](./kibana-plugin-server.indexsettingsdeprecationinfo.md) | | +| [IRenderOptions](./kibana-plugin-server.irenderoptions.md) | | | [IRouter](./kibana-plugin-server.irouter.md) | Registers route handlers for specified resource path and method. See [RouteConfig](./kibana-plugin-server.routeconfig.md) and [RequestHandler](./kibana-plugin-server.requesthandler.md) for more information about arguments to route registrations. | +| [IScopedRenderingClient](./kibana-plugin-server.iscopedrenderingclient.md) | | | [IUiSettingsClient](./kibana-plugin-server.iuisettingsclient.md) | Server-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. | | [KibanaRequestRoute](./kibana-plugin-server.kibanarequestroute.md) | Request specific route information exposed to a handler. | | [LegacyRequest](./kibana-plugin-server.legacyrequest.md) | | @@ -91,7 +93,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [PluginManifest](./kibana-plugin-server.pluginmanifest.md) | Describes the set of required and optional properties plugin can define in its mandatory JSON manifest file. | | [PluginsServiceSetup](./kibana-plugin-server.pluginsservicesetup.md) | | | [PluginsServiceStart](./kibana-plugin-server.pluginsservicestart.md) | | -| [RequestHandlerContext](./kibana-plugin-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.Provides the following clients: - [savedObjects.client](./kibana-plugin-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [elasticsearch.dataClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.adminClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch admin client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request | +| [RequestHandlerContext](./kibana-plugin-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.Provides the following clients: - [rendering](./kibana-plugin-server.iscopedrenderingclient.md) - Rendering client which uses the data of the incoming request - [savedObjects.client](./kibana-plugin-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [elasticsearch.dataClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.adminClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch admin client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request | | [RouteConfig](./kibana-plugin-server.routeconfig.md) | Route specific configuration. | | [RouteConfigOptions](./kibana-plugin-server.routeconfigoptions.md) | Additional route options. | | [RouteConfigOptionsBody](./kibana-plugin-server.routeconfigoptionsbody.md) | Additional body options for a route | diff --git a/docs/development/core/server/kibana-plugin-server.requesthandlercontext.core.md b/docs/development/core/server/kibana-plugin-server.requesthandlercontext.core.md index 2d8b27ecb6c670..d1760dafd5bb63 100644 --- a/docs/development/core/server/kibana-plugin-server.requesthandlercontext.core.md +++ b/docs/development/core/server/kibana-plugin-server.requesthandlercontext.core.md @@ -8,6 +8,7 @@ ```typescript core: { + rendering: IScopedRenderingClient; savedObjects: { client: SavedObjectsClientContract; }; diff --git a/docs/development/core/server/kibana-plugin-server.requesthandlercontext.md b/docs/development/core/server/kibana-plugin-server.requesthandlercontext.md index d9b781e1e550ec..7c8625a5824ee8 100644 --- a/docs/development/core/server/kibana-plugin-server.requesthandlercontext.md +++ b/docs/development/core/server/kibana-plugin-server.requesthandlercontext.md @@ -6,7 +6,7 @@ Plugin specific context passed to a route handler. -Provides the following clients: - [savedObjects.client](./kibana-plugin-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [elasticsearch.dataClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.adminClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch admin client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request +Provides the following clients: - [rendering](./kibana-plugin-server.iscopedrenderingclient.md) - Rendering client which uses the data of the incoming request - [savedObjects.client](./kibana-plugin-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [elasticsearch.dataClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.adminClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch admin client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request Signature: @@ -18,5 +18,5 @@ export interface RequestHandlerContext | Property | Type | Description | | --- | --- | --- | -| [core](./kibana-plugin-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
};
elasticsearch: {
dataClient: IScopedClusterClient;
adminClient: IScopedClusterClient;
};
uiSettings: {
client: IUiSettingsClient;
};
} | | +| [core](./kibana-plugin-server.requesthandlercontext.core.md) | {
rendering: IScopedRenderingClient;
savedObjects: {
client: SavedObjectsClientContract;
};
elasticsearch: {
dataClient: IScopedClusterClient;
adminClient: IScopedClusterClient;
};
uiSettings: {
client: IUiSettingsClient;
};
} | | diff --git a/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md b/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md index 4fbcf0981f1140..23a72fc3c68b3a 100644 --- a/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md +++ b/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md @@ -1,62 +1,62 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteConfig](./kibana-plugin-server.routeconfig.md) > [validate](./kibana-plugin-server.routeconfig.validate.md) - -## RouteConfig.validate property - -A schema created with `@kbn/config-schema` that every request will be validated against. - -Signature: - -```typescript -RouteValidatorFullConfig | false; -``` - -## Remarks - -You \*must\* specify a validation schema to be able to read: - url path segments - request query - request body To opt out of validating the request, specify `validate: false`. In this case request params, query, and body will be \*\*empty\*\* objects and have no access to raw values. In some cases you may want to use another validation library. To do this, you need to instruct the `@kbn/config-schema` library to output \*\*non-validated values\*\* with setting schema as `schema.object({}, { allowUnknowns: true })`; - -## Example - - -```ts - import { schema } from '@kbn/config-schema'; - router.get({ - path: 'path/{id}', - validate: { - params: schema.object({ - id: schema.string(), - }), - query: schema.object({...}), - body: schema.object({...}), - }, -}, -(context, req, res,) { - req.params; // type Readonly<{id: string}> - console.log(req.params.id); // value -}); - -router.get({ - path: 'path/{id}', - validate: false, // handler has no access to params, query, body values. -}, -(context, req, res,) { - req.params; // type Readonly<{}>; - console.log(req.params.id); // undefined -}); - -router.get({ - path: 'path/{id}', - validate: { - // handler has access to raw non-validated params in runtime - params: schema.object({}, { allowUnknowns: true }) - }, -}, -(context, req, res,) { - req.params; // type Readonly<{}>; - console.log(req.params.id); // value - myValidationLibrary.validate({ params: req.params }); -}); - -``` - + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteConfig](./kibana-plugin-server.routeconfig.md) > [validate](./kibana-plugin-server.routeconfig.validate.md) + +## RouteConfig.validate property + +A schema created with `@kbn/config-schema` that every request will be validated against. + +Signature: + +```typescript +validate: RouteValidatorFullConfig | false; +``` + +## Remarks + +You \*must\* specify a validation schema to be able to read: - url path segments - request query - request body To opt out of validating the request, specify `validate: false`. In this case request params, query, and body will be \*\*empty\*\* objects and have no access to raw values. In some cases you may want to use another validation library. To do this, you need to instruct the `@kbn/config-schema` library to output \*\*non-validated values\*\* with setting schema as `schema.object({}, { allowUnknowns: true })`; + +## Example + + +```ts + import { schema } from '@kbn/config-schema'; + router.get({ + path: 'path/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + query: schema.object({...}), + body: schema.object({...}), + }, +}, +(context, req, res,) { + req.params; // type Readonly<{id: string}> + console.log(req.params.id); // value +}); + +router.get({ + path: 'path/{id}', + validate: false, // handler has no access to params, query, body values. +}, +(context, req, res,) { + req.params; // type Readonly<{}>; + console.log(req.params.id); // undefined +}); + +router.get({ + path: 'path/{id}', + validate: { + // handler has access to raw non-validated params in runtime + params: schema.object({}, { allowUnknowns: true }) + }, +}, +(context, req, res,) { + req.params; // type Readonly<{}>; + console.log(req.params.id); // value + myValidationLibrary.validate({ params: req.params }); +}); + +``` + diff --git a/docs/development/core/server/kibana-plugin-server.routevalidationerror._constructor_.md b/docs/development/core/server/kibana-plugin-server.routevalidationerror._constructor_.md index 31dc6ceb919952..551e13faaf1542 100644 --- a/docs/development/core/server/kibana-plugin-server.routevalidationerror._constructor_.md +++ b/docs/development/core/server/kibana-plugin-server.routevalidationerror._constructor_.md @@ -1,21 +1,21 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidationError](./kibana-plugin-server.routevalidationerror.md) > [(constructor)](./kibana-plugin-server.routevalidationerror._constructor_.md) - -## RouteValidationError.(constructor) - -Constructs a new instance of the `RouteValidationError` class - -Signature: - -```typescript -constructor(error;: Error | string, path?: string[];) -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| error | Error | string | | -| path | string[] | | - + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidationError](./kibana-plugin-server.routevalidationerror.md) > [(constructor)](./kibana-plugin-server.routevalidationerror._constructor_.md) + +## RouteValidationError.(constructor) + +Constructs a new instance of the `RouteValidationError` class + +Signature: + +```typescript +constructor(error: Error | string, path?: string[]); +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| error | Error | string | | +| path | string[] | | + diff --git a/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.badrequest.md b/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.badrequest.md index 2462ae17943bee..36ea6103fb352d 100644 --- a/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.badrequest.md +++ b/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.badrequest.md @@ -1,13 +1,13 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidationResultFactory](./kibana-plugin-server.routevalidationresultfactory.md) > [badRequest](./kibana-plugin-server.routevalidationresultfactory.badrequest.md) - -## RouteValidationResultFactory.badRequest property - -Signature: - -```typescript -(error: Error | string, path?: string[]) => { - RouteValidationError; - }; -``` + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidationResultFactory](./kibana-plugin-server.routevalidationresultfactory.md) > [badRequest](./kibana-plugin-server.routevalidationresultfactory.badrequest.md) + +## RouteValidationResultFactory.badRequest property + +Signature: + +```typescript +badRequest: (error: Error | string, path?: string[]) => { + error: RouteValidationError; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.ok.md b/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.ok.md index c86ef616de1030..eca6a31bd547f5 100644 --- a/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.ok.md +++ b/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.ok.md @@ -1,13 +1,13 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidationResultFactory](./kibana-plugin-server.routevalidationresultfactory.md) > [ok](./kibana-plugin-server.routevalidationresultfactory.ok.md) - -## RouteValidationResultFactory.ok property - -Signature: - -```typescript -(value: T) => { - T; - }; -``` + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidationResultFactory](./kibana-plugin-server.routevalidationresultfactory.md) > [ok](./kibana-plugin-server.routevalidationresultfactory.ok.md) + +## RouteValidationResultFactory.ok property + +Signature: + +```typescript +ok: (value: T) => { + value: T; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.routevalidatoroptions.unsafe.md b/docs/development/core/server/kibana-plugin-server.routevalidatoroptions.unsafe.md index b1c75e6dbdf67c..0406a372c4e9d7 100644 --- a/docs/development/core/server/kibana-plugin-server.routevalidatoroptions.unsafe.md +++ b/docs/development/core/server/kibana-plugin-server.routevalidatoroptions.unsafe.md @@ -1,18 +1,17 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidatorOptions](./kibana-plugin-server.routevalidatoroptions.md) > [unsafe](./kibana-plugin-server.routevalidatoroptions.unsafe.md) - -## RouteValidatorOptions.unsafe property - -Set the `unsafe` config to avoid running some additional internal \*safe\* validations on top of your custom validation - -Signature: - -```typescript -unsafe?: { - params?: boolean; - query?: boolean; - body?: boolean; - } - -``` + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidatorOptions](./kibana-plugin-server.routevalidatoroptions.md) > [unsafe](./kibana-plugin-server.routevalidatoroptions.unsafe.md) + +## RouteValidatorOptions.unsafe property + +Set the `unsafe` config to avoid running some additional internal \*safe\* validations on top of your custom validation + +Signature: + +```typescript +unsafe?: { + params?: boolean; + query?: boolean; + body?: boolean; + }; +``` diff --git a/docs/images/canvas-change-your-expression-chart-no-legend.png b/docs/images/canvas-change-your-expression-chart-no-legend.png new file mode 100644 index 00000000000000..f54437c1eba3ce Binary files /dev/null and b/docs/images/canvas-change-your-expression-chart-no-legend.png differ diff --git a/docs/images/canvas-change-your-expression-chart.png b/docs/images/canvas-change-your-expression-chart.png new file mode 100755 index 00000000000000..4400ce4dfb2c37 Binary files /dev/null and b/docs/images/canvas-change-your-expression-chart.png differ diff --git a/docs/images/canvas-functions-can-take-arguments-donut-chart.png b/docs/images/canvas-functions-can-take-arguments-donut-chart.png new file mode 100644 index 00000000000000..d126830c4fdc9e Binary files /dev/null and b/docs/images/canvas-functions-can-take-arguments-donut-chart.png differ diff --git a/docs/images/canvas-functions-can-take-arguments-pie-chart.png b/docs/images/canvas-functions-can-take-arguments-pie-chart.png new file mode 100644 index 00000000000000..be923675bea80b Binary files /dev/null and b/docs/images/canvas-functions-can-take-arguments-pie-chart.png differ diff --git a/docs/maps/maps-aggregations.asciidoc b/docs/maps/maps-aggregations.asciidoc index 98aa21f6a07a35..627fd49dafa514 100644 --- a/docs/maps/maps-aggregations.asciidoc +++ b/docs/maps/maps-aggregations.asciidoc @@ -47,6 +47,7 @@ To enable top hits: . Set *Entity* to the field that identifies entities in your documents. This field will be used in the terms aggregation to group your documents into entity buckets. . Set *Documents per entity* to configure the maximum number of documents accumulated per entity. +This setting is limited to the `index.max_inner_result_window` index setting, which defaults to 100. [role="screenshot"] image::maps/images/top_hits.png[] diff --git a/docs/maps/vector-layer.asciidoc b/docs/maps/vector-layer.asciidoc index 1b9d0e6556f54f..1d4ba9912529a2 100644 --- a/docs/maps/vector-layer.asciidoc +++ b/docs/maps/vector-layer.asciidoc @@ -15,7 +15,7 @@ See map.regionmap.* in <> for details. *Documents*:: Vector data from a Kibana index pattern. The index must contain at least one field mapped as {ref}/geo-point.html[geo_point] or {ref}/geo-shape.html[geo_shape]. -NOTE: Document results are limited to the first 10000 matching documents. +NOTE: Document results are limited to the `index.max_result_window` index setting, which defaults to 10000. Use <> to plot large data sets. *Grid aggregation*:: Geospatial data grouped in grids with metrics for each gridded cell. diff --git a/docs/user/canvas.asciidoc b/docs/user/canvas.asciidoc index c58635ba977696..5c5f5c2f80bf92 100644 --- a/docs/user/canvas.asciidoc +++ b/docs/user/canvas.asciidoc @@ -37,6 +37,8 @@ include::{kib-repo-dir}/canvas/canvas-present-workpad.asciidoc[] include::{kib-repo-dir}/canvas/canvas-share-workpad.asciidoc[] +include::{kib-repo-dir}/canvas/canvas-expression-lifecycle.asciidoc[] + include::{kib-repo-dir}/canvas/canvas-function-reference.asciidoc[] -include::{kib-repo-dir}/canvas/canvas-tinymath-functions.asciidoc[] +include::{kib-repo-dir}/canvas/canvas-tinymath-functions.asciidoc[] \ No newline at end of file diff --git a/package.json b/package.json index 651ffb60d7b887..a0f5dd3af14c00 100644 --- a/package.json +++ b/package.json @@ -234,7 +234,7 @@ "react-resize-detector": "^4.2.0", "react-router-dom": "^5.1.2", "react-sizeme": "^2.3.6", - "react-use": "^13.10.2", + "react-use": "^13.13.0", "reactcss": "1.2.3", "redux": "4.0.0", "redux-actions": "2.2.1", diff --git a/src/cli/cluster/cluster_manager.ts b/src/cli/cluster/cluster_manager.ts index d97f7485fb4d22..3fa4bdcbc5fa5e 100644 --- a/src/cli/cluster/cluster_manager.ts +++ b/src/cli/cluster/cluster_manager.ts @@ -26,7 +26,7 @@ import { first, mapTo, filter, map, take } from 'rxjs/operators'; import { REPO_ROOT } from '@kbn/dev-utils'; import { FSWatcher } from 'chokidar'; -import { LegacyConfig } from '../../core/server/legacy/config'; +import { LegacyConfig } from '../../core/server/legacy'; import { BasePathProxyServer } from '../../core/server/http'; // @ts-ignore diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index a2db7552246362..b2e2161c92cc8e 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -20,15 +20,13 @@ import { Subject } from 'rxjs'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; -import { ApplicationService } from './application_service'; import { ApplicationSetup, InternalApplicationStart, ApplicationStart, InternalApplicationSetup, } from './types'; - -type ApplicationServiceContract = PublicMethodsOf; +import { ApplicationServiceContract } from './test_types'; const createSetupContractMock = (): jest.Mocked => ({ register: jest.fn(), @@ -41,23 +39,27 @@ const createInternalSetupContractMock = (): jest.Mocked => ({ +const createStartContractMock = (): jest.Mocked => ({ capabilities: capabilitiesServiceMock.createStartContract().capabilities, navigateToApp: jest.fn(), getUrlForApp: jest.fn(), registerMountContext: jest.fn(), }); -const createInternalStartContractMock = (): jest.Mocked => ({ - availableApps: new Map(), - availableLegacyApps: new Map(), - capabilities: capabilitiesServiceMock.createStartContract().capabilities, - navigateToApp: jest.fn(), - getUrlForApp: jest.fn(), - registerMountContext: jest.fn(), - currentAppId$: new Subject(), - getComponent: jest.fn(), -}); +const createInternalStartContractMock = (): jest.Mocked => { + const currentAppId$ = new Subject(); + + return { + availableApps: new Map(), + availableLegacyApps: new Map(), + capabilities: capabilitiesServiceMock.createStartContract().capabilities, + currentAppId$: currentAppId$.asObservable(), + getComponent: jest.fn(), + getUrlForApp: jest.fn(), + navigateToApp: jest.fn().mockImplementation(appId => currentAppId$.next(appId)), + registerMountContext: jest.fn(), + }; +}; const createMock = (): jest.Mocked => ({ setup: jest.fn().mockReturnValue(createInternalSetupContractMock()), @@ -69,7 +71,6 @@ export const applicationServiceMock = { create: createMock, createSetupContract: createSetupContractMock, createStartContract: createStartContractMock, - createInternalSetupContract: createInternalSetupContractMock, createInternalStartContract: createInternalStartContractMock, }; diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts new file mode 100644 index 00000000000000..d064b17ace1423 --- /dev/null +++ b/src/core/public/application/application_service.test.ts @@ -0,0 +1,441 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createElement } from 'react'; +import { Subject } from 'rxjs'; +import { bufferCount, skip, takeUntil } from 'rxjs/operators'; +import { shallow } from 'enzyme'; + +import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; +import { contextServiceMock } from '../context/context_service.mock'; +import { httpServiceMock } from '../http/http_service.mock'; +import { MockCapabilitiesService, MockHistory } from './application_service.test.mocks'; +import { MockLifecycle } from './test_types'; +import { ApplicationService } from './application_service'; + +function mount() {} + +describe('#setup()', () => { + let setupDeps: MockLifecycle<'setup'>; + let startDeps: MockLifecycle<'start'>; + let service: ApplicationService; + + beforeEach(() => { + const http = httpServiceMock.createSetupContract({ basePath: '/test' }); + setupDeps = { + http, + context: contextServiceMock.createSetupContract(), + injectedMetadata: injectedMetadataServiceMock.createSetupContract(), + }; + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false); + startDeps = { http, injectedMetadata: setupDeps.injectedMetadata }; + service = new ApplicationService(); + }); + + describe('register', () => { + it('throws an error if two apps with the same id are registered', () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + expect(() => + register(Symbol(), { id: 'app1', mount } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the id \\"app1\\""` + ); + }); + + it('throws error if additional apps are registered after setup', async () => { + const { register } = service.setup(setupDeps); + + await service.start(startDeps); + expect(() => + register(Symbol(), { id: 'app1', mount } as any) + ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); + }); + + it('throws an error if an App with the same appRoute is registered', () => { + const { register, registerLegacyApp } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + + expect(() => + register(Symbol(), { id: 'app2', mount, appRoute: '/app/app1' } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the appRoute \\"/app/app1\\""` + ); + expect(() => registerLegacyApp({ id: 'app1' } as any)).not.toThrow(); + + register(Symbol(), { id: 'app-next', mount, appRoute: '/app/app3' } as any); + + expect(() => + register(Symbol(), { id: 'app2', mount, appRoute: '/app/app3' } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the appRoute \\"/app/app3\\""` + ); + expect(() => registerLegacyApp({ id: 'app3' } as any)).not.toThrow(); + }); + + it('throws an error if an App starts with the HTTP base path', () => { + const { register } = service.setup(setupDeps); + + expect(() => + register(Symbol(), { id: 'app2', mount, appRoute: '/test/app2' } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot register an application route that includes HTTP base path"` + ); + }); + }); + + describe('registerLegacyApp', () => { + it('throws an error if two apps with the same id are registered', () => { + const { registerLegacyApp } = service.setup(setupDeps); + + registerLegacyApp({ id: 'app2' } as any); + expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot( + `"A legacy application is already registered with the id \\"app2\\""` + ); + }); + + it('throws error if additional apps are registered after setup', async () => { + const { registerLegacyApp } = service.setup(setupDeps); + + await service.start(startDeps); + expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot( + `"Applications cannot be registered after \\"setup\\""` + ); + }); + + it('throws an error if a LegacyApp with the same appRoute is registered', () => { + const { register, registerLegacyApp } = service.setup(setupDeps); + + registerLegacyApp({ id: 'app1' } as any); + + expect(() => + register(Symbol(), { id: 'app2', mount, appRoute: '/app/app1' } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the appRoute \\"/app/app1\\""` + ); + expect(() => registerLegacyApp({ id: 'app1:other' } as any)).not.toThrow(); + }); + }); + + it("`registerMountContext` calls context container's registerContext", () => { + const { registerMountContext } = service.setup(setupDeps); + const container = setupDeps.context.createContextContainer.mock.results[0].value; + const pluginId = Symbol(); + + registerMountContext(pluginId, 'test' as any, mount as any); + expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', mount); + }); +}); + +describe('#start()', () => { + let setupDeps: MockLifecycle<'setup'>; + let startDeps: MockLifecycle<'start'>; + let service: ApplicationService; + + beforeEach(() => { + MockHistory.push.mockReset(); + const http = httpServiceMock.createSetupContract({ basePath: '/test' }); + setupDeps = { + http, + context: contextServiceMock.createSetupContract(), + injectedMetadata: injectedMetadataServiceMock.createSetupContract(), + }; + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false); + startDeps = { http, injectedMetadata: setupDeps.injectedMetadata }; + service = new ApplicationService(); + }); + + it('rejects if called prior to #setup()', async () => { + await expect(service.start(startDeps)).rejects.toThrowErrorMatchingInlineSnapshot( + `"ApplicationService#setup() must be invoked before start."` + ); + }); + + it('exposes available apps', async () => { + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + const { register, registerLegacyApp } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + registerLegacyApp({ id: 'app2' } as any); + + const { availableApps, availableLegacyApps } = await service.start(startDeps); + + expect(availableApps).toMatchInlineSnapshot(` + Map { + "app1" => Object { + "appRoute": "/app/app1", + "id": "app1", + "mount": [Function], + }, + } + `); + expect(availableLegacyApps).toMatchInlineSnapshot(` + Map { + "app2" => Object { + "id": "app2", + }, + } + `); + }); + + it('passes appIds to capabilities', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + register(Symbol(), { id: 'app2', mount } as any); + register(Symbol(), { id: 'app3', mount } as any); + await service.start(startDeps); + + expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ + appIds: ['app1', 'app2', 'app3'], + http: setupDeps.http, + }); + }); + + it('filters available applications based on capabilities', async () => { + MockCapabilitiesService.start.mockResolvedValueOnce({ + capabilities: { + navLinks: { + app1: true, + app2: false, + legacyApp1: true, + legacyApp2: false, + }, + }, + } as any); + + const { register, registerLegacyApp } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + registerLegacyApp({ id: 'legacyApp1' } as any); + register(Symbol(), { id: 'app2', mount } as any); + registerLegacyApp({ id: 'legacyApp2' } as any); + + const { availableApps, availableLegacyApps } = await service.start(startDeps); + + expect(availableApps).toMatchInlineSnapshot(` + Map { + "app1" => Object { + "appRoute": "/app/app1", + "id": "app1", + "mount": [Function], + }, + } + `); + expect(availableLegacyApps).toMatchInlineSnapshot(` + Map { + "legacyApp1" => Object { + "id": "legacyApp1", + }, + } + `); + }); + + describe('getComponent', () => { + it('returns renderable JSX tree', async () => { + service.setup(setupDeps); + + const { getComponent } = await service.start(startDeps); + + expect(() => shallow(createElement(getComponent))).not.toThrow(); + expect(getComponent()).toMatchInlineSnapshot(` + + `); + }); + + it('renders null when in legacy mode', async () => { + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + service.setup(setupDeps); + + const { getComponent } = await service.start(startDeps); + + expect(() => shallow(createElement(getComponent))).not.toThrow(); + expect(getComponent()).toBe(null); + }); + }); + + describe('getUrlForApp', () => { + it('creates URL for unregistered appId', async () => { + service.setup(setupDeps); + + const { getUrlForApp } = await service.start(startDeps); + + expect(getUrlForApp('app1')).toBe('/app/app1'); + }); + + it('creates URL for registered appId', async () => { + const { register, registerLegacyApp } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + registerLegacyApp({ id: 'legacyApp1' } as any); + register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + + const { getUrlForApp } = await service.start(startDeps); + + expect(getUrlForApp('app1')).toBe('/app/app1'); + expect(getUrlForApp('legacyApp1')).toBe('/app/legacyApp1'); + expect(getUrlForApp('app2')).toBe('/custom/path'); + }); + + it('creates URLs with path parameter', async () => { + service.setup(setupDeps); + + const { getUrlForApp } = await service.start(startDeps); + + expect(getUrlForApp('app1', { path: 'deep/link' })).toBe('/app/app1/deep/link'); + expect(getUrlForApp('app1', { path: '/deep//link/' })).toBe('/app/app1/deep/link'); + expect(getUrlForApp('app1', { path: '//deep/link//' })).toBe('/app/app1/deep/link'); + expect(getUrlForApp('app1', { path: 'deep/link///' })).toBe('/app/app1/deep/link'); + }); + }); + + describe('navigateToApp', () => { + it('changes the browser history to /app/:appId', async () => { + service.setup(setupDeps); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp'); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined); + + navigateToApp('myOtherApp'); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myOtherApp', undefined); + }); + + it('changes the browser history for custom appRoutes', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp'); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined); + + navigateToApp('app2'); + expect(MockHistory.push).toHaveBeenCalledWith('/custom/path', undefined); + }); + + it('appends a path if specified', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp', { path: 'deep/link/to/location/2' }); + expect(MockHistory.push).toHaveBeenCalledWith( + '/app/myTestApp/deep/link/to/location/2', + undefined + ); + + navigateToApp('app2', { path: 'deep/link/to/location/2' }); + expect(MockHistory.push).toHaveBeenCalledWith( + '/custom/path/deep/link/to/location/2', + undefined + ); + }); + + it('includes state if specified', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp', { state: 'my-state' }); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', 'my-state'); + + navigateToApp('app2', { state: 'my-state' }); + expect(MockHistory.push).toHaveBeenCalledWith('/custom/path', 'my-state'); + }); + + it('redirects when in legacyMode', async () => { + setupDeps.redirectTo = jest.fn(); + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + service.setup(setupDeps); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp'); + expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/myTestApp'); + }); + + it('updates currentApp$ after mounting', async () => { + service.setup(setupDeps); + + const { currentAppId$, navigateToApp } = await service.start(startDeps); + const stop$ = new Subject(); + const promise = currentAppId$.pipe(skip(1), bufferCount(4), takeUntil(stop$)).toPromise(); + + await navigateToApp('alpha'); + await navigateToApp('beta'); + await navigateToApp('gamma'); + await navigateToApp('delta'); + stop$.next(); + + const appIds = await promise; + + expect(appIds).toMatchInlineSnapshot(` + Array [ + "alpha", + "beta", + "gamma", + "delta", + ] + `); + }); + + it('sets window.location.href when navigating to legacy apps', async () => { + setupDeps.http = httpServiceMock.createSetupContract({ basePath: '/test' }); + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + setupDeps.redirectTo = jest.fn(); + service.setup(setupDeps); + + const { navigateToApp } = await service.start(startDeps); + + await navigateToApp('alpha'); + expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/alpha'); + }); + + it('handles legacy apps with subapps', async () => { + setupDeps.http = httpServiceMock.createSetupContract({ basePath: '/test' }); + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + setupDeps.redirectTo = jest.fn(); + + const { registerLegacyApp } = service.setup(setupDeps); + + registerLegacyApp({ id: 'baseApp:legacyApp1' } as any); + + const { navigateToApp } = await service.start(startDeps); + + await navigateToApp('baseApp:legacyApp1'); + expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/baseApp'); + }); + }); +}); diff --git a/src/core/public/application/application_service.test.tsx b/src/core/public/application/application_service.test.tsx deleted file mode 100644 index 32634572466a67..00000000000000 --- a/src/core/public/application/application_service.test.tsx +++ /dev/null @@ -1,249 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { shallow } from 'enzyme'; -import React from 'react'; - -import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; -import { MockCapabilitiesService, MockHistory } from './application_service.test.mocks'; -import { ApplicationService } from './application_service'; -import { contextServiceMock } from '../context/context_service.mock'; -import { httpServiceMock } from '../http/http_service.mock'; - -describe('#setup()', () => { - describe('register', () => { - it('throws an error if two apps with the same id are registered', () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.register(Symbol(), { id: 'app1', mount: jest.fn() } as any); - expect(() => - setup.register(Symbol(), { id: 'app1', mount: jest.fn() } as any) - ).toThrowErrorMatchingInlineSnapshot( - `"An application is already registered with the id \\"app1\\""` - ); - }); - - it('throws error if additional apps are registered after setup', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - await service.start({ http, injectedMetadata }); - expect(() => - setup.register(Symbol(), { id: 'app1' } as any) - ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); - }); - - it('logs a warning when registering a deprecated app mount', async () => { - const consoleWarnSpy = jest.spyOn(console, 'warn'); - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.register(Symbol(), { id: 'app1', mount: (ctx: any, params: any) => {} } as any); - expect(consoleWarnSpy).toHaveBeenCalledWith( - `App [app1] is using deprecated mount context. Use core.getStartServices() instead.` - ); - consoleWarnSpy.mockRestore(); - }); - }); - - describe('registerLegacyApp', () => { - it('throws an error if two apps with the same id are registered', () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.registerLegacyApp({ id: 'app2' } as any); - expect(() => - setup.registerLegacyApp({ id: 'app2' } as any) - ).toThrowErrorMatchingInlineSnapshot( - `"A legacy application is already registered with the id \\"app2\\""` - ); - }); - - it('throws error if additional apps are registered after setup', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - await service.start({ http, injectedMetadata }); - expect(() => - setup.registerLegacyApp({ id: 'app2' } as any) - ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); - }); - }); - - it("`registerMountContext` calls context container's registerContext", () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - const container = context.createContextContainer.mock.results[0].value; - const pluginId = Symbol(); - const noop = () => {}; - setup.registerMountContext(pluginId, 'test' as any, noop as any); - expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', noop); - }); -}); - -describe('#start()', () => { - beforeEach(() => { - MockHistory.push.mockReset(); - }); - - it('exposes available apps from capabilities', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.register(Symbol(), { id: 'app1', mount: jest.fn() } as any); - setup.registerLegacyApp({ id: 'app2' } as any); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - const startContract = await service.start({ http, injectedMetadata }); - - expect(startContract.availableApps).toMatchInlineSnapshot(` - Map { - "app1" => Object { - "id": "app1", - "mount": [MockFunction], - }, - } - `); - expect(startContract.availableLegacyApps).toMatchInlineSnapshot(` - Map { - "app2" => Object { - "id": "app2", - }, - } - `); - }); - - it('passes registered applications to capabilities', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - const app1 = { id: 'app1', mount: jest.fn() }; - setup.register(Symbol(), app1 as any); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - await service.start({ http, injectedMetadata }); - - expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ - apps: new Map([['app1', app1]]), - legacyApps: new Map(), - http, - }); - }); - - it('passes registered legacy applications to capabilities', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.registerLegacyApp({ id: 'legacyApp1' } as any); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - await service.start({ http, injectedMetadata }); - - expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ - apps: new Map(), - legacyApps: new Map([['legacyApp1', { id: 'legacyApp1' }]]), - http, - }); - }); - - it('returns renderable JSX tree', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(false); - const start = await service.start({ http, injectedMetadata }); - - expect(() => shallow(React.createElement(() => start.getComponent()))).not.toThrow(); - }); - - describe('navigateToApp', () => { - it('changes the browser history to /app/:appId', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(false); - const start = await service.start({ http, injectedMetadata }); - - start.navigateToApp('myTestApp'); - expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined); - start.navigateToApp('myOtherApp'); - expect(MockHistory.push).toHaveBeenCalledWith('/app/myOtherApp', undefined); - }); - - it('appends a path if specified', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(false); - const start = await service.start({ http, injectedMetadata }); - - start.navigateToApp('myTestApp', { path: 'deep/link/to/location/2' }); - expect(MockHistory.push).toHaveBeenCalledWith( - '/app/myTestApp/deep/link/to/location/2', - undefined - ); - }); - - it('includes state if specified', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(false); - const start = await service.start({ http, injectedMetadata }); - - start.navigateToApp('myTestApp', { state: 'my-state' }); - expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', 'my-state'); - }); - - it('redirects when in legacyMode', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(true); - const redirectTo = jest.fn(); - const start = await service.start({ http, injectedMetadata, redirectTo }); - start.navigateToApp('myTestApp'); - expect(redirectTo).toHaveBeenCalledWith('/app/myTestApp'); - }); - }); -}); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index df00c84028e6f4..a96b9dea9b9c71 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -17,31 +17,32 @@ * under the License. */ -import { createBrowserHistory } from 'history'; -import { BehaviorSubject } from 'rxjs'; import React from 'react'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { createBrowserHistory, History } from 'history'; -import { InjectedMetadataStart } from '../injected_metadata'; -import { CapabilitiesService } from './capabilities'; -import { AppRouter } from './ui'; -import { HttpStart } from '../http'; +import { InjectedMetadataSetup, InjectedMetadataStart } from '../injected_metadata'; +import { HttpSetup, HttpStart } from '../http'; import { ContextSetup, IContextContainer } from '../context'; +import { AppRouter } from './ui'; +import { CapabilitiesService, Capabilities } from './capabilities'; import { App, LegacyApp, AppMount, AppMountDeprecated, + AppMounter, + LegacyAppMounter, + Mounter, InternalApplicationSetup, InternalApplicationStart, } from './types'; interface SetupDeps { context: ContextSetup; -} - -interface StartDeps { - http: HttpStart; - injectedMetadata: InjectedMetadataStart; + http: HttpSetup; + injectedMetadata: InjectedMetadataSetup; /** * Only necessary for redirecting to legacy apps * @deprecated @@ -49,144 +50,158 @@ interface StartDeps { redirectTo?: (path: string) => void; } -interface AppBox { - app: App; - mount: AppMount; +interface StartDeps { + injectedMetadata: InjectedMetadataStart; + http: HttpStart; } +// Mount functions with two arguments are assumed to expect deprecated `context` object. +const isAppMountDeprecated = (mount: (...args: any[]) => any): mount is AppMountDeprecated => + mount.length === 2; +const filterAvailable = (map: Map, capabilities: Capabilities) => + new Map( + [...map].filter( + ([id]) => capabilities.navLinks[id] === undefined || capabilities.navLinks[id] === true + ) + ); +const findMounter = (mounters: Map, appRoute?: string) => + [...mounters].find(([, mounter]) => mounter.appRoute === appRoute); +const getAppUrl = (mounters: Map, appId: string, path: string = '') => + `/${mounters.get(appId)?.appRoute ?? `/app/${appId}`}/${path}` + .replace(/\/{2,}/g, '/') // Remove duplicate slashes + .replace(/\/$/, ''); // Remove trailing slash + /** * Service that is responsible for registering new applications. * @internal */ export class ApplicationService { - private readonly apps$ = new BehaviorSubject>(new Map()); - private readonly legacyApps$ = new BehaviorSubject>(new Map()); + private readonly apps = new Map(); + private readonly legacyApps = new Map(); + private readonly mounters = new Map(); private readonly capabilities = new CapabilitiesService(); + private currentAppId$ = new BehaviorSubject(undefined); + private stop$ = new Subject(); + private registrationClosed = false; + private history?: History; private mountContext?: IContextContainer; + private navigate?: (url: string, state: any) => void; - public setup({ context }: SetupDeps): InternalApplicationSetup { + public setup({ + context, + http: { basePath }, + injectedMetadata, + redirectTo = (path: string) => (window.location.href = path), + }: SetupDeps): InternalApplicationSetup { + const basename = basePath.get(); + // Only setup history if we're not in legacy mode + if (!injectedMetadata.getLegacyMode()) { + this.history = createBrowserHistory({ basename }); + } + + // If we do not have history available, use redirectTo to do a full page refresh. + this.navigate = (url, state) => + // basePath not needed here because `history` is configured with basename + this.history ? this.history.push(url, state) : redirectTo(basePath.prepend(url)); this.mountContext = context.createContextContainer(); return { - register: (plugin: symbol, app: App) => { - if (this.apps$.value.has(app.id)) { - throw new Error(`An application is already registered with the id "${app.id}"`); - } - if (this.apps$.isStopped) { + registerMountContext: this.mountContext!.registerContext, + register: (plugin, app) => { + app = { appRoute: `/app/${app.id}`, ...app }; + + if (this.registrationClosed) { throw new Error(`Applications cannot be registered after "setup"`); + } else if (this.apps.has(app.id)) { + throw new Error(`An application is already registered with the id "${app.id}"`); + } else if (findMounter(this.mounters, app.appRoute)) { + throw new Error( + `An application is already registered with the appRoute "${app.appRoute}"` + ); + } else if (basename && app.appRoute!.startsWith(basename)) { + throw new Error('Cannot register an application route that includes HTTP base path'); } - let appBox: AppBox; + let handler: AppMount; + if (isAppMountDeprecated(app.mount)) { + handler = this.mountContext!.createHandler(plugin, app.mount); // eslint-disable-next-line no-console console.warn( `App [${app.id}] is using deprecated mount context. Use core.getStartServices() instead.` ); - - appBox = { - app, - mount: this.mountContext!.createHandler(plugin, app.mount), - }; } else { - appBox = { app, mount: app.mount }; + handler = app.mount; } - this.apps$.next(new Map([...this.apps$.value.entries(), [app.id, appBox]])); + const mount: AppMounter = async params => { + const unmount = await handler(params); + this.currentAppId$.next(app.id); + return unmount; + }; + this.apps.set(app.id, app); + this.mounters.set(app.id, { + appRoute: app.appRoute!, + appBasePath: basePath.prepend(app.appRoute!), + mount, + unmountBeforeMounting: false, + }); }, - registerLegacyApp: (app: LegacyApp) => { - if (this.legacyApps$.value.has(app.id)) { + registerLegacyApp: app => { + const appRoute = `/app/${app.id.split(':')[0]}`; + + if (this.registrationClosed) { + throw new Error('Applications cannot be registered after "setup"'); + } else if (this.legacyApps.has(app.id)) { throw new Error(`A legacy application is already registered with the id "${app.id}"`); - } - if (this.legacyApps$.isStopped) { - throw new Error(`Applications cannot be registered after "setup"`); + } else if (basename && appRoute!.startsWith(basename)) { + throw new Error('Cannot register an application route that includes HTTP base path'); } - this.legacyApps$.next(new Map([...this.legacyApps$.value.entries(), [app.id, app]])); + const appBasePath = basePath.prepend(appRoute); + const mount: LegacyAppMounter = () => redirectTo(appBasePath); + this.legacyApps.set(app.id, app); + this.mounters.set(app.id, { + appRoute, + appBasePath, + mount, + unmountBeforeMounting: true, + }); }, - registerMountContext: this.mountContext!.registerContext, }; } - public async start({ - http, - injectedMetadata, - redirectTo = (path: string) => (window.location.href = path), - }: StartDeps): Promise { + public async start({ injectedMetadata, http }: StartDeps): Promise { if (!this.mountContext) { - throw new Error(`ApplicationService#setup() must be invoked before start.`); + throw new Error('ApplicationService#setup() must be invoked before start.'); } - // Disable registration of new applications - this.apps$.complete(); - this.legacyApps$.complete(); - - const legacyMode = injectedMetadata.getLegacyMode(); - const currentAppId$ = new BehaviorSubject(undefined); - const { availableApps, availableLegacyApps, capabilities } = await this.capabilities.start({ + this.registrationClosed = true; + const { capabilities } = await this.capabilities.start({ + appIds: [...this.mounters.keys()], http, - apps: new Map([...this.apps$.value].map(([id, { app }]) => [id, app])), - legacyApps: this.legacyApps$.value, }); - - // Only setup history if we're not in legacy mode - const history = legacyMode ? null : createBrowserHistory({ basename: http.basePath.get() }); + const availableMounters = filterAvailable(this.mounters, capabilities); return { - availableApps, - availableLegacyApps, + availableApps: filterAvailable(this.apps, capabilities), + availableLegacyApps: filterAvailable(this.legacyApps, capabilities), capabilities, + currentAppId$: this.currentAppId$.pipe(takeUntil(this.stop$)), registerMountContext: this.mountContext.registerContext, - currentAppId$, - - getUrlForApp: (appId, options: { path?: string } = {}) => { - return http.basePath.prepend(appPath(appId, options)); - }, - + getUrlForApp: (appId, { path }: { path?: string } = {}) => + getAppUrl(availableMounters, appId, path), navigateToApp: (appId, { path, state }: { path?: string; state?: any } = {}) => { - if (legacyMode) { - // If we're in legacy mode, do a full page refresh to load the NP app. - redirectTo(http.basePath.prepend(appPath(appId, { path }))); - } else { - // basePath not needed here because `history` is configured with basename - history!.push(appPath(appId, { path }), state); - } - }, - - getComponent: () => { - if (legacyMode) { - return null; - } - - // Filter only available apps and map to just the mount function. - const appMounts = new Map( - [...this.apps$.value] - .filter(([id]) => availableApps.has(id)) - .map(([id, { mount }]) => [id, mount]) - ); - - return ( - - ); + this.navigate!(getAppUrl(availableMounters, appId, path), state); + this.currentAppId$.next(appId); }, + getComponent: () => + this.history ? : null, }; } - public stop() {} -} - -const appPath = (appId: string, { path }: { path?: string } = {}): string => - path - ? `/app/${appId}/${path.replace(/^\//, '')}` // Remove preceding slash from path if present - : `/app/${appId}`; - -function isAppMountDeprecated(mount: (...args: any[]) => any): mount is AppMountDeprecated { - // Mount functions with two arguments are assumed to expect deprecated `context` object. - return mount.length === 2; + public stop() { + this.stop$.next(); + this.currentAppId$.complete(); + } } diff --git a/src/core/public/application/capabilities/capabilities_service.mock.ts b/src/core/public/application/capabilities/capabilities_service.mock.ts index 29c3275f0e3b27..54aaa31e08859e 100644 --- a/src/core/public/application/capabilities/capabilities_service.mock.ts +++ b/src/core/public/application/capabilities/capabilities_service.mock.ts @@ -17,15 +17,9 @@ * under the License. */ import { CapabilitiesService, CapabilitiesStart } from './capabilities_service'; -import { deepFreeze } from '../../../utils/'; -import { App, LegacyApp } from '../types'; +import { deepFreeze } from '../../../utils'; -const createStartContractMock = ( - apps: ReadonlyMap = new Map(), - legacyApps: ReadonlyMap = new Map() -): jest.Mocked => ({ - availableApps: apps, - availableLegacyApps: legacyApps, +const createStartContractMock = (): jest.Mocked => ({ capabilities: deepFreeze({ catalogue: {}, management: {}, @@ -33,11 +27,8 @@ const createStartContractMock = ( }), }); -type CapabilitiesServiceContract = PublicMethodsOf; -const createMock = (): jest.Mocked => ({ - start: jest - .fn() - .mockImplementation(({ apps, legacyApps }) => createStartContractMock(apps, legacyApps)), +const createMock = (): jest.Mocked> => ({ + start: jest.fn().mockImplementation(createStartContractMock), }); export const capabilitiesServiceMock = { diff --git a/src/core/public/application/capabilities/capabilities_service.test.ts b/src/core/public/application/capabilities/capabilities_service.test.ts index 3245be8dd502d9..dfbb449b4d58e6 100644 --- a/src/core/public/application/capabilities/capabilities_service.test.ts +++ b/src/core/public/application/capabilities/capabilities_service.test.ts @@ -19,7 +19,6 @@ import { httpServiceMock, HttpSetupMock } from '../../http/http_service.mock'; import { CapabilitiesService } from './capabilities_service'; -import { LegacyApp, App } from '../types'; const mockedCapabilities = { catalogue: {}, @@ -42,36 +41,22 @@ describe('#start', () => { http.post.mockReturnValue(Promise.resolve(mockedCapabilities)); }); - const apps = new Map([ - ['app1', { id: 'app1' }], - ['app2', { id: 'app2', capabilities: { app2: { feature: true } } }], - ['appMissingInCapabilities', { id: 'appMissingInCapabilities' }], - ] as Array<[string, App]>); - const legacyApps = new Map([ - ['legacyApp1', { id: 'legacyApp1' }], - ['legacyApp2', { id: 'legacyApp2', capabilities: { app2: { feature: true } } }], - ] as Array<[string, LegacyApp]>); - - it('filters available apps based on returned navLinks', async () => { + it('only returns capabilities for given appIds', async () => { const service = new CapabilitiesService(); - const startContract = await service.start({ apps, legacyApps, http }); - expect(startContract.availableApps).toEqual( - new Map([ - ['app1', { id: 'app1' }], - ['appMissingInCapabilities', { id: 'appMissingInCapabilities' }], - ]) - ); - expect(startContract.availableLegacyApps).toEqual( - new Map([['legacyApp1', { id: 'legacyApp1' }]]) - ); + const { capabilities } = await service.start({ + http, + appIds: ['app1', 'app2', 'legacyApp1', 'legacyApp2'], + }); + + // @ts-ignore TypeScript knows this shouldn't be possible + expect(() => (capabilities.foo = 'foo')).toThrowError(); }); it('does not allow Capabilities to be modified', async () => { const service = new CapabilitiesService(); const { capabilities } = await service.start({ - apps, - legacyApps, http, + appIds: ['app1', 'app2', 'legacyApp1', 'legacyApp2'], }); // @ts-ignore TypeScript knows this shouldn't be possible diff --git a/src/core/public/application/capabilities/capabilities_service.tsx b/src/core/public/application/capabilities/capabilities_service.tsx index 24d9765953c443..05d718e1073dfa 100644 --- a/src/core/public/application/capabilities/capabilities_service.tsx +++ b/src/core/public/application/capabilities/capabilities_service.tsx @@ -19,22 +19,16 @@ import { Capabilities } from '../../../types/capabilities'; import { deepFreeze, RecursiveReadonly } from '../../../utils'; -import { LegacyApp, App } from '../types'; import { HttpStart } from '../../http'; interface StartDeps { - apps: ReadonlyMap; - legacyApps: ReadonlyMap; + appIds: string[]; http: HttpStart; } -export { Capabilities }; - /** @internal */ export interface CapabilitiesStart { capabilities: RecursiveReadonly; - availableApps: ReadonlyMap; - availableLegacyApps: ReadonlyMap; } /** @@ -42,41 +36,14 @@ export interface CapabilitiesStart { * @internal */ export class CapabilitiesService { - public async start({ apps, legacyApps, http }: StartDeps): Promise { - const capabilities = await this.fetchCapabilities(http, [...apps.keys(), ...legacyApps.keys()]); - - const availableApps = new Map( - [...apps].filter( - ([appId]) => - capabilities.navLinks[appId] === undefined || capabilities.navLinks[appId] === true - ) - ); - - const availableLegacyApps = new Map( - [...legacyApps].filter( - ([appId]) => - capabilities.navLinks[appId] === undefined || capabilities.navLinks[appId] === true - ) - ); + public async start({ appIds, http }: StartDeps): Promise { + const route = http.anonymousPaths.isAnonymous(window.location.pathname) ? '/defaults' : ''; + const capabilities = await http.post(`/api/core/capabilities${route}`, { + body: JSON.stringify({ applications: appIds }), + }); return { - availableApps, - availableLegacyApps, - capabilities, + capabilities: deepFreeze(capabilities), }; } - - private async fetchCapabilities(http: HttpStart, appIds: string[]): Promise { - const payload = JSON.stringify({ - applications: appIds, - }); - - const url = http.anonymousPaths.isAnonymous(window.location.pathname) - ? '/api/core/capabilities/defaults' - : '/api/core/capabilities'; - const capabilities = await http.post(url, { - body: payload, - }); - return deepFreeze(capabilities); - } } diff --git a/src/core/public/application/capabilities/index.ts b/src/core/public/application/capabilities/index.ts index 9d8bec955eb979..e4112a55ef6bd2 100644 --- a/src/core/public/application/capabilities/index.ts +++ b/src/core/public/application/capabilities/index.ts @@ -17,4 +17,5 @@ * under the License. */ -export { Capabilities, CapabilitiesService } from './capabilities_service'; +export { Capabilities } from '../../../types/capabilities'; +export { CapabilitiesService } from './capabilities_service'; diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index 81aef5204c7e29..10544c348afb03 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -18,107 +18,161 @@ */ import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; -import { createMemoryHistory, History } from 'history'; -import { BehaviorSubject } from 'rxjs'; +import { createMemoryHistory, History, createHashHistory } from 'history'; -import { I18nProvider } from '@kbn/i18n/react'; - -import { AppMount, LegacyApp, AppMountParameters } from '../types'; -import { httpServiceMock } from '../../http/http_service.mock'; import { AppRouter, AppNotFound } from '../ui'; - -const createMountHandler = (htmlString: string) => - jest.fn(async ({ appBasePath: basename, element: el }: AppMountParameters) => { - el.innerHTML = `
\nbasename: ${basename}\nhtml: ${htmlString}\n
`; - return jest.fn(() => (el.innerHTML = '')); - }); +import { EitherApp, MockedMounterMap, MockedMounterTuple } from '../test_types'; +import { createRenderer, createAppMounter, createLegacyAppMounter } from './utils'; describe('AppContainer', () => { - let apps: Map, Parameters>>; - let legacyApps: Map; + let mounters: MockedMounterMap; let history: History; - let router: ReactWrapper; - let redirectTo: jest.Mock; - let currentAppId$: BehaviorSubject; + let update: ReturnType; - const navigate = async (path: string) => { + const navigate = (path: string) => { history.push(path); - router.update(); - // flushes any pending promises - return new Promise(resolve => setImmediate(resolve)); + return update(); }; + const mockMountersToMounters = () => + new Map([...mounters].map(([appId, { mounter }]) => [appId, mounter])); + beforeEach(() => { - redirectTo = jest.fn(); - apps = new Map([ - ['app1', createMountHandler('App 1')], - ['app2', createMountHandler('
App 2
')], - ]); - legacyApps = new Map([ - ['legacyApp1', { id: 'legacyApp1' }], - ['baseApp:legacyApp2', { id: 'baseApp:legacyApp2' }], - ]) as Map; + mounters = new Map([ + createAppMounter('app1', 'App 1'), + createLegacyAppMounter('legacyApp1', jest.fn()), + createAppMounter('app2', '
App 2
'), + createLegacyAppMounter('baseApp:legacyApp2', jest.fn()), + createAppMounter('app3', '
App 3
', '/custom/path'), + ] as Array>); history = createMemoryHistory(); - currentAppId$ = new BehaviorSubject(undefined); - // Use 'asdf' as the basepath - const http = httpServiceMock.createStartContract({ basePath: '/asdf' }); - router = mount( - - - - ); + update = createRenderer(); }); - it('calls mountHandler and returned unmount function when navigating between apps', async () => { - await navigate('/app/app1'); - expect(apps.get('app1')!).toHaveBeenCalled(); - expect(router.html()).toMatchInlineSnapshot(` + it('calls mount handler and returned unmount function when navigating between apps', async () => { + const dom1 = await navigate('/app/app1'); + const app1 = mounters.get('app1')!; + + expect(app1.mounter.mount).toHaveBeenCalled(); + expect(dom1?.html()).toMatchInlineSnapshot(` "
- basename: /asdf/app/app1 + basename: /app/app1 html: App 1
" `); - const app1Unmount = await apps.get('app1')!.mock.results[0].value; - await navigate('/app/app2'); - expect(app1Unmount).toHaveBeenCalled(); + const app1Unmount = await app1.mounter.mount.mock.results[0].value; + const dom2 = await navigate('/app/app2'); - expect(apps.get('app2')!).toHaveBeenCalled(); - expect(router.html()).toMatchInlineSnapshot(` + expect(app1Unmount).toHaveBeenCalled(); + expect(mounters.get('app2')!.mounter.mount).toHaveBeenCalled(); + expect(dom2?.html()).toMatchInlineSnapshot(` "
- basename: /asdf/app/app2 + basename: /app/app2 html:
App 2
" `); }); - it('updates currentApp$ after mounting', async () => { - await navigate('/app/app1'); - expect(currentAppId$.value).toEqual('app1'); - await navigate('/app/app2'); - expect(currentAppId$.value).toEqual('app2'); + it('should not mount when partial route path matches', async () => { + mounters.set(...createAppMounter('spaces', '
Custom Space
', '/spaces/fake-login')); + mounters.set(...createAppMounter('login', '
Login Page
', '/fake-login')); + history = createMemoryHistory(); + update = createRenderer(); + + await navigate('/fake-login'); + + expect(mounters.get('spaces')!.mounter.mount).not.toHaveBeenCalled(); + expect(mounters.get('login')!.mounter.mount).toHaveBeenCalled(); + }); + + it('should not mount when partial route path has higher specificity', async () => { + mounters.set(...createAppMounter('login', '
Login Page
', '/fake-login')); + mounters.set(...createAppMounter('spaces', '
Custom Space
', '/spaces/fake-login')); + history = createMemoryHistory(); + update = createRenderer(); + + await navigate('/spaces/fake-login'); + + expect(mounters.get('spaces')!.mounter.mount).toHaveBeenCalled(); + expect(mounters.get('login')!.mounter.mount).not.toHaveBeenCalled(); }); - it('sets window.location.href when navigating to legacy apps', async () => { + it('should not remount when changing pages within app', async () => { + const { mounter, unmount } = mounters.get('app1')!; + await navigate('/app/app1/page1'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + + // Navigating to page within app does not trigger re-render + await navigate('/app/app1/page2'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + expect(unmount).not.toHaveBeenCalled(); + }); + + it('should not remount when going back within app', async () => { + const { mounter, unmount } = mounters.get('app1')!; + await navigate('/app/app1/page1'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + + // Hitting back button within app does not trigger re-render + await navigate('/app/app1/page2'); + history.goBack(); + await update(); + expect(mounter.mount).toHaveBeenCalledTimes(1); + expect(unmount).not.toHaveBeenCalled(); + }); + + it('should not remount when when changing pages within app using hash history', async () => { + history = createHashHistory(); + update = createRenderer(); + + const { mounter, unmount } = mounters.get('app1')!; + await navigate('/app/app1/page1'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + + // Changing hash history does not trigger re-render + await navigate('/app/app1/page2'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + expect(unmount).not.toHaveBeenCalled(); + }); + + it('should unmount when changing between apps', async () => { + const { mounter, unmount } = mounters.get('app1')!; + await navigate('/app/app1/page1'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + + // Navigating to other app triggers unmount + await navigate('/app/app2/page1'); + expect(unmount).toHaveBeenCalledTimes(1); + }); + + it('calls legacy mount handler', async () => { await navigate('/app/legacyApp1'); - expect(redirectTo).toHaveBeenCalledWith('/asdf/app/legacyApp1'); + expect(mounters.get('legacyApp1')!.mounter.mount.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "appBasePath": "/app/legacyApp1", + "element":
, + }, + ] + `); }); it('handles legacy apps with subapps', async () => { await navigate('/app/baseApp'); - expect(redirectTo).toHaveBeenCalledWith('/asdf/app/baseApp'); + expect(mounters.get('baseApp:legacyApp2')!.mounter.mount.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "appBasePath": "/app/baseApp", + "element":
, + }, + ] + `); }); it('displays error page if no app is found', async () => { - await navigate('/app/unknown'); - expect(router.exists(AppNotFound)).toBe(true); + const dom = await navigate('/app/unknown'); + + expect(dom?.exists(AppNotFound)).toBe(true); }); }); diff --git a/src/core/public/application/integration_tests/utils.tsx b/src/core/public/application/integration_tests/utils.tsx new file mode 100644 index 00000000000000..6367d1fa12697e --- /dev/null +++ b/src/core/public/application/integration_tests/utils.tsx @@ -0,0 +1,82 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { ReactElement } from 'react'; +import { mount } from 'enzyme'; + +import { I18nProvider } from '@kbn/i18n/react'; + +import { App, LegacyApp, AppMountParameters } from '../types'; +import { MockedMounter, MockedMounterTuple } from '../test_types'; + +type Dom = ReturnType | null; +type Renderer = () => Dom | Promise; + +export const createRenderer = (element: ReactElement | null): Renderer => { + const dom: Dom = element && mount({element}); + + return () => + new Promise(async resolve => { + if (dom) { + dom.update(); + } + setImmediate(() => resolve(dom)); // flushes any pending promises + }); +}; + +export const createAppMounter = ( + appId: string, + html: string, + appRoute = `/app/${appId}` +): MockedMounterTuple => { + const unmount = jest.fn(); + return [ + appId, + { + mounter: { + appRoute, + appBasePath: appRoute, + mount: jest.fn(async ({ appBasePath: basename, element }: AppMountParameters) => { + Object.assign(element, { + innerHTML: `
\nbasename: ${basename}\nhtml: ${html}\n
`, + }); + unmount.mockImplementation(() => Object.assign(element, { innerHTML: '' })); + return unmount; + }), + }, + unmount, + }, + ]; +}; + +export const createLegacyAppMounter = ( + appId: string, + legacyMount: MockedMounter['mount'] +): MockedMounterTuple => [ + appId, + { + mounter: { + appRoute: `/app/${appId.split(':')[0]}`, + appBasePath: `/app/${appId.split(':')[0]}`, + unmountBeforeMounting: true, + mount: legacyMount, + }, + unmount: jest.fn(), + }, +]; diff --git a/src/core/public/application/test_types.ts b/src/core/public/application/test_types.ts new file mode 100644 index 00000000000000..3d992cb950eb42 --- /dev/null +++ b/src/core/public/application/test_types.ts @@ -0,0 +1,45 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { App, LegacyApp, Mounter, AppUnmount } from './types'; +import { ApplicationService } from './application_service'; + +/** @internal */ +export type ApplicationServiceContract = PublicMethodsOf; +/** @internal */ +export type EitherApp = App | LegacyApp; +/** @internal */ +export type MockedUnmount = jest.Mocked; +/** @internal */ +export type MockedMounter = jest.Mocked>>; +/** @internal */ +export type MockedMounterTuple = [ + string, + { mounter: MockedMounter; unmount: MockedUnmount } +]; +/** @internal */ +export type MockedMounterMap = Map< + string, + { mounter: MockedMounter; unmount: MockedUnmount } +>; +/** @internal */ +export type MockLifecycle< + T extends keyof ApplicationService, + U = Parameters[0] +> = { [P in keyof U]: jest.Mocked }; diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index fd009066fc6640..c026851af7eb80 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Observable, Subject } from 'rxjs'; +import { Observable } from 'rxjs'; import { Capabilities } from './capabilities'; import { ChromeStart } from '../chrome'; @@ -89,6 +89,13 @@ export interface App extends AppBase { * Takes precedence over chrome service visibility settings. */ chromeless?: boolean; + + /** + * Override the application's routing path from `/app/${id}`. + * Must be unique across registered applications. Should not include the + * base path from HTTP. + */ + appRoute?: string; } /** @internal */ @@ -177,7 +184,8 @@ export interface AppMountParameters { element: HTMLElement; /** - * The base path for configuring the application's router. + * The route path for configuring navigation to the application. + * This string should not include the base path from HTTP. * * @example * @@ -189,6 +197,7 @@ export interface AppMountParameters { * setup({ application }) { * application.register({ * id: 'my-app', + * appRoute: '/my-app', * async mount(params) { * const { renderApp } = await import('./application'); * return renderApp(params); @@ -229,6 +238,23 @@ export interface AppMountParameters { */ export type AppUnmount = () => void; +/** @internal */ +export type AppMounter = (params: AppMountParameters) => Promise; + +/** @internal */ +export type LegacyAppMounter = (params: AppMountParameters) => void; + +/** @internal */ +export type Mounter = SelectivePartial< + { + appRoute: string; + appBasePath: string; + mount: T extends LegacyApp ? LegacyAppMounter : AppMounter; + unmountBeforeMounting: T extends LegacyApp ? true : boolean; + }, + T extends LegacyApp ? never : 'unmountBeforeMounting' +>; + /** @public */ export interface ApplicationSetup { /** @@ -352,6 +378,12 @@ export interface InternalApplicationStart ): void; // Internal APIs - currentAppId$: Subject; + currentAppId$: Observable; getComponent(): JSX.Element | null; } + +/** @internal */ +type SelectivePartial = Partial> & + Required>> extends infer U + ? { [P in keyof U]: U[P] } + : never; diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index 9c2bb30e795032..153582e805fa1b 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -17,95 +17,60 @@ * under the License. */ -import React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import { Subject } from 'rxjs'; - -import { LegacyApp, AppMount, AppUnmount } from '../types'; -import { HttpStart } from '../../http'; +import React, { + Fragment, + FunctionComponent, + useLayoutEffect, + useRef, + useState, + MutableRefObject, +} from 'react'; + +import { AppUnmount, Mounter } from '../types'; import { AppNotFound } from './app_not_found_screen'; -interface Props extends RouteComponentProps<{ appId: string }> { - apps: ReadonlyMap; - legacyApps: ReadonlyMap; - basePath: HttpStart['basePath']; - currentAppId$: Subject; - /** - * Only necessary for redirecting to legacy apps - * @deprecated - */ - redirectTo: (path: string) => void; -} - -interface State { - appNotFound: boolean; -} - -export class AppContainer extends React.Component { - private readonly containerDiv = React.createRef(); - private unmountFunc?: AppUnmount; - - state: State = { appNotFound: false }; - - componentDidMount() { - this.mountApp(); - } - - componentWillUnmount() { - this.unmountApp(); - } - - componentDidUpdate(prevProps: Props) { - if (prevProps.match.params.appId !== this.props.match.params.appId) { - this.unmountApp(); - this.mountApp(); - } - } - - async mountApp() { - const { apps, legacyApps, match, basePath, currentAppId$, redirectTo } = this.props; - const { appId } = match.params; - - const mount = apps.get(appId); - if (mount) { - this.unmountFunc = await mount({ - appBasePath: basePath.prepend(`/app/${appId}`), - element: this.containerDiv.current!, - }); - currentAppId$.next(appId); - this.setState({ appNotFound: false }); - return; - } - - const legacyApp = findLegacyApp(appId, legacyApps); - if (legacyApp) { - this.unmountApp(); - redirectTo(basePath.prepend(`/app/${appId}`)); - this.setState({ appNotFound: false }); - return; - } - - this.setState({ appNotFound: true }); - } - - async unmountApp() { - if (this.unmountFunc) { - this.unmountFunc(); - this.unmountFunc = undefined; - } - } - - render() { - return ( - - {this.state.appNotFound && } -
- - ); - } +interface Props { + appId: string; + mounter?: Mounter; } -function findLegacyApp(appId: string, apps: ReadonlyMap) { - const matchingApps = [...apps.entries()].filter(([id]) => id.split(':')[0] === appId); - return matchingApps.length ? matchingApps[0][1] : null; -} +export const AppContainer: FunctionComponent = ({ mounter, appId }: Props) => { + const [appNotFound, setAppNotFound] = useState(false); + const elementRef = useRef(null); + const unmountRef: MutableRefObject = useRef(null); + + useLayoutEffect(() => { + const unmount = () => { + if (unmountRef.current) { + unmountRef.current(); + unmountRef.current = null; + } + }; + const mount = async () => { + if (!mounter) { + return setAppNotFound(true); + } + + if (mounter.unmountBeforeMounting) { + unmount(); + } + + unmountRef.current = + (await mounter.mount({ + appBasePath: mounter.appBasePath, + element: elementRef.current!, + })) || null; + setAppNotFound(false); + }; + + mount(); + return unmount; + }, [mounter]); + + return ( + + {appNotFound && } +
+ + ); +}; diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index 67701a33dabf41..8db46f97942779 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -17,37 +17,53 @@ * under the License. */ +import React, { FunctionComponent } from 'react'; import { History } from 'history'; -import React from 'react'; -import { Router, Route } from 'react-router-dom'; -import { Subject } from 'rxjs'; +import { Router, Route, RouteComponentProps, Switch } from 'react-router-dom'; -import { LegacyApp, AppMount } from '../types'; +import { Mounter } from '../types'; import { AppContainer } from './app_container'; -import { HttpStart } from '../../http'; interface Props { - apps: ReadonlyMap; - legacyApps: ReadonlyMap; - basePath: HttpStart['basePath']; - currentAppId$: Subject; + mounters: Map; history: History; - /** - * Only necessary for redirecting to legacy apps - * @deprecated - */ - redirectTo?: (path: string) => void; } -export const AppRouter: React.FunctionComponent = ({ - history, - redirectTo = (path: string) => (window.location.href = path), - ...otherProps -}) => ( +interface Params { + appId: string; +} + +export const AppRouter: FunctionComponent = ({ history, mounters }) => ( - } - /> + + {[...mounters].flatMap(([appId, mounter]) => + // Remove /app paths from the routes as they will be handled by the + // "named" route parameter `:appId` below + mounter.appBasePath.startsWith('/app') + ? [] + : [ + } + />, + ] + )} + ) => { + // Find the mounter including legacy mounters with subapps: + const [id, mounter] = mounters.has(appId) + ? [appId, mounters.get(appId)] + : [...mounters].filter(([key]) => key.split(':')[0] === appId)[0] ?? []; + + return ; + }} + /> + ); diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index 96567394216869..d9c35b20db03bb 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -211,14 +211,14 @@ describe('start', () => { new FakeApp('beta', true), new FakeApp('gamma', false), ]); - const { availableApps, currentAppId$ } = startDeps.application; + const { availableApps, navigateToApp } = startDeps.application; const { chrome, service } = await start({ startDeps }); const promise = chrome .getIsVisible$() .pipe(toArray()) .toPromise(); - [...availableApps.keys()].forEach(appId => currentAppId$.next(appId)); + [...availableApps.keys()].forEach(appId => navigateToApp(appId)); service.stop(); await expect(promise).resolves.toMatchInlineSnapshot(` @@ -233,14 +233,14 @@ describe('start', () => { it('changing visibility has no effect on chrome-hiding application', async () => { const startDeps = defaultStartDeps([new FakeApp('alpha', true)]); - const { currentAppId$ } = startDeps.application; + const { navigateToApp } = startDeps.application; const { chrome, service } = await start({ startDeps }); const promise = chrome .getIsVisible$() .pipe(toArray()) .toPromise(); - currentAppId$.next('alpha'); + navigateToApp('alpha'); chrome.setIsVisible(true); service.stop(); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 25c00836a4db7a..18c0c9870d72fd 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -127,7 +127,7 @@ export class ChromeService { ) ); this.isVisible$ = combineLatest(this.appHidden$, this.toggleHidden$).pipe( - map(([appHidden, chromeHidden]) => !(appHidden || chromeHidden)), + map(([appHidden, toggleHidden]) => !(appHidden || toggleHidden)), takeUntil(this.stop$) ); } diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 2a9dca96062dc9..485c11aae6508c 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -174,7 +174,7 @@ export class CoreSystem { [this.legacy.legacyId, [...pluginDependencies.keys()]], ]), }); - const application = this.application.setup({ context }); + const application = this.application.setup({ context, http, injectedMetadata }); const core: InternalCoreSetup = { application, @@ -307,6 +307,7 @@ export class CoreSystem { this.uiSettings.stop(); this.chrome.stop(); this.i18n.stop(); + this.application.stop(); this.rootDomElement.textContent = ''; } } diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index 6a44000bf617e6..0bde1b68e1876a 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -80,9 +80,6 @@ export interface InjectedMetadataParams { user?: Record; }; }; - apm: { - [key: string]: unknown; - }; }; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index dfbb6b4a6fbf54..f61741571dc1dc 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -18,6 +18,7 @@ import { UserProvidedValues as UserProvidedValues_2 } from 'src/core/server/type // @public export interface App extends AppBase { + appRoute?: string; chromeless?: boolean; mount: AppMount | AppMountDeprecated; } diff --git a/src/legacy/ui/ui_nav_links/ui_nav_links_mixin.js b/src/core/server/config/config.mock.ts similarity index 61% rename from src/legacy/ui/ui_nav_links/ui_nav_links_mixin.js rename to src/core/server/config/config.mock.ts index e445f5e9126d4b..e098fa142b9d13 100644 --- a/src/legacy/ui/ui_nav_links/ui_nav_links_mixin.js +++ b/src/core/server/config/config.mock.ts @@ -17,18 +17,18 @@ * under the License. */ -import { UiNavLink } from './ui_nav_link'; +import { Config } from './config'; -export function uiNavLinksMixin(kbnServer, server) { - const uiApps = server.getAllUiApps(); +type ConfigMock = jest.Mocked; - const { navLinkSpecs = [] } = kbnServer.uiExports; +const createConfigMock = (): ConfigMock => ({ + has: jest.fn(), + get: jest.fn(), + set: jest.fn(), + getFlattenedPaths: jest.fn(), + toRaw: jest.fn(), +}); - const fromSpecs = navLinkSpecs.map(navLinkSpec => new UiNavLink(navLinkSpec)); - - const fromApps = uiApps.map(app => app.getNavLink()).filter(Boolean); - - const uiNavLinks = fromSpecs.concat(fromApps).sort((a, b) => a.getOrder() - b.getOrder()); - - server.decorate('server', 'getUiNavLinks', () => uiNavLinks.slice(0)); -} +export const configMock = { + create: createConfigMock, +}; diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 1668b409050b74..700ae04f00d47a 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -20,6 +20,7 @@ import { Server } from 'hapi'; import { CspConfig } from '../csp'; import { mockRouter } from './router/router.mock'; +import { configMock } from '../config/config.mock'; import { InternalHttpServiceSetup } from './types'; import { HttpService } from './http_service'; import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; @@ -28,13 +29,14 @@ import { sessionStorageMock } from './cookie_session_storage.mocks'; import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; +type BasePathMocked = jest.Mocked; export type HttpServiceSetupMock = jest.Mocked & { - basePath: jest.Mocked; + basePath: BasePathMocked; }; -const createBasePathMock = (): jest.Mocked => ({ - serverBasePath: '/mock-server-basepath', - get: jest.fn(), +const createBasePathMock = (serverBasePath = '/mock-server-basepath'): BasePathMocked => ({ + serverBasePath, + get: jest.fn().mockReturnValue(serverBasePath), set: jest.fn(), prepend: jest.fn(), remove: jest.fn(), @@ -44,9 +46,12 @@ const createSetupContractMock = () => { const setupContract: HttpServiceSetupMock = { // we can mock other hapi server methods when we need it server: ({ + name: 'http-server-test', + version: 'kibana', route: jest.fn(), start: jest.fn(), stop: jest.fn(), + config: jest.fn().mockReturnValue(configMock.create()), } as unknown) as jest.MockedClass, createCookieSessionStorageFactory: jest.fn(), registerOnPreAuth: jest.fn(), diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 878f854f2a5174..953fa0738597c0 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -41,6 +41,7 @@ import { ElasticsearchServiceSetup, IScopedClusterClient } from './elasticsearch'; import { HttpServiceSetup } from './http'; +import { IScopedRenderingClient } from './rendering'; import { PluginsServiceSetup, PluginsServiceStart, PluginOpaqueId } from './plugins'; import { ContextSetup } from './context'; import { IUiSettingsClient, UiSettingsServiceSetup, UiSettingsServiceStart } from './ui_settings'; @@ -149,6 +150,7 @@ export { SessionCookieValidationResult, SessionStorageFactory, } from './http'; +export { RenderingServiceSetup, IRenderOptions, LegacyRenderOptions } from './rendering'; export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging'; export { @@ -229,12 +231,21 @@ export { SavedObjectsMigrationVersion, } from './types'; -export { LegacyServiceSetupDeps, LegacyServiceStartDeps } from './legacy'; +export { + LegacyServiceSetupDeps, + LegacyServiceStartDeps, + LegacyServiceDiscoverPlugins, + LegacyConfig, + LegacyUiExports, + LegacyInternals, +} from './legacy'; /** * Plugin specific context passed to a route handler. * * Provides the following clients: + * - {@link IScopedRenderingClient | rendering} - Rendering client + * which uses the data of the incoming request * - {@link SavedObjectsClient | savedObjects.client} - Saved Objects client * which uses the credentials of the incoming request * - {@link ScopedClusterClient | elasticsearch.dataClient} - Elasticsearch @@ -248,6 +259,7 @@ export { LegacyServiceSetupDeps, LegacyServiceStartDeps } from './legacy'; */ export interface RequestHandlerContext { core: { + rendering: IScopedRenderingClient; savedObjects: { client: SavedObjectsClientContract; }; @@ -301,6 +313,7 @@ export { CapabilitiesSetup, CapabilitiesStart, ContextSetup, + IScopedRenderingClient, PluginsServiceSetup, PluginsServiceStart, PluginOpaqueId, diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index 52adaaccab4b79..be4d830c55eab7 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -17,15 +17,15 @@ * under the License. */ +import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; +import { ContextSetup } from './context'; import { InternalElasticsearchServiceSetup } from './elasticsearch'; import { InternalHttpServiceSetup } from './http'; -import { InternalUiSettingsServiceSetup, InternalUiSettingsServiceStart } from './ui_settings'; -import { ContextSetup } from './context'; import { - InternalSavedObjectsServiceStart, InternalSavedObjectsServiceSetup, + InternalSavedObjectsServiceStart, } from './saved_objects'; -import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; +import { InternalUiSettingsServiceSetup, InternalUiSettingsServiceStart } from './ui_settings'; import { UuidServiceSetup } from './uuid'; /** @internal */ diff --git a/src/core/server/legacy/config/ensure_valid_configuration.ts b/src/core/server/legacy/config/ensure_valid_configuration.ts index 026683a7b7cb00..a68d3df577a891 100644 --- a/src/core/server/legacy/config/ensure_valid_configuration.ts +++ b/src/core/server/legacy/config/ensure_valid_configuration.ts @@ -19,7 +19,7 @@ import { getUnusedConfigKeys } from './get_unused_config_keys'; import { ConfigService } from '../../config'; -import { LegacyServiceDiscoverPlugins } from '../legacy_service'; +import { LegacyServiceDiscoverPlugins } from '../types'; import { CriticalError } from '../../errors'; export async function ensureValidConfiguration( diff --git a/src/core/server/legacy/config/get_unused_config_keys.test.ts b/src/core/server/legacy/config/get_unused_config_keys.test.ts index bf011fa01a3427..c4452fc6a1209a 100644 --- a/src/core/server/legacy/config/get_unused_config_keys.test.ts +++ b/src/core/server/legacy/config/get_unused_config_keys.test.ts @@ -17,8 +17,7 @@ * under the License. */ -import { LegacyPluginSpec } from '../plugins/find_legacy_plugin_specs'; -import { LegacyConfig } from './types'; +import { LegacyPluginSpec, LegacyConfig, LegacyVars } from '../types'; import { getUnusedConfigKeys } from './get_unused_config_keys'; describe('getUnusedConfigKeys', () => { @@ -26,7 +25,7 @@ describe('getUnusedConfigKeys', () => { jest.resetAllMocks(); }); - const getConfig = (values: Record = {}): LegacyConfig => + const getConfig = (values: LegacyVars = {}): LegacyConfig => ({ get: () => values as any, } as LegacyConfig); diff --git a/src/core/server/legacy/config/get_unused_config_keys.ts b/src/core/server/legacy/config/get_unused_config_keys.ts index 73cc7d8c50474c..e425082ba126dc 100644 --- a/src/core/server/legacy/config/get_unused_config_keys.ts +++ b/src/core/server/legacy/config/get_unused_config_keys.ts @@ -22,8 +22,7 @@ import { difference, get, set } from 'lodash'; import { getTransform } from '../../../../legacy/deprecation/index'; import { unset, getFlattenedObject } from '../../../../legacy/utils'; import { hasConfigPathIntersection } from '../../config'; -import { LegacyPluginSpec } from '../plugins/find_legacy_plugin_specs'; -import { LegacyConfig } from './types'; +import { LegacyPluginSpec, LegacyConfig, LegacyVars } from '../types'; const getFlattenedKeys = (object: object) => Object.keys(getFlattenedObject(object)); @@ -37,7 +36,7 @@ export async function getUnusedConfigKeys({ coreHandledConfigPaths: string[]; pluginSpecs: LegacyPluginSpec[]; disabledPluginSpecs: LegacyPluginSpec[]; - settings: Record; + settings: LegacyVars; legacyConfig: LegacyConfig; }) { // transform deprecated plugin settings diff --git a/src/core/server/legacy/config/index.ts b/src/core/server/legacy/config/index.ts index c3f308fd6d903b..f10e3f22d53c5e 100644 --- a/src/core/server/legacy/config/index.ts +++ b/src/core/server/legacy/config/index.ts @@ -20,9 +20,3 @@ export { ensureValidConfiguration } from './ensure_valid_configuration'; export { LegacyObjectToConfigAdapter } from './legacy_object_to_config_adapter'; export { convertLegacyDeprecationProvider } from './legacy_deprecation_adapters'; -export { - LegacyConfig, - LegacyConfigDeprecation, - LegacyConfigDeprecationFactory, - LegacyConfigDeprecationProvider, -} from './types'; diff --git a/src/core/server/legacy/config/legacy_deprecation_adapters.test.ts b/src/core/server/legacy/config/legacy_deprecation_adapters.test.ts index 144e057c118f70..8651d05064492e 100644 --- a/src/core/server/legacy/config/legacy_deprecation_adapters.test.ts +++ b/src/core/server/legacy/config/legacy_deprecation_adapters.test.ts @@ -17,11 +17,11 @@ * under the License. */ -import { convertLegacyDeprecationProvider } from './legacy_deprecation_adapters'; -import { LegacyConfigDeprecationProvider } from './types'; import { ConfigDeprecation } from '../../config'; import { configDeprecationFactory } from '../../config/deprecation/deprecation_factory'; import { applyDeprecations } from '../../config/deprecation/apply_deprecations'; +import { LegacyConfigDeprecationProvider } from '../types'; +import { convertLegacyDeprecationProvider } from './legacy_deprecation_adapters'; jest.spyOn(configDeprecationFactory, 'unusedFromRoot'); jest.spyOn(configDeprecationFactory, 'renameFromRoot'); diff --git a/src/core/server/legacy/config/legacy_deprecation_adapters.ts b/src/core/server/legacy/config/legacy_deprecation_adapters.ts index b0e3bc37e15107..1e0733969e6628 100644 --- a/src/core/server/legacy/config/legacy_deprecation_adapters.ts +++ b/src/core/server/legacy/config/legacy_deprecation_adapters.ts @@ -18,8 +18,8 @@ */ import { ConfigDeprecation, ConfigDeprecationProvider } from '../../config/deprecation'; -import { LegacyConfigDeprecation, LegacyConfigDeprecationProvider } from './index'; import { configDeprecationFactory } from '../../config/deprecation/deprecation_factory'; +import { LegacyConfigDeprecation, LegacyConfigDeprecationProvider } from '../types'; const convertLegacyDeprecation = ( legacyDeprecation: LegacyConfigDeprecation diff --git a/src/core/server/legacy/config/legacy_object_to_config_adapter.ts b/src/core/server/legacy/config/legacy_object_to_config_adapter.ts index ffcbfda4e024dc..bdcde8262ef981 100644 --- a/src/core/server/legacy/config/legacy_object_to_config_adapter.ts +++ b/src/core/server/legacy/config/legacy_object_to_config_adapter.ts @@ -19,6 +19,7 @@ import { ConfigPath } from '../../config'; import { ObjectToConfigAdapter } from '../../config/object_to_config_adapter'; +import { LegacyVars } from '../types'; /** * Represents logging config supported by the legacy platform. @@ -77,7 +78,7 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { }; } - private static transformPlugins(configValue: Record) { + private static transformPlugins(configValue: LegacyVars) { // These properties are the only ones we use from the existing `plugins` config node // since `scanDirs` isn't respected by new platform plugin discovery. return { @@ -94,7 +95,7 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { case 'server': return LegacyObjectToConfigAdapter.transformServer(configValue); case 'plugins': - return LegacyObjectToConfigAdapter.transformPlugins(configValue as Record); + return LegacyObjectToConfigAdapter.transformPlugins(configValue as LegacyVars); default: return configValue; } diff --git a/src/core/server/legacy/config/types.ts b/src/core/server/legacy/config/types.ts deleted file mode 100644 index cac1002d6c2446..00000000000000 --- a/src/core/server/legacy/config/types.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * New platform representation of the legacy configuration (KibanaConfig) - * - * @internal - */ -export interface LegacyConfig { - get(key?: string): T; - has(key: string): boolean; - set(key: string, value: any): void; - set(config: Record): void; -} - -/** - * Representation of a legacy configuration deprecation factory used for - * legacy plugin deprecations. - * - * @internal - */ -export interface LegacyConfigDeprecationFactory { - rename(oldKey: string, newKey: string): LegacyConfigDeprecation; - unused(unusedKey: string): LegacyConfigDeprecation; -} - -/** - * Representation of a legacy configuration deprecation. - * - * @internal - */ -export type LegacyConfigDeprecation = ( - settings: Record, - log: (msg: string) => void -) => void; - -/** - * Representation of a legacy configuration deprecation provider. - * - * @internal - */ -export type LegacyConfigDeprecationProvider = ( - factory: LegacyConfigDeprecationFactory -) => LegacyConfigDeprecation[] | Promise; diff --git a/src/core/server/legacy/index.ts b/src/core/server/legacy/index.ts index 10686fc521d35e..208e9b11672536 100644 --- a/src/core/server/legacy/index.ts +++ b/src/core/server/legacy/index.ts @@ -18,6 +18,10 @@ */ /** @internal */ -export { LegacyObjectToConfigAdapter, ensureValidConfiguration, LegacyConfig } from './config'; +export { LegacyObjectToConfigAdapter, ensureValidConfiguration } from './config'; /** @internal */ -export { LegacyService, LegacyServiceSetupDeps, LegacyServiceStartDeps } from './legacy_service'; +export { LegacyInternals } from './legacy_internals'; +/** @internal */ +export { LegacyService, ILegacyService } from './legacy_service'; +/** @internal */ +export * from './types'; diff --git a/src/core/server/legacy/legacy_internals.test.ts b/src/core/server/legacy/legacy_internals.test.ts new file mode 100644 index 00000000000000..dcab62627442b2 --- /dev/null +++ b/src/core/server/legacy/legacy_internals.test.ts @@ -0,0 +1,211 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Server } from 'hapi'; + +import { configMock } from '../config/config.mock'; +import { httpServiceMock } from '../http/http_service.mock'; +import { httpServerMock } from '../http/http_server.mocks'; +import { findLegacyPluginSpecsMock } from './legacy_service.test.mocks'; +import { LegacyInternals } from './legacy_internals'; +import { ILegacyInternals, LegacyConfig, LegacyVars, LegacyUiExports } from './types'; + +function varsProvider(vars: LegacyVars, configValue?: any) { + return { + fn: jest.fn().mockReturnValue(vars), + pluginSpec: { + readConfigValue: jest.fn().mockReturnValue(configValue), + }, + }; +} + +describe('LegacyInternals', () => { + describe('getInjectedUiAppVars()', () => { + let uiExports: LegacyUiExports; + let config: LegacyConfig; + let server: Server; + let legacyInternals: ILegacyInternals; + + beforeEach(async () => { + uiExports = findLegacyPluginSpecsMock().uiExports; + config = configMock.create() as any; + server = httpServiceMock.createSetupContract().server; + legacyInternals = new LegacyInternals(uiExports, config, server); + }); + + it('gets with no injectors', async () => { + await expect(legacyInternals.getInjectedUiAppVars('core')).resolves.toMatchInlineSnapshot( + `Object {}` + ); + }); + + it('gets with no matching injectors', async () => { + const injector = jest.fn().mockResolvedValue({ not: 'core' }); + legacyInternals.injectUiAppVars('not-core', injector); + + await expect(legacyInternals.getInjectedUiAppVars('core')).resolves.toMatchInlineSnapshot( + `Object {}` + ); + expect(injector).not.toHaveBeenCalled(); + }); + + it('gets with single matching injector', async () => { + const injector = jest.fn().mockResolvedValue({ is: 'core' }); + legacyInternals.injectUiAppVars('core', injector); + + await expect(legacyInternals.getInjectedUiAppVars('core')).resolves.toMatchInlineSnapshot(` + Object { + "is": "core", + } + `); + expect(injector).toHaveBeenCalled(); + }); + + it('gets with multiple matching injectors', async () => { + const injectors = [ + jest.fn().mockResolvedValue({ is: 'core' }), + jest.fn().mockReturnValue({ sync: 'injector' }), + jest.fn().mockResolvedValue({ is: 'merged-core' }), + ]; + + injectors.forEach(injector => legacyInternals.injectUiAppVars('core', injector)); + + await expect(legacyInternals.getInjectedUiAppVars('core')).resolves.toMatchInlineSnapshot(` + Object { + "is": "merged-core", + "sync": "injector", + } + `); + expect(injectors[0]).toHaveBeenCalled(); + expect(injectors[1]).toHaveBeenCalled(); + expect(injectors[2]).toHaveBeenCalled(); + }); + }); + + describe('getVars()', () => { + let uiExports: LegacyUiExports; + let config: LegacyConfig; + let server: Server; + let legacyInternals: LegacyInternals; + + beforeEach(async () => { + uiExports = findLegacyPluginSpecsMock().uiExports; + config = configMock.create() as any; + server = httpServiceMock.createSetupContract().server; + legacyInternals = new LegacyInternals(uiExports, config, server); + }); + + it('gets: no default injectors, no injected vars replacers, no ui app injectors, no inject arg', async () => { + const vars = await legacyInternals.getVars('core', httpServerMock.createRawRequest()); + + expect(vars).toMatchInlineSnapshot(`Object {}`); + }); + + it('gets: with default injectors, no injected vars replacers, no ui app injectors, no inject arg', async () => { + uiExports.defaultInjectedVarProviders = [ + varsProvider({ alpha: 'alpha' }), + varsProvider({ gamma: 'gamma' }), + varsProvider({ alpha: 'beta' }), + ]; + + const vars = await legacyInternals.getVars('core', httpServerMock.createRawRequest()); + + expect(vars).toMatchInlineSnapshot(` + Object { + "alpha": "beta", + "gamma": "gamma", + } + `); + }); + + it('gets: no default injectors, with injected vars replacers, with ui app injectors, no inject arg', async () => { + uiExports.injectedVarsReplacers = [ + jest.fn(async vars => ({ ...vars, added: 'key' })), + jest.fn(vars => vars), + jest.fn(vars => ({ replaced: 'all' })), + jest.fn(async vars => ({ ...vars, added: 'last-key' })), + ]; + + const request = httpServerMock.createRawRequest(); + const vars = await legacyInternals.getVars('core', request); + + expect(vars).toMatchInlineSnapshot(` + Object { + "added": "last-key", + "replaced": "all", + } + `); + }); + + it('gets: no default injectors, no injected vars replacers, with ui app injectors, no inject arg', async () => { + legacyInternals.injectUiAppVars('core', async () => ({ is: 'core' })); + legacyInternals.injectUiAppVars('core', () => ({ sync: 'injector' })); + legacyInternals.injectUiAppVars('core', async () => ({ is: 'merged-core' })); + + const vars = await legacyInternals.getVars('core', httpServerMock.createRawRequest()); + + expect(vars).toMatchInlineSnapshot(` + Object { + "is": "merged-core", + "sync": "injector", + } + `); + }); + + it('gets: no default injectors, no injected vars replacers, no ui app injectors, with inject arg', async () => { + const vars = await legacyInternals.getVars('core', httpServerMock.createRawRequest(), { + injected: 'arg', + }); + + expect(vars).toMatchInlineSnapshot(` + Object { + "injected": "arg", + } + `); + }); + + it('gets: with default injectors, with injected vars replacers, with ui app injectors, with inject arg', async () => { + uiExports.defaultInjectedVarProviders = [ + varsProvider({ alpha: 'alpha' }), + varsProvider({ gamma: 'gamma' }), + varsProvider({ alpha: 'beta' }), + ]; + uiExports.injectedVarsReplacers = [jest.fn(async vars => ({ ...vars, gamma: 'delta' }))]; + + legacyInternals.injectUiAppVars('core', async () => ({ is: 'core' })); + legacyInternals.injectUiAppVars('core', () => ({ sync: 'injector' })); + legacyInternals.injectUiAppVars('core', async () => ({ is: 'merged-core' })); + + const vars = await legacyInternals.getVars('core', httpServerMock.createRawRequest(), { + injected: 'arg', + sync: 'arg', + }); + + expect(vars).toMatchInlineSnapshot(` + Object { + "alpha": "beta", + "gamma": "delta", + "injected": "arg", + "is": "merged-core", + "sync": "arg", + } + `); + }); + }); +}); diff --git a/src/core/server/legacy/legacy_internals.ts b/src/core/server/legacy/legacy_internals.ts new file mode 100644 index 00000000000000..3bf54e5f75dce5 --- /dev/null +++ b/src/core/server/legacy/legacy_internals.ts @@ -0,0 +1,87 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Server } from 'hapi'; + +import { LegacyRequest } from '../http'; +import { mergeVars } from './merge_vars'; +import { ILegacyInternals, LegacyVars, VarsInjector, LegacyConfig, LegacyUiExports } from './types'; + +/** + * @internal + * @deprecated + */ +export class LegacyInternals implements ILegacyInternals { + private readonly injectors = new Map>(); + private cachedDefaultVars?: LegacyVars; + + constructor( + private readonly uiExports: LegacyUiExports, + private readonly config: LegacyConfig, + private readonly server: Server + ) {} + + private get defaultVars(): LegacyVars { + if (this.cachedDefaultVars) { + return this.cachedDefaultVars; + } + + const { defaultInjectedVarProviders = [] } = this.uiExports; + + return (this.cachedDefaultVars = defaultInjectedVarProviders.reduce( + (vars, { fn, pluginSpec }) => + mergeVars(vars, fn(this.server, pluginSpec.readConfigValue(this.config, []))), + {} + )); + } + + private replaceVars(vars: LegacyVars, request: LegacyRequest) { + const { injectedVarsReplacers = [] } = this.uiExports; + + return injectedVarsReplacers.reduce( + async (injected, replacer) => replacer(await injected, request, this.server), + Promise.resolve(vars) + ); + } + + public injectUiAppVars(id: string, injector: VarsInjector) { + if (!this.injectors.has(id)) { + this.injectors.set(id, new Set()); + } + + this.injectors.get(id)!.add(injector); + } + + public getInjectedUiAppVars(id: string) { + return [...(this.injectors.get(id) || [])].reduce( + async (promise, injector) => ({ + ...(await promise), + ...(await injector()), + }), + Promise.resolve({}) + ); + } + + public async getVars(id: string, request: LegacyRequest, injected: LegacyVars = {}) { + return this.replaceVars( + mergeVars(this.defaultVars, await this.getInjectedUiAppVars(id), injected), + request + ); + } +} diff --git a/src/core/server/legacy/legacy_service.mock.ts b/src/core/server/legacy/legacy_service.mock.ts index ac0319cdf4eb5e..495141cdcb58de 100644 --- a/src/core/server/legacy/legacy_service.mock.ts +++ b/src/core/server/legacy/legacy_service.mock.ts @@ -17,23 +17,33 @@ * under the License. */ -import { LegacyServiceDiscoverPlugins } from './legacy_service'; +import { LegacyService } from './legacy_service'; +import { LegacyServiceDiscoverPlugins, LegacyServiceSetupDeps } from './types'; -const createDiscoverMock = () => { - const setupContract: DeeplyMockedKeys = { - pluginSpecs: [], - disabledPluginSpecs: [], - uiExports: {} as any, - settings: {}, - pluginExtendedConfig: { - get: jest.fn(), - has: jest.fn(), - set: jest.fn(), - } as any, - }; - return setupContract; -}; +type LegacyServiceMock = jest.Mocked & { legacyId: symbol }>; + +const createDiscoverPluginsMock = (): LegacyServiceDiscoverPlugins => ({ + pluginSpecs: [], + uiExports: {} as any, + navLinks: [], + pluginExtendedConfig: { + get: jest.fn(), + has: jest.fn(), + set: jest.fn(), + }, + disabledPluginSpecs: [], + settings: {}, +}); +const createLegacyServiceMock = (): LegacyServiceMock => ({ + legacyId: Symbol(), + discoverPlugins: jest.fn().mockResolvedValue(createDiscoverPluginsMock()), + setup: jest.fn(), + start: jest.fn(), + stop: jest.fn(), +}); export const legacyServiceMock = { - createDiscover: createDiscoverMock, + create: createLegacyServiceMock, + createSetupContract: (deps: LegacyServiceSetupDeps) => createLegacyServiceMock().setup(deps), + createDiscoverPlugins: createDiscoverPluginsMock, }; diff --git a/src/core/server/legacy/legacy_service.test.mocks.ts b/src/core/server/legacy/legacy_service.test.mocks.ts index e8d4a0ed0bd4d6..451a75ced7ae29 100644 --- a/src/core/server/legacy/legacy_service.test.mocks.ts +++ b/src/core/server/legacy/legacy_service.test.mocks.ts @@ -17,18 +17,19 @@ * under the License. */ -export const findLegacyPluginSpecsMock = jest - .fn() - .mockImplementation((settings: Record) => ({ - pluginSpecs: [], - pluginExtendedConfig: { - has: jest.fn(), - get: jest.fn(() => settings), - set: jest.fn(), - }, - disabledPluginSpecs: [], - uiExports: [], - })); +import { LegacyVars } from './types'; + +export const findLegacyPluginSpecsMock = jest.fn().mockImplementation((settings: LegacyVars) => ({ + pluginSpecs: [], + pluginExtendedConfig: { + has: jest.fn(), + get: jest.fn().mockReturnValue(settings), + set: jest.fn(), + }, + disabledPluginSpecs: [], + uiExports: {}, + navLinks: [], +})); jest.doMock('./plugins/find_legacy_plugin_specs.ts', () => ({ findLegacyPluginSpecs: findLegacyPluginSpecsMock, })); diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index c652bb1c948878..608392e4943f91 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -25,7 +25,7 @@ jest.mock('./config/legacy_deprecation_adapters', () => ({ import { findLegacyPluginSpecsMock } from './legacy_service.test.mocks'; import { BehaviorSubject, throwError } from 'rxjs'; -import { LegacyService, LegacyServiceSetupDeps, LegacyServiceStartDeps } from '.'; + // @ts-ignore: implicit any for JS file import { ClusterManager as MockClusterManager } from '../../../cli/cluster/cluster_manager'; import KbnServer from '../../../legacy/server/kbn_server'; @@ -33,7 +33,6 @@ import { Config, Env, ObjectToConfigAdapter } from '../config'; import { getEnvOptions } from '../config/__mocks__/env'; import { BasePathProxyServer } from '../http'; import { DiscoveredPlugin } from '../plugins'; -import { findLegacyPluginSpecs } from './plugins/find_legacy_plugin_specs'; import { configServiceMock } from '../config/config_service.mock'; import { loggingServiceMock } from '../logging/logging_service.mock'; @@ -42,7 +41,11 @@ import { httpServiceMock } from '../http/http_service.mock'; import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.mock'; import { capabilitiesServiceMock } from '../capabilities/capabilities_service.mock'; +import { setupMock as renderingServiceMock } from '../rendering/__mocks__/rendering_service'; import { uuidServiceMock } from '../uuid/uuid_service.mock'; +import { findLegacyPluginSpecs } from './plugins'; +import { LegacyVars, LegacyServiceSetupDeps, LegacyServiceStartDeps } from './types'; +import { LegacyService } from './legacy_service'; const MockKbnServer: jest.Mock = KbnServer as any; @@ -89,6 +92,7 @@ beforeEach(() => { browserConfigs: new Map(), }, }, + rendering: renderingServiceMock, uuid: uuidSetup, }, plugins: { 'plugin-id': 'plugin-value' }, @@ -138,7 +142,7 @@ describe('once LegacyService is set up with connection info', () => { { path: { autoListen: true }, server: { autoListen: true } }, // Because of the mock, path also gets the value expect.objectContaining({ get: expect.any(Function) }), expect.any(Object), - { disabledPluginSpecs: [], pluginSpecs: [], uiExports: [] } + { disabledPluginSpecs: [], pluginSpecs: [], uiExports: {}, navLinks: [] } ); expect(MockKbnServer.mock.calls[0][1].get()).toEqual({ path: { autoListen: true }, @@ -168,7 +172,7 @@ describe('once LegacyService is set up with connection info', () => { { path: { autoListen: false }, server: { autoListen: true } }, expect.objectContaining({ get: expect.any(Function) }), expect.any(Object), - { disabledPluginSpecs: [], pluginSpecs: [], uiExports: [] } + { disabledPluginSpecs: [], pluginSpecs: [], uiExports: {}, navLinks: [] } ); expect(MockKbnServer.mock.calls[0][1].get()).toEqual({ path: { autoListen: false }, @@ -309,7 +313,7 @@ describe('once LegacyService is set up without connection info', () => { { path: {}, server: { autoListen: true } }, expect.objectContaining({ get: expect.any(Function) }), expect.any(Object), - { disabledPluginSpecs: [], pluginSpecs: [], uiExports: [] } + { disabledPluginSpecs: [], pluginSpecs: [], uiExports: {}, navLinks: [] } ); expect(MockKbnServer.mock.calls[0][1].get()).toEqual({ path: {}, @@ -395,16 +399,18 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => { }); }); -test('Cannot start without setup phase', async () => { - const legacyService = new LegacyService({ - coreId, - env, - logger, - configService: configService as any, +describe('start', () => { + test('Cannot start without setup phase', async () => { + const legacyService = new LegacyService({ + coreId, + env, + logger, + configService: configService as any, + }); + await expect(legacyService.start(startDeps)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Legacy service is not setup yet."` + ); }); - await expect(legacyService.start(startDeps)).rejects.toThrowErrorMatchingInlineSnapshot( - `"Legacy service is not setup yet."` - ); }); describe('#discoverPlugins()', () => { @@ -438,7 +444,8 @@ describe('#discoverPlugins()', () => { ], pluginExtendedConfig: settings, disabledPluginSpecs: [], - uiExports: [], + uiExports: {}, + navLinks: [], }) as any ); @@ -469,15 +476,16 @@ test('Sets the server.uuid property on the legacy configuration', async () => { const configSetMock = jest.fn(); - findLegacyPluginSpecsMock.mockImplementation((settings: Record) => ({ + findLegacyPluginSpecsMock.mockImplementation((settings: LegacyVars) => ({ pluginSpecs: [], pluginExtendedConfig: { has: jest.fn(), - get: jest.fn(() => settings), + get: jest.fn().mockReturnValue(settings), set: configSetMock, }, disabledPluginSpecs: [], - uiExports: [], + uiExports: {}, + navLinks: [], })); await legacyService.discoverPlugins(); diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 2e8a467eff995f..2ed87f4c6d4886 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -19,24 +19,30 @@ import { combineLatest, ConnectableObservable, EMPTY, Observable, Subscription } from 'rxjs'; import { first, map, publishReplay, tap } from 'rxjs/operators'; + import { CoreService } from '../../types'; -import { CoreSetup, CoreStart } from '../'; -import { InternalCoreSetup, InternalCoreStart } from '../internal_types'; -import { SavedObjectsLegacyUiExports } from '../types'; import { Config, ConfigDeprecationProvider } from '../config'; import { CoreContext } from '../core_context'; import { CspConfigType, config as cspConfig } from '../csp'; import { DevConfig, DevConfigType, config as devConfig } from '../dev'; import { BasePathProxyServer, HttpConfig, HttpConfigType, config as httpConfig } from '../http'; import { Logger } from '../logging'; -import { PluginsServiceSetup, PluginsServiceStart } from '../plugins'; -import { findLegacyPluginSpecs } from './plugins'; -import { LegacyPluginSpec } from './plugins/find_legacy_plugin_specs'; import { PathConfigType } from '../path'; -import { LegacyConfig, convertLegacyDeprecationProvider } from './config'; +import { findLegacyPluginSpecs } from './plugins'; +import { convertLegacyDeprecationProvider } from './config'; +import { + LegacyServiceSetupDeps, + LegacyServiceStartDeps, + LegacyPlugins, + LegacyServiceDiscoverPlugins, + LegacyConfig, + LegacyVars, +} from './types'; +import { LegacyInternals } from './legacy_internals'; +import { CoreSetup, CoreStart } from '..'; interface LegacyKbnServer { - applyLoggingConfiguration: (settings: Readonly>) => void; + applyLoggingConfiguration: (settings: Readonly) => void; listen: () => Promise; ready: () => Promise; close: () => Promise; @@ -53,43 +59,14 @@ function getLegacyRawConfig(config: Config, pathConfig: PathConfigType) { return { ...rawConfig, - path: pathConfig, // We rely heavily in the default value of 'path.data' in the legacy world and, since it has been moved to NP, it won't show up in RawConfig - }; -} - -/** - * @public - * @deprecated - */ -export interface LegacyServiceSetupDeps { - core: InternalCoreSetup & { - plugins: PluginsServiceSetup; - }; - plugins: Record; -} - -/** - * @public - * @deprecated - */ -export interface LegacyServiceStartDeps { - core: InternalCoreStart & { - plugins: PluginsServiceStart; + // We rely heavily in the default value of 'path.data' in the legacy world and, + // since it has been moved to NP, it won't show up in RawConfig. + path: pathConfig, }; - plugins: Record; } /** @internal */ -export interface LegacyServiceDiscoverPlugins { - pluginSpecs: LegacyPluginSpec[]; - disabledPluginSpecs: LegacyPluginSpec[]; - uiExports: SavedObjectsLegacyUiExports; - pluginExtendedConfig: LegacyConfig; - settings: Record; -} - -/** @internal */ -export type ILegacyService = Pick; +export type ILegacyService = PublicMethodsOf; /** @internal */ export class LegacyService implements CoreService { @@ -101,16 +78,10 @@ export class LegacyService implements CoreService { private kbnServer?: LegacyKbnServer; private configSubscription?: Subscription; private setupDeps?: LegacyServiceSetupDeps; - private update$: ConnectableObservable<[Config, PathConfigType]> | undefined; - private legacyRawConfig: LegacyConfig | undefined; - private legacyPlugins: - | { - pluginSpecs: LegacyPluginSpec[]; - disabledPluginSpecs: LegacyPluginSpec[]; - uiExports: SavedObjectsLegacyUiExports; - } - | undefined; - private settings: Record | undefined; + private update$?: ConnectableObservable<[Config, PathConfigType]>; + private legacyRawConfig?: LegacyConfig; + private legacyPlugins?: LegacyPlugins; + private settings?: LegacyVars; constructor(private readonly coreContext: CoreContext) { const { logger, configService, env } = coreContext; @@ -153,12 +124,14 @@ export class LegacyService implements CoreService { pluginExtendedConfig, disabledPluginSpecs, uiExports, + navLinks, } = await findLegacyPluginSpecs(this.settings, this.coreContext.logger); this.legacyPlugins = { pluginSpecs, disabledPluginSpecs, uiExports, + navLinks, }; const deprecationProviders = await pluginSpecs @@ -188,6 +161,7 @@ export class LegacyService implements CoreService { pluginSpecs, disabledPluginSpecs, uiExports, + navLinks, pluginExtendedConfig, settings: this.settings, }; @@ -195,35 +169,37 @@ export class LegacyService implements CoreService { public async setup(setupDeps: LegacyServiceSetupDeps) { this.log.debug('setting up legacy service'); - if (!this.legacyRawConfig || !this.legacyPlugins || !this.settings) { + + if (!this.legacyPlugins) { throw new Error( 'Legacy service has not discovered legacy plugins yet. Ensure LegacyService.discoverPlugins() is called before LegacyService.setup()' ); } - // propagate the instance uuid to the legacy config, as it was the legacy way to access it. - this.legacyRawConfig.set('server.uuid', setupDeps.core.uuid.getInstanceUuid()); + // propagate the instance uuid to the legacy config, as it was the legacy way to access it. + this.legacyRawConfig!.set('server.uuid', setupDeps.core.uuid.getInstanceUuid()); this.setupDeps = setupDeps; } public async start(startDeps: LegacyServiceStartDeps) { const { setupDeps } = this; - if (!setupDeps || !this.legacyRawConfig || !this.legacyPlugins || !this.settings) { + + if (!setupDeps || !this.legacyPlugins) { throw new Error('Legacy service is not setup yet.'); } + this.log.debug('starting legacy service'); // Receive initial config and create kbnServer/ClusterManager. - if (this.coreContext.env.isDevClusterMaster) { - await this.createClusterManager(this.legacyRawConfig); + await this.createClusterManager(this.legacyRawConfig!); } else { this.kbnServer = await this.createKbnServer( - this.settings, - this.legacyRawConfig, + this.settings!, + this.legacyRawConfig!, setupDeps, startDeps, - this.legacyPlugins + this.legacyPlugins! ); } } @@ -263,15 +239,11 @@ export class LegacyService implements CoreService { } private async createKbnServer( - settings: Record, + settings: LegacyVars, config: LegacyConfig, setupDeps: LegacyServiceSetupDeps, startDeps: LegacyServiceStartDeps, - legacyPlugins: { - pluginSpecs: LegacyPluginSpec[]; - disabledPluginSpecs: LegacyPluginSpec[]; - uiExports: SavedObjectsLegacyUiExports; - } + legacyPlugins: LegacyPlugins ) { const coreSetup: CoreSetup = { capabilities: setupDeps.core.capabilities, @@ -338,8 +310,10 @@ export class LegacyService implements CoreService { kibanaMigrator: startDeps.core.savedObjects.migrator, uiPlugins: setupDeps.core.plugins.uiPlugins, elasticsearch: setupDeps.core.elasticsearch, + rendering: setupDeps.core.rendering, uiSettings: setupDeps.core.uiSettings, savedObjectsClientProvider: startDeps.core.savedObjects.clientProvider, + legacy: new LegacyInternals(legacyPlugins.uiExports, config, setupDeps.core.http.server), }, logger: this.coreContext.logger, }, diff --git a/src/core/server/legacy/logging/appenders/legacy_appender.ts b/src/core/server/legacy/logging/appenders/legacy_appender.ts index 011dfae8a5cefc..6d82d929e7daa1 100644 --- a/src/core/server/legacy/logging/appenders/legacy_appender.ts +++ b/src/core/server/legacy/logging/appenders/legacy_appender.ts @@ -21,6 +21,7 @@ import { schema } from '@kbn/config-schema'; import { DisposableAppender } from '../../../logging/appenders/appenders'; import { LogRecord } from '../../../logging/log_record'; import { LegacyLoggingServer } from '../legacy_logging_server'; +import { LegacyVars } from '../../types'; /** * Simple appender that just forwards `LogRecord` to the legacy KbnServer log. @@ -34,7 +35,7 @@ export class LegacyAppender implements DisposableAppender { private readonly loggingServer: LegacyLoggingServer; - constructor(legacyLoggingConfig: Readonly>) { + constructor(legacyLoggingConfig: Readonly) { this.loggingServer = new LegacyLoggingServer(legacyLoggingConfig); } diff --git a/src/core/server/legacy/logging/legacy_logging_server.ts b/src/core/server/legacy/logging/legacy_logging_server.ts index 57706bcac2232f..85a8686b4eded6 100644 --- a/src/core/server/legacy/logging/legacy_logging_server.ts +++ b/src/core/server/legacy/logging/legacy_logging_server.ts @@ -25,9 +25,10 @@ import { Config } from '../../../../legacy/server/config'; import { setupLogging } from '../../../../legacy/server/logging'; import { LogLevel } from '../../logging/log_level'; import { LogRecord } from '../../logging/log_record'; +import { LegacyVars } from '../../types'; export const metadataSymbol = Symbol('log message with metadata'); -export function attachMetaData(message: string, metadata: Record = {}) { +export function attachMetaData(message: string, metadata: LegacyVars = {}) { return { [metadataSymbol]: { message, @@ -50,7 +51,7 @@ interface PluginRegisterParams { options: PluginRegisterParams['options'] ) => Promise; }; - options: Record; + options: LegacyVars; } /** @@ -84,7 +85,7 @@ export class LegacyLoggingServer { private onPostStopCallback?: () => void; - constructor(legacyLoggingConfig: Readonly>) { + constructor(legacyLoggingConfig: Readonly) { // We set `ops.interval` to max allowed number and `ops` filter to value // that doesn't exist to avoid logging of ops at all, if turned on it will be // logged by the "legacy" Kibana. diff --git a/src/legacy/ui/ui_render/lib/merge_variables.test.ts b/src/core/server/legacy/merge_vars.test.ts similarity index 58% rename from src/legacy/ui/ui_render/lib/merge_variables.test.ts rename to src/core/server/legacy/merge_vars.test.ts index 4d69216bc0bfda..d977ee292d039d 100644 --- a/src/legacy/ui/ui_render/lib/merge_variables.test.ts +++ b/src/core/server/legacy/merge_vars.test.ts @@ -17,29 +17,26 @@ * under the License. */ -import { mergeVariables } from './merge_variables'; +import { mergeVars } from './merge_vars'; -describe('mergeVariables', () => { +describe('mergeVars', () => { it('merges two objects together', () => { - const someVariables = { - name: 'value', - canFoo: true, - nested: { - anotherVariable: 'ok', - }, - }; - - const otherVariables = { + const first = { otherName: 'value', otherCanFoo: true, otherNested: { otherAnotherVariable: 'ok', }, }; + const second = { + name: 'value', + canFoo: true, + nested: { + anotherVariable: 'ok', + }, + }; - const result = mergeVariables(someVariables, otherVariables); - - expect(result).toEqual({ + expect(mergeVars(first, second)).toEqual({ name: 'value', canFoo: true, nested: { @@ -54,86 +51,76 @@ describe('mergeVariables', () => { }); it('does not mutate the source objects', () => { - const original = { - var1: 'original', + const first = { + var1: 'first', }; - - const set1 = { - var1: 'value1', - var2: 'value1', + const second = { + var1: 'second', + var2: 'second', }; - - const set2 = { - var1: 'value2', - var2: 'value2', - var3: 'value2', + const third = { + var1: 'third', + var2: 'third', + var3: 'third', }; - - const set3 = { - var1: 'value3', - var2: 'value3', - var3: 'value3', - var4: 'value3', + const fourth = { + var1: 'fourth', + var2: 'fourth', + var3: 'fourth', + var4: 'fourth', }; - mergeVariables(original, set1, set2, set3); + mergeVars(first, second, third, fourth); - expect(original).toEqual({ var1: 'original' }); - expect(set1).toEqual({ var1: 'value1', var2: 'value1' }); - expect(set2).toEqual({ var1: 'value2', var2: 'value2', var3: 'value2' }); - expect(set3).toEqual({ var1: 'value3', var2: 'value3', var3: 'value3', var4: 'value3' }); + expect(first).toEqual({ var1: 'first' }); + expect(second).toEqual({ var1: 'second', var2: 'second' }); + expect(third).toEqual({ var1: 'third', var2: 'third', var3: 'third' }); + expect(fourth).toEqual({ var1: 'fourth', var2: 'fourth', var3: 'fourth', var4: 'fourth' }); }); - it('merges multiple objects together, preferring the leftmost values', () => { - const original = { - var1: 'original', + it('merges multiple objects together with precedence increasing from left-to-right', () => { + const first = { + var1: 'first', + var2: 'first', + var3: 'first', + var4: 'first', }; - - const set1 = { - var1: 'value1', - var2: 'value1', + const second = { + var1: 'second', + var2: 'second', + var3: 'second', }; - - const set2 = { - var1: 'value2', - var2: 'value2', - var3: 'value2', + const third = { + var1: 'third', + var2: 'third', }; - - const set3 = { - var1: 'value3', - var2: 'value3', - var3: 'value3', - var4: 'value3', + const fourth = { + var1: 'fourth', }; - const result = mergeVariables(original, set1, set2, set3); - - expect(result).toEqual({ - var1: 'original', - var2: 'value1', - var3: 'value2', - var4: 'value3', + expect(mergeVars(first, second, third, fourth)).toEqual({ + var1: 'fourth', + var2: 'third', + var3: 'second', + var4: 'first', }); }); - it('retains the original variable value if a duplicate entry is found', () => { - const someVariables = { - name: 'value', - canFoo: true, + it('overwrites the original variable value if a duplicate entry is found', () => { + const first = { nested: { - anotherVariable: 'ok', + otherAnotherVariable: 'ok', }, }; - - const otherVariables = { + const second = { + name: 'value', + canFoo: true, nested: { - otherAnotherVariable: 'ok', + anotherVariable: 'ok', }, }; - const result = mergeVariables(someVariables, otherVariables); - expect(result).toEqual({ + expect(mergeVars(first, second)).toEqual({ name: 'value', canFoo: true, nested: { @@ -143,55 +130,61 @@ describe('mergeVariables', () => { }); it('combines entries within "uiCapabilities"', () => { - const someVariables = { - name: 'value', - canFoo: true, + const first = { uiCapabilities: { firstCapability: 'ok', + sharedCapability: 'shared', }, }; - - const otherVariables = { + const second = { + name: 'value', + canFoo: true, uiCapabilities: { secondCapability: 'ok', }, }; + const third = { + name: 'value', + canFoo: true, + uiCapabilities: { + thirdCapability: 'ok', + sharedCapability: 'blocked', + }, + }; - const result = mergeVariables(someVariables, otherVariables); - - expect(result).toEqual({ + expect(mergeVars(first, second, third)).toEqual({ name: 'value', canFoo: true, uiCapabilities: { firstCapability: 'ok', secondCapability: 'ok', + thirdCapability: 'ok', + sharedCapability: 'blocked', }, }); }); it('does not deeply combine entries within "uiCapabilities"', () => { - const someVariables = { - name: 'value', - canFoo: true, + const first = { uiCapabilities: { firstCapability: 'ok', nestedCapability: { - nestedProp: 'nestedValue', + otherNestedProp: 'otherNestedValue', }, }, }; - - const otherVariables = { + const second = { + name: 'value', + canFoo: true, uiCapabilities: { secondCapability: 'ok', nestedCapability: { - otherNestedProp: 'otherNestedValue', + nestedProp: 'nestedValue', }, }, }; - const result = mergeVariables(someVariables, otherVariables); - expect(result).toEqual({ + expect(mergeVars(first, second)).toEqual({ name: 'value', canFoo: true, uiCapabilities: { diff --git a/src/legacy/ui/ui_render/lib/merge_variables.ts b/src/core/server/legacy/merge_vars.ts similarity index 65% rename from src/legacy/ui/ui_render/lib/merge_variables.ts rename to src/core/server/legacy/merge_vars.ts index 0f65c7825bdba3..a1d43af2f861de 100644 --- a/src/legacy/ui/ui_render/lib/merge_variables.ts +++ b/src/core/server/legacy/merge_vars.ts @@ -17,23 +17,18 @@ * under the License. */ -const ELIGIBLE_FLAT_MERGE_KEYS = ['uiCapabilities']; - -export function mergeVariables(...sources: Array>) { - const result: Record = {}; +import { LegacyVars } from './types'; - for (const source of sources) { - Object.entries(source).forEach(([key, value]) => { - if (ELIGIBLE_FLAT_MERGE_KEYS.includes(key)) { - result[key] = { - ...value, - ...result[key], - }; - } else if (!result.hasOwnProperty(key)) { - result[key] = value; - } - }); - } +const ELIGIBLE_FLAT_MERGE_KEYS = ['uiCapabilities']; - return result; +export function mergeVars(...sources: LegacyVars[]): LegacyVars { + return Object.assign( + {}, + ...sources, + ...ELIGIBLE_FLAT_MERGE_KEYS.flatMap(key => + sources.some(source => key in source) + ? [{ [key]: Object.assign({}, ...sources.map(source => source[key] || {})) }] + : [] + ) + ); } diff --git a/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts b/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts index 0a49154801e563..d2e7a39236d0a8 100644 --- a/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts +++ b/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts @@ -19,25 +19,77 @@ import { Observable, merge, forkJoin } from 'rxjs'; import { toArray, tap, distinct, map } from 'rxjs/operators'; + import { findPluginSpecs, defaultConfig, // @ts-ignore } from '../../../../legacy/plugin_discovery/find_plugin_specs.js'; -import { LoggerFactory } from '../../logging'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { collectUiExports as collectLegacyUiExports } from '../../../../legacy/ui/ui_exports/collect_ui_exports'; -import { LegacyConfig, LegacyConfigDeprecationProvider } from '../config'; -export interface LegacyPluginPack { - getPath(): string; +import { LoggerFactory } from '../../logging'; +import { + LegacyUiExports, + LegacyNavLink, + LegacyPluginSpec, + LegacyPluginPack, + LegacyConfig, +} from '../types'; + +const REMOVE_FROM_ARRAY: LegacyNavLink[] = []; + +function getUiAppsNavLinks({ uiAppSpecs = [] }: LegacyUiExports, pluginSpecs: LegacyPluginSpec[]) { + return uiAppSpecs.flatMap(spec => { + if (!spec) { + return REMOVE_FROM_ARRAY; + } + + const id = spec.pluginId || spec.id; + + if (!id) { + throw new Error('Every app must specify an id'); + } + + if (spec.pluginId && !pluginSpecs.some(plugin => plugin.getId() === spec.pluginId)) { + throw new Error(`Unknown plugin id "${spec.pluginId}"`); + } + + const listed = typeof spec.listed === 'boolean' ? spec.listed : true; + + if (spec.hidden || !listed) { + return REMOVE_FROM_ARRAY; + } + + return { + id, + title: spec.title, + order: typeof spec.order === 'number' ? spec.order : 0, + icon: spec.icon, + euiIconType: spec.euiIconType, + url: spec.url || `/app/${id}`, + linkToLastSubUrl: spec.linkToLastSubUrl, + }; + }); } -export interface LegacyPluginSpec { - getId: () => unknown; - getExpectedKibanaVersion: () => string; - getConfigPrefix: () => string; - getDeprecationsProvider: () => LegacyConfigDeprecationProvider | undefined; +function getNavLinks(uiExports: LegacyUiExports, pluginSpecs: LegacyPluginSpec[]) { + return (uiExports.navLinkSpecs || []) + .map(spec => ({ + id: spec.id, + title: spec.title, + order: typeof spec.order === 'number' ? spec.order : 0, + url: spec.url, + subUrlBase: spec.subUrlBase || spec.url, + icon: spec.icon, + euiIconType: spec.euiIconType, + linkToLastSub: 'linkToLastSubUrl' in spec ? spec.linkToLastSubUrl : false, + hidden: 'hidden' in spec ? spec.hidden : false, + disabled: 'disabled' in spec ? spec.disabled : false, + tooltip: spec.tooltip || '', + })) + .concat(getUiAppsNavLinks(uiExports, pluginSpecs)) + .sort((a, b) => a.order - b.order); } export async function findLegacyPluginSpecs(settings: unknown, loggerFactory: LoggerFactory) { @@ -128,11 +180,14 @@ export async function findLegacyPluginSpecs(settings: unknown, loggerFactory: Lo spec$.pipe(toArray()), log$.pipe(toArray()) ).toPromise(); + const uiExports = collectLegacyUiExports(pluginSpecs); + const navLinks = getNavLinks(uiExports, pluginSpecs); return { disabledPluginSpecs, pluginSpecs, pluginExtendedConfig: configToMutate, - uiExports: collectLegacyUiExports(pluginSpecs), + uiExports, + navLinks, }; } diff --git a/src/core/server/legacy/plugins/index.ts b/src/core/server/legacy/plugins/index.ts index 7c69546f0c4de3..a6d55e1da78390 100644 --- a/src/core/server/legacy/plugins/index.ts +++ b/src/core/server/legacy/plugins/index.ts @@ -16,4 +16,5 @@ * specific language governing permissions and limitations * under the License. */ + export { findLegacyPluginSpecs } from './find_legacy_plugin_specs'; diff --git a/src/core/server/legacy/types.ts b/src/core/server/legacy/types.ts new file mode 100644 index 00000000000000..6ec893be9b3103 --- /dev/null +++ b/src/core/server/legacy/types.ts @@ -0,0 +1,222 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Server } from 'hapi'; + +import { ChromeNavLink } from '../../public'; +import { LegacyRequest } from '../http'; +import { InternalCoreSetup, InternalCoreStart } from '../internal_types'; +import { PluginsServiceSetup, PluginsServiceStart } from '../plugins'; +import { RenderingServiceSetup } from '../rendering'; +import { SavedObjectsLegacyUiExports } from '../types'; + +/** + * @internal + * @deprecated + */ +export type LegacyVars = Record; + +type LegacyCoreSetup = InternalCoreSetup & { + plugins: PluginsServiceSetup; + rendering: RenderingServiceSetup; +}; +type LegacyCoreStart = InternalCoreStart & { plugins: PluginsServiceStart }; + +/** + * New platform representation of the legacy configuration (KibanaConfig) + * + * @internal + * @deprecated + */ +export interface LegacyConfig { + get(key?: string): T; + has(key: string): boolean; + set(key: string, value: any): void; + set(config: LegacyVars): void; +} + +/** + * Representation of a legacy configuration deprecation factory used for + * legacy plugin deprecations. + * + * @internal + * @deprecated + */ +export interface LegacyConfigDeprecationFactory { + rename(oldKey: string, newKey: string): LegacyConfigDeprecation; + unused(unusedKey: string): LegacyConfigDeprecation; +} + +/** + * Representation of a legacy configuration deprecation. + * + * @internal + * @deprecated + */ +export type LegacyConfigDeprecation = (settings: LegacyVars, log: (msg: string) => void) => void; + +/** + * Representation of a legacy configuration deprecation provider. + * + * @internal + * @deprecated + */ +export type LegacyConfigDeprecationProvider = ( + factory: LegacyConfigDeprecationFactory +) => LegacyConfigDeprecation[] | Promise; + +/** + * @internal + * @deprecated + */ +export interface LegacyPluginPack { + getPath(): string; +} + +/** + * @internal + * @deprecated + */ +export interface LegacyPluginSpec { + getId: () => unknown; + getExpectedKibanaVersion: () => string; + getConfigPrefix: () => string; + getDeprecationsProvider: () => LegacyConfigDeprecationProvider | undefined; +} + +/** + * @internal + * @deprecated + */ +export interface VarsProvider { + fn: (server: Server, configValue: any) => LegacyVars; + pluginSpec: { + readConfigValue(config: any, key: string | string[]): any; + }; +} + +/** + * @internal + * @deprecated + */ +export type VarsInjector = () => LegacyVars; + +/** + * @internal + * @deprecated + */ +export type VarsReplacer = ( + vars: LegacyVars, + request: LegacyRequest, + server: Server +) => LegacyVars | Promise; + +/** + * @internal + * @deprecated + */ +export type LegacyNavLinkSpec = Record & ChromeNavLink; + +/** + * @internal + * @deprecated + */ +export type LegacyAppSpec = Pick< + ChromeNavLink, + 'title' | 'order' | 'icon' | 'euiIconType' | 'url' | 'linkToLastSubUrl' | 'hidden' +> & { pluginId?: string; id?: string; listed?: boolean }; + +/** + * @internal + * @deprecated + */ +export type LegacyNavLink = Omit & { + order: number; +}; + +/** + * @internal + * @deprecated + */ +export type LegacyUiExports = SavedObjectsLegacyUiExports & { + defaultInjectedVarProviders?: VarsProvider[]; + injectedVarsReplacers?: VarsReplacer[]; + navLinkSpecs?: LegacyNavLinkSpec[] | null; + uiAppSpecs?: Array; + unknown?: [{ pluginSpec: LegacyPluginSpec; type: unknown }]; +}; + +/** + * @public + * @deprecated + */ +export interface LegacyServiceSetupDeps { + core: LegacyCoreSetup; + plugins: Record; +} + +/** + * @public + * @deprecated + */ +export interface LegacyServiceStartDeps { + core: LegacyCoreStart; + plugins: Record; +} + +/** + * @internal + * @deprecated + */ +export interface ILegacyInternals { + /** + * Inject UI app vars for a particular plugin + */ + injectUiAppVars(id: string, injector: VarsInjector): void; + + /** + * Get all the merged injected UI app vars for a particular plugin + */ + getInjectedUiAppVars(id: string): Promise; + + /** + * Get the metadata vars for a particular plugin + */ + getVars(id: string, request: LegacyRequest, injected?: LegacyVars): Promise; +} + +/** + * @internal + * @deprecated + */ +export interface LegacyPlugins { + disabledPluginSpecs: LegacyPluginSpec[]; + pluginSpecs: LegacyPluginSpec[]; + uiExports: LegacyUiExports; + navLinks: LegacyNavLink[]; +} + +/** + * @internal + * @deprecated + */ +export interface LegacyServiceDiscoverPlugins extends LegacyPlugins { + pluginExtendedConfig: LegacyConfig; + settings: LegacyVars; +} diff --git a/src/core/server/plugins/plugins_service.mock.ts b/src/core/server/plugins/plugins_service.mock.ts index 8d3c6a8c909a2f..5a52ebccbd472d 100644 --- a/src/core/server/plugins/plugins_service.mock.ts +++ b/src/core/server/plugins/plugins_service.mock.ts @@ -17,28 +17,28 @@ * under the License. */ -import { PluginsService } from './plugins_service'; +import { PluginsService, PluginsServiceSetup } from './plugins_service'; -type ServiceContract = PublicMethodsOf; -const createServiceMock = () => { - const mocked: jest.Mocked = { - discover: jest.fn(), - setup: jest.fn(), - start: jest.fn(), - stop: jest.fn(), - }; - mocked.setup.mockResolvedValue({ - contracts: new Map(), - uiPlugins: { - browserConfigs: new Map(), - internal: new Map(), - public: new Map(), - }, - }); - mocked.start.mockResolvedValue({ contracts: new Map() }); - return mocked; -}; +type PluginsServiceMock = jest.Mocked>; + +const createSetupContractMock = (): PluginsServiceSetup => ({ + contracts: new Map(), + uiPlugins: { + browserConfigs: new Map(), + internal: new Map(), + public: new Map(), + }, +}); +const createStartContractMock = () => ({ contracts: new Map() }); +const createServiceMock = (): PluginsServiceMock => ({ + discover: jest.fn(), + setup: jest.fn().mockResolvedValue(createSetupContractMock()), + start: jest.fn().mockResolvedValue(createStartContractMock()), + stop: jest.fn(), +}); export const pluginServiceMock = { create: createServiceMock, + createSetupContract: createSetupContractMock, + createStartContract: createStartContractMock, }; diff --git a/src/plugins/kibana_react/public/util/use_observable.ts b/src/core/server/rendering/__mocks__/params.ts similarity index 56% rename from src/plugins/kibana_react/public/util/use_observable.ts rename to src/core/server/rendering/__mocks__/params.ts index 6a7ce1f5290d28..392b2f0c5e2a4c 100644 --- a/src/plugins/kibana_react/public/util/use_observable.ts +++ b/src/core/server/rendering/__mocks__/params.ts @@ -17,18 +17,19 @@ * under the License. */ -import { useLayoutEffect, useState } from 'react'; -import { Observable } from 'rxjs'; +import { mockCoreContext } from '../../core_context.mock'; +import { httpServiceMock } from '../../http/http_service.mock'; +import { pluginServiceMock } from '../../plugins/plugins_service.mock'; +import { legacyServiceMock } from '../../legacy/legacy_service.mock'; -export function useObservable(observable$: Observable): T | undefined; -export function useObservable(observable$: Observable, initialValue: T): T; -export function useObservable(observable$: Observable, initialValue?: T): T | undefined { - const [value, update] = useState(initialValue); +const context = mockCoreContext.create(); +const http = httpServiceMock.createSetupContract(); +const plugins = pluginServiceMock.createSetupContract(); +const legacyPlugins = legacyServiceMock.createDiscoverPlugins(); - useLayoutEffect(() => { - const s = observable$.subscribe(update); - return () => s.unsubscribe(); - }, [observable$]); - - return value; -} +export const mockRenderingServiceParams = context; +export const mockRenderingSetupDeps = { + http, + legacyPlugins, + plugins, +}; diff --git a/src/core/server/rendering/__mocks__/rendering_service.ts b/src/core/server/rendering/__mocks__/rendering_service.ts new file mode 100644 index 00000000000000..33dca7cc0d30e5 --- /dev/null +++ b/src/core/server/rendering/__mocks__/rendering_service.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { RenderingService as Service } from '../rendering_service'; +import { RenderingServiceSetup } from '../types'; +import { mockRenderingServiceParams } from './params'; + +type IRenderingService = PublicMethodsOf; + +export const setupMock: jest.Mocked = { + render: jest.fn(), +}; +export const mockSetup = jest.fn().mockResolvedValue(setupMock); +export const mockStart = jest.fn(); +export const mockStop = jest.fn(); +export const mockRenderingService: jest.Mocked = { + setup: mockSetup, + start: mockStart, + stop: mockStop, +}; +export const RenderingService = jest.fn( + () => mockRenderingService +); diff --git a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap new file mode 100644 index 00000000000000..edde1dee85f4f8 --- /dev/null +++ b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap @@ -0,0 +1,719 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RenderingService setup() render() renders "core" from legacy request 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:core", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": false, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "core" page 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:core", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": false, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "core" page driven by settings 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:core", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object { + "theme:darkMode": Object { + "userValue": true, + }, + }, + }, + "version": Any, + }, + "legacyMode": false, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "core" page for blank basepath 1`] = ` +Object { + "basePath": "", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:core", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": false, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "core" with excluded user settings 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:core", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": false, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "legacy" page 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:legacy", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": true, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "legacy" page for blank basepath 1`] = ` +Object { + "basePath": "", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:legacy", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": true, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "legacy" with custom vars 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:legacy", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": true, + "uiPlugins": Array [], + "vars": Object { + "fake": "__TEST_TOKEN__", + }, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "legacy" with excluded user settings 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:legacy", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": true, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "legacy" with excluded user settings and custom vars 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:legacy", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": true, + "uiPlugins": Array [], + "vars": Object { + "fake": "__TEST_TOKEN__", + }, + "version": Any, +} +`; diff --git a/src/plugins/kibana_react/public/util/use_unmount.ts b/src/core/server/rendering/index.ts similarity index 87% rename from src/plugins/kibana_react/public/util/use_unmount.ts rename to src/core/server/rendering/index.ts index 009bf8c4caa1c4..233f4b26a70dbb 100644 --- a/src/plugins/kibana_react/public/util/use_unmount.ts +++ b/src/core/server/rendering/index.ts @@ -17,8 +17,5 @@ * under the License. */ -import { useEffect } from 'react'; - -export function useUnmount(fn: () => void): void { - useEffect(() => fn, []); -} +export { RenderingService } from './rendering_service'; +export * from './types'; diff --git a/src/core/server/rendering/rendering_service.test.ts b/src/core/server/rendering/rendering_service.test.ts new file mode 100644 index 00000000000000..63145f2b30573a --- /dev/null +++ b/src/core/server/rendering/rendering_service.test.ts @@ -0,0 +1,185 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { load } from 'cheerio'; + +import { httpServerMock } from '../http/http_server.mocks'; +import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; +import { mockRenderingServiceParams, mockRenderingSetupDeps } from './__mocks__/params'; +import { RenderingServiceSetup } from './types'; +import { RenderingService } from './rendering_service'; + +const INJECTED_METADATA = { + version: expect.any(String), + branch: expect.any(String), + buildNumber: expect.any(Number), + env: { + binDir: expect.any(String), + configDir: expect.any(String), + homeDir: expect.any(String), + logDir: expect.any(String), + packageInfo: { + branch: expect.any(String), + buildNum: expect.any(Number), + buildSha: expect.any(String), + version: expect.any(String), + }, + pluginSearchPaths: expect.any(Array), + staticFilesDir: expect.any(String), + }, + legacyMetadata: { + branch: expect.any(String), + buildNum: expect.any(Number), + buildSha: expect.any(String), + version: expect.any(String), + }, +}; +const { createKibanaRequest, createRawRequest } = httpServerMock; +const legacyApp = { getId: () => 'legacy' }; + +describe('RenderingService', () => { + let service: RenderingService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new RenderingService(mockRenderingServiceParams); + }); + + describe('setup()', () => { + it('creates instance of RenderingServiceSetup', async () => { + const rendering = await service.setup(mockRenderingSetupDeps); + + expect(rendering.render).toBeInstanceOf(Function); + }); + + describe('render()', () => { + let uiSettings: ReturnType; + let render: RenderingServiceSetup['render']; + + beforeEach(async () => { + uiSettings = uiSettingsServiceMock.createClient(); + uiSettings.getRegistered.mockReturnValue({ + registered: { name: 'title' }, + }); + render = (await service.setup(mockRenderingSetupDeps)).render; + }); + + it('renders "core" page', async () => { + const content = await render(createKibanaRequest(), uiSettings); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "core" page for blank basepath', async () => { + mockRenderingSetupDeps.http.basePath.get.mockReturnValueOnce(''); + + const content = await render(createKibanaRequest(), uiSettings); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "core" page driven by settings', async () => { + uiSettings.getUserProvided.mockResolvedValue({ 'theme:darkMode': { userValue: true } }); + const content = await render(createKibanaRequest(), uiSettings); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "core" with excluded user settings', async () => { + const content = await render(createKibanaRequest(), uiSettings, { + includeUserSettings: false, + }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "core" from legacy request', async () => { + const content = await render(createRawRequest(), uiSettings); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "legacy" page', async () => { + const content = await render(createRawRequest(), uiSettings, { app: legacyApp }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "legacy" page for blank basepath', async () => { + mockRenderingSetupDeps.http.basePath.get.mockReturnValueOnce(''); + + const content = await render(createRawRequest(), uiSettings, { app: legacyApp }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "legacy" with custom vars', async () => { + const content = await render(createRawRequest(), uiSettings, { + app: legacyApp, + vars: { + fake: '__TEST_TOKEN__', + }, + }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "legacy" with excluded user settings', async () => { + const content = await render(createRawRequest(), uiSettings, { + app: legacyApp, + includeUserSettings: false, + }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "legacy" with excluded user settings and custom vars', async () => { + const content = await render(createRawRequest(), uiSettings, { + app: legacyApp, + includeUserSettings: false, + vars: { + fake: '__TEST_TOKEN__', + }, + }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + }); + }); +}); diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx new file mode 100644 index 00000000000000..41810c6a106559 --- /dev/null +++ b/src/core/server/rendering/rendering_service.tsx @@ -0,0 +1,120 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { take } from 'rxjs/operators'; + +import { i18n } from '@kbn/i18n'; + +import { CoreService } from '../../types'; +import { CoreContext } from '../core_context'; +import { Template } from './views'; +import { + RenderingSetupDeps, + RenderingServiceSetup, + RenderingMetadata, + LegacyRenderOptions, +} from './types'; + +/** @internal */ +export class RenderingService implements CoreService { + constructor(private readonly coreContext: CoreContext) {} + + public async setup({ + http, + legacyPlugins, + plugins, + }: RenderingSetupDeps): Promise { + async function getUiConfig(pluginId: string) { + const browserConfig = plugins.uiPlugins.browserConfigs.get(pluginId); + + return ((await browserConfig?.pipe(take(1)).toPromise()) ?? {}) as Record; + } + + return { + render: async ( + request, + uiSettings, + { + app = { getId: () => 'core' }, + includeUserSettings = true, + vars = {}, + }: LegacyRenderOptions = {} + ) => { + const { env } = this.coreContext; + const basePath = http.basePath.get(request); + const settings = { + defaults: uiSettings.getRegistered(), + user: includeUserSettings ? await uiSettings.getUserProvided() : {}, + }; + const appId = app.getId(); + const metadata: RenderingMetadata = { + strictCsp: http.csp.strict, + uiPublicUrl: `${basePath}/ui`, + bootstrapScriptUrl: `${basePath}/bundles/app/${appId}/bootstrap.js`, + i18n: i18n.translate, + locale: i18n.getLocale(), + darkMode: settings.user?.['theme:darkMode']?.userValue + ? Boolean(settings.user['theme:darkMode'].userValue) + : false, + injectedMetadata: { + version: env.packageInfo.version, + buildNumber: env.packageInfo.buildNum, + branch: env.packageInfo.branch, + basePath, + env, + legacyMode: appId !== 'core', + i18n: { + translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, + }, + csp: { warnLegacyBrowsers: http.csp.warnLegacyBrowsers }, + vars, + uiPlugins: await Promise.all( + [...plugins.uiPlugins.public].map(async ([id, plugin]) => ({ + id, + plugin, + config: await getUiConfig(id), + })) + ), + legacyMetadata: { + app, + bundleId: `app:${appId}`, + nav: legacyPlugins.navLinks, + version: env.packageInfo.version, + branch: env.packageInfo.branch, + buildNum: env.packageInfo.buildNum, + buildSha: env.packageInfo.buildSha, + serverName: http.server.name, + devMode: env.mode.dev, + basePath, + uiSettings: settings, + }, + }, + }; + + return `${renderToStaticMarkup(