diff --git a/.gitignore b/.gitignore index fbe28b8f1e77c..1a4ffefd6d12f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .aws-config.json .signing-config.json +/api_docs .ackrc /.es /.chromium diff --git a/dev_docs/tutorials/data/search.mdx b/dev_docs/tutorials/data/search.mdx new file mode 100644 index 0000000000000..69b4d5dab58b5 --- /dev/null +++ b/dev_docs/tutorials/data/search.mdx @@ -0,0 +1,496 @@ +--- +id: kibDevTutorialDataSearchAndSessions +slug: /kibana-dev-docs/tutorials/data/search-and-sessions +title: Kibana data.search Services +summary: Kibana Search Services +date: 2021-02-10 +tags: ['kibana', 'onboarding', 'dev', 'tutorials', 'search', 'sessions', 'search-sessions'] +--- + +## Search service + +### Low level search + +Searching data stored in Elasticsearch can be done in various ways, for example using the Elasticsearch REST API or using an `Elasticsearch Client` for low level access. + +However, the recommended and easist way to search Elasticsearch is by using the low level search service. The service is exposed from the `data` plugin, and by using it, you not only gain access to the data you stored, but also to capabilities, such as Custom Search Strategies, Asynchronous Search, Partial Results, Search Sessions, and more. + +Here is a basic example for using the `data.search` service from a custom plugin: + +```ts +import { CoreStart, Plugin } from 'kibana/public'; +import { DataPublicPluginStart, isCompleteResponse, isErrorResponse } from import { DataPublicPluginStart, isCompleteResponse, isErrorResponse } from '../../src/plugins/data'; + +export interface MyPluginStartDependencies { + data: DataPublicPluginStart; +} + +export class MyPlugin implements Plugin { + public start(core: CoreStart, { data }: MyPluginStartDependencies) { + const query = { + filter: [{ + match_all: {} + }], + }; + const req = { + params: { + index: 'my-index-*', + body: { + query, + aggs: {}, + }, + } + }; + data.search.search(req).subscribe({ + next: (result) => { + if (isCompleteResponse(res)) { + // handle search result + } else if (isErrorResponse(res)) { + // handle error, this means that some results were returned, but the search has failed to complete. + } else { + // handle partial results if you want. + } + }, + error: (e) => { + // handle error thrown, for example a server hangup + }, + }) + } +} +``` + +Note: The `data` plugin contains services to help you generate the `query` and `aggs` portions, as well as managing indices using the `data.indexPatterns` service. + + + The `data.search` service is available on both server and client, with similar APIs. + + +#### Error handling + +The `search` method can throw several types of errors, for example: + + - `EsError` for errors originating in Elasticsearch errors + - `PainlessError` for errors originating from a Painless script + - `AbortError` if the search was aborted via an `AbortController` + - `HttpError` in case of a network error + +To display the errors in the context of an application, use the helper method provided on the `data.search` service. These errors are shown in a toast message, using the `core.notifications` service. + +```ts +data.search.search(req).subscribe({ + next: (result) => {}, + error: (e) => { + data.search.showError(e); + }, +}) +``` + +If you decide to handle errors by yourself, watch for errors coming from `Elasticsearch`. They have an additional `attributes` property that holds the raw error from `Elasticsearch`. + +```ts +data.search.search(req).subscribe({ + next: (result) => {}, + error: (e) => { + if (e instanceof IEsError) { + showErrorReason(e.attributes); + } + }, +}) +``` + +#### Stop a running search + +The search service `search` method supports a second argument called `options`. One of these options provides an `abortSignal` to stop searches from running to completion, if the result is no longer needed. + +```ts +import { AbortError } from '../../src/data/public'; + +const abortController = new AbortController(); +data.search.search(req, { + abortSignal: abortController.signal, +}).subscribe({ + next: (result) => { + // handle result + }, + error: (e) => { + if (e instanceof AbortError) { + // you can ignore this error + return; + } + // handle error, for example a server hangup + }, +}); + +// Abort the search request after a second +setTimeout(() => { + abortController.abort(); +}, 1000); +``` + +#### Search strategies + +By default, the search service uses the DSL query and aggregation syntax and returns the response from Elasticsearch as is. It also provides several additional basic strategies, such as Async DSL (`x-pack` default) and EQL. + +For example, to run an EQL query using the `data.search` service, you should to specify the strategy name using the options parameter: + +```ts +const req = getEqlRequest(); +data.search.search(req, { + strategy: EQL_SEARCH_STRATEGY, +}).subscribe({ + next: (result) => { + // handle EQL result + }, +}); +``` + +##### Custom search strategies + +To use a different query syntax, preprocess the request, or process the response before returning it to the client, you can create and register a custom search strategy to encapsulate your custom logic. + +The following example shows how to define, register, and use a search strategy that preprocesses the request before sending it to the default DSL search strategy, and then processes the response before returning. + +```ts +// ./myPlugin/server/myStrategy.ts + +/** + * Your custom search strategy should implement the ISearchStrategy interface, requiring at minimum a `search` function. + */ +export const mySearchStrategyProvider = ( + data: PluginStart +): ISearchStrategy => { + const preprocessRequest = (request: IMyStrategyRequest) => { + // Custom preprocessing + } + + const formatResponse = (response: IMyStrategyResponse) => { + // Custom post-processing + } + + // Get the default search strategy + const es = data.search.getSearchStrategy(ES_SEARCH_STRATEGY); + return { + search: (request, options, deps) => { + return formatResponse(es.search(preprocessRequest(request), options, deps)); + }, + }; +}; +``` + +```ts +// ./myPlugin/server/plugin.ts +import type { + CoreSetup, + CoreStart, + Plugin, +} from 'kibana/server'; + +import { mySearchStrategyProvider } from './my_strategy'; + +/** + * Your plugin will receive the `data` plugin contact in both the setup and start lifecycle hooks. + */ +export interface MyPluginSetupDeps { + data: PluginSetup; +} + +export interface MyPluginStartDeps { + data: PluginStart; +} + +/** + * In your custom server side plugin, register the strategy from the setup contract + */ +export class MyPlugin implements Plugin { + public setup( + core: CoreSetup, + deps: MyPluginSetupDeps + ) { + core.getStartServices().then(([_, depsStart]) => { + const myStrategy = mySearchStrategyProvider(depsStart.data); + deps.data.search.registerSearchStrategy('myCustomStrategy', myStrategy); + }); + } +} +``` + +```ts +// ./myPlugin/public/plugin.ts +const req = getRequest(); +data.search.search(req, { + strategy: 'myCustomStrategy', +}).subscribe({ + next: (result) => { + // handle result + }, +}); +``` + +##### Async search and custom async search strategies + +The open source default search strategy (`ES_SEARCH_STRATEGY`), run searches synchronously, keeping an open connection to Elasticsearch while the query executes. The duration of these queries is restricted by the `elasticsearch.requestTimeout` setting in `kibana.yml`, which is 30 seconds by default. + +This synchronous execution works great in most cases. However, with the introduction of features such as `data tiers` and `runtime fields`, the need to allow slower-running queries, where holding an open connection might be inefficient, has increased. In 7.7, `Elasticsearch` introduced the [async_search API](https://www.elastic.co/guide/en/elasticsearch/reference/current/async-search.html), allowing a query to run longer without keeping an open connection. Instead, the initial search request returns an ID that identifies the search running in `Elasticsearch`. This ID can then be used to retrieve, cancel, or manage the search result. + +The [async_search API](https://www.elastic.co/guide/en/elasticsearch/reference/current/async-search.html) is what drives more advanced `Kibana` `search` features, such as `partial results` and `search sessions`. [When available](https://www.elastic.co/subscriptions), the default search strategy of `Kibana` is automatically set to the **async** default search strategy (`ENHANCED_ES_SEARCH_STRATEGY`), empowering Kibana to run longer queries, with an **optional** duration restriction defined by the UI setting `search:timeout`. + +If you are implementing your own async custom search strategy, make sure to implement `cancel` and `extend`, as shown in the following example: + +```ts +// ./myPlugin/server/myEnhancedStrategy.ts +export const myEnhancedSearchStrategyProvider = ( + data: PluginStart +): ISearchStrategy => { + // Get the default search strategy + const ese = data.search.getSearchStrategy(ENHANCED_ES_SEARCH_STRATEGY); + return { + search: (request, options, deps) => { + // search will be called multiple times, + // be sure your response formatting is capable of handling partial results, as well as the final result. + return formatResponse(ese.search(request, options, deps)); + }, + cancel: async (id, options, deps) => { + // call the cancel method of the async strategy you are using or implement your own cancellation function. + await ese.cancel(id, options, deps); + }, + extend: async (id, keepAlive, options, deps) => { + // async search results are not stored indefinitely. By default, they expire after 7 days (or as defined by xpack.data_enhanced.search.sessions.defaultExpiration setting in kibana.yml). + // call the extend method of the async strategy you are using or implement your own extend function. + await ese.extend(id, options, deps); + }, + }; +}; +``` + +### High level search + +The high level search service is a simplified way to create and run search requests, without writing custom DSL queries. + +#### Search source + +```ts +function searchWithSearchSource() { + const indexPattern = data.indexPatterns.getDefault(); + const query = data.query.queryString.getQuery(); + const filters = data.query.filterManager.getFilters(); + const timefilter = data.query.timefilter.timefilter.createFilter(indexPattern); + if (timefilter) { + filters.push(timefilter); + } + + const searchSource = await data.search.searchSource.create(); + + searchSource + .setField('index', indexPattern) + .setField('filter', filters) + .setField('query', query) + .setField('fields', selectedFields.length ? selectedFields.map((f) => f.name) : ['*']) + .setField('aggs', getAggsDsl()); + + searchSource.fetch$().subscribe({ + next: () => {}, + error: () => {}, + }); +} +``` + +### Partial results + +When searching using an `async` strategy (such as async DSL and async EQL), the search service will stream back partial results. + +Although you can ignore the partial results and wait for the final result before rendering, you can also use the partial results to create a more interactive experience for your users. It is highly advised, however, to make sure users are aware that the results they are seeing are partial. + +```ts +// Handling partial results +data.search.search(req).subscribe({ + next: (result) => { + if (isCompleteResponse(res)) { + renderFinalResult(res); + } else if (isPartialResponse(res)) { + renderPartialResult(res); + } + }, +}) + +// Skipping partial results +const finalResult = await data.search.search(req).toPromise(); +``` + +### Search sessions + +A search session is a higher level concept than search. A search session describes a grouping of one or more async search requests with additional context. + +Search sessions are handy when you want to enable a user to run something asynchronously (for example, a dashboard over a long period of time), and then quickly restore the results at a later time. The `Search Service` transparently fetches results from the `.async-search` index, instead of running each request again. + +Internally, any search run within a search session is saved into an object, allowing Kibana to manage their lifecycle. Most saved objects are deleted automatically after a short period of time, but if a user chooses to save the search session, the saved object is persisted, so that results can be restored in a later time. + +Stored search sessions are listed in the *Management* application, under *Kibana > Search Sessions*, making it easy to find, manage, and restore them. + +As a developer, you might encounter these two common, use cases: + + * Running a search inside an existing search session + * Supporting search sessions in your application + +#### Running a search inside an existing search session + +For this example, assume you are implementing a new type of `Embeddable` that will be shown on dashboards. The same principle applies, however, to any search requests that you are running, as long as the application you are running inside is managing an active session. + +Because the Dashboard application is already managing a search session, all you need to do is pass down the `searchSessionId` argument to any `search` call. This applies to both the low and high level search APIs. + +The search information will be added to the saved object for the search session. + +```ts +export class SearchEmbeddable + extends Embeddable { + + private async fetchData() { + // Every embeddable receives an optional `searchSessionId` input parameter. + const { searchSessionId } = this.input; + + // Setup your search source + this.configureSearchSource(); + + try { + // Mark the embeddable as loading + this.updateOutput({ loading: true, error: undefined }); + + // Make the request, wait for the final result + const resp = await searchSource.fetch$({ + sessionId: searchSessionId, + }).toPromise(); + + this.useSearchResult(resp); + + this.updateOutput({ loading: false, error: undefined }); + } catch (error) { + // handle search errors + this.updateOutput({ loading: false, error }); + } + } +} + +``` + +You can also retrieve the active `Search Session ID` from the `Search Service` directly: + +```ts +async function fetchData(data: DataPublicPluginStart) { + try { + return await searchSource.fetch$({ + sessionId: data.search.sessions.getSessionId(), + }).toPromise(); + } catch (e) { + // handle search errors + } +} + +``` + + + Search sessions are initiated by the client. If you are using a route that runs server side searches, you can send the `searchSessionId` to the server, and then pass it down to the server side `data.search` function call. + + +#### Supporting search sessions in your application + +Before implementing the ability to create and restore search sessions in your application, ask yourself the following questions: + +1. **Does your application normally run long operations?** For example, it makes sense for a user to generate a Dashboard or a Canvas report from data stored in cold storage. However, when editing a single visualization, it is best to work with a shorter timeframe of hot or warm data. +2. **Does it make sense for your application to restore a search session?** For example, you might want to restore an interesting configuration of filters of older documents you found in Discover. However, a single Lens or Map visualization might not be as helpful, outside the context of a specific dashboard. +3. **What is a search session in the context of your application?** Although Discover and Dashboard start a new search session every time the time range or filters change, or when the user clicks **Refresh**, you can manage your sessions differently. For example, if your application has tabs, you might group searches from multiple tabs into a single search session. You must be able to clearly define the **state** used to create the search session`. The **state** refers to any setting that might change the queries being set to `Elasticsearch`. + +Once you answer those questions, proceed to implement the following bits of code in your application. + +##### Provide storage configuration + +In your plugin's `start` lifecycle method, call the `enableStorage` method. This method helps the `Session Service` gather the information required to save the search sessions upon a user's request and construct the restore state: + +```ts +export class MyPlugin implements Plugin { + public start(core: CoreStart, { data }: MyPluginStartDependencies) { + const sessionRestorationDataProvider: SearchSessionInfoProvider = { + data, + getDashboard + } + + data.search.session.enableStorage({ + getName: async () => { + // return the name you want to give the saved Search Session + return `MyApp_${Math.random()}`; + }, + getUrlGeneratorData: async () => { + return { + urlGeneratorId: MY_URL_GENERATOR, + initialState: getUrlGeneratorState({ ...deps, shouldRestoreSearchSession: false }), + restoreState: getUrlGeneratorState({ ...deps, shouldRestoreSearchSession: true }), + }; + }, + }); + } +} +``` + + + The restore state of a search session may be different from the initial state used to create it. For example, where the initial state may contain relative dates, in the restore state, those must be converted to absolute dates. Read more about the [NowProvider](). + + + + Calling `enableStorage` will also enable the `Search Session Indicator` component in the chrome component of your solution. The `Search Session Indicator` is a small button, used by default to engage users and save new search sessions. To implement your own UI, contact the Kibana application services team to decouple this behavior. + + +##### Start a new search session + +Make sure to call `start` when the **state** you previously defined changes. + +```ts + +function onSearchSessionConfigChange() { + this.searchSessionId = data.search.sessions.start(); +} + +``` + +Pass the `searchSessionId` to every `search` call inside your application. If you're using `Embeddables`, pass down the `searchSessionId` as `input`. + +If you can't pass the `searchSessionId` directly, you can retrieve it from the service. + +```ts +const currentSearchSessionId = data.search.sessions.getSessionId(); + +``` + +##### Clear search sessions + +Creating a new search session clears the previous one. You must explicitly `clear` the search session when your application is being destroyed: + +```ts +function onDestroy() { + data.search.session.clear(); +} + +``` + +If you don't call `clear`, you will see a warning in the console while developing. However, when running in production, you will get a fatal error. This is done to avoid leakage of unrelated search requests into an existing search session left open by mistake. + +##### Restore search sessions + +The last step of the integration is restoring an existing search session. The `searchSessionId` parameter and the rest of the restore state are passed into the application via the URL. Non-URL support is planned for future releases. + +If you detect the presense of a `searchSessionId` parameter in the URL, call the `restore` method **instead** of calling `start`. The previous example would now become: + +```ts + +function onSearchSessionConfigChange(searchSessionIdFromUrl?: string) { + if (searchSessionIdFromUrl) { + data.search.sessions.restore(searchSessionIdFromUrl); + } else { + data.search.sessions.start(); + } +} + +``` + +Once you `restore` the session, as long as all `search` requests run with the same `searchSessionId`, the search session should be seamlessly restored. + +##### Customize the user experience + +TBD diff --git a/docs/api/using-api.asciidoc b/docs/api/using-api.asciidoc deleted file mode 100644 index d66718be4074a..0000000000000 --- a/docs/api/using-api.asciidoc +++ /dev/null @@ -1,86 +0,0 @@ -[[using-api]] -== Using the APIs - -Interact with the {kib} APIs through the `curl` command and HTTP and HTTPs protocols. - -It is recommended that you use HTTPs on port 5601 because it is more secure. - -NOTE: The {kib} Console supports only Elasticsearch APIs. You are unable to interact with the {kib} APIs with the Console and must use `curl` or another HTTP tool instead. For more information, refer to <>. - -[float] -[[api-authentication]] -=== Authentication -The {kib} APIs support key- and token-based authentication. - -[float] -[[token-api-authentication]] -==== Token-based authentication - -To use token-based authentication, you use the same username and password that you use to log into Elastic. -In a given HTTP tool, and when available, you can select to use its 'Basic Authentication' option, -which is where the username and password are stored in order to be passed as part of the call. - -[float] -[[key-authentication]] -==== Key-based authentication - -To use key-based authentication, you create an API key using the Elastic Console, then specify the key in the header of your API calls. - -For information about API keys, refer to <>. - -[float] -[[api-calls]] -=== API calls -API calls are stateless. Each request that you make happens in isolation from other calls and must include all of the necessary information for {kib} to fulfill the request. API requests return JSON output, which is a format that is machine-readable and works well for automation. - -Calls to the API endpoints require different operations. To interact with the {kib} APIs, use the following operations: - -* *GET* - Fetches the information. - -* *POST* - Adds new information. - -* *PUT* - Updates the existing information. - -* *DELETE* - Removes the information. - -For example, the following `curl` command exports a dashboard: - -[source,sh] --- -curl -X POST api/kibana/dashboards/export?dashboard=942dcef0-b2cd-11e8-ad8e-85441f0c2e5c --- -// KIBANA - -[float] -[[api-request-headers]] -=== Request headers - -For all APIs, you must use a request header. The {kib} APIs support the `kbn-xsrf` and `Content-Type` headers. - -`kbn-xsrf: true`:: - By default, you must use `kbn-xsrf` for all API calls, except in the following scenarios: - -* The API endpoint uses the `GET` or `HEAD` operations -* The path is allowed using the <> setting -* XSRF protections are disabled using the <> setting - -`Content-Type: application/json`:: - Applicable only when you send a payload in the API request. {kib} API requests and responses use JSON. - Typically, if you include the `kbn-xsrf` header, you must also include the `Content-Type` header. - -Request header example: - -[source,sh] --- -curl -X POST \ - http://localhost:5601/api/spaces/space \ - -H 'Content-Type: application/json' \ - -H 'kbn-xsrf: true' \ - -d '{ - "id": "sales", - "name": "Sales", - "description": "This is your Sales Space!", - "disabledFeatures": [] -} -' --- diff --git a/docs/canvas/canvas-function-reference.asciidoc b/docs/canvas/canvas-function-reference.asciidoc index 6a6c840074f02..eaaf68eb06195 100644 --- a/docs/canvas/canvas-function-reference.asciidoc +++ b/docs/canvas/canvas-function-reference.asciidoc @@ -1697,6 +1697,16 @@ Aliases: `column`, `name` Aliases: `exp`, `fn`, `function` |`boolean`, `number`, `string`, `null` |A Canvas expression that is passed to each row as a single row `datatable`. + +|`id` + +|`string`, `null` +|An optional id of the resulting column. When not specified or `null` the name argument is used as id. + +|`copyMetaFrom` + +|`string`, `null` +|If set, the meta object from the specified column id is copied over to the specified target column. Throws an exception if the column doesn't exist |=== *Returns:* `datatable` @@ -1755,9 +1765,16 @@ Interprets a `TinyMath` math expression using a `number` or `datatable` as _cont Alias: `expression` |`string` |An evaluated `TinyMath` expression. See https://www.elastic.co/guide/en/kibana/current/canvas-tinymath-functions.html. + +|`onError` + +|`string` +|In case the `TinyMath` evaluation fails or returns NaN, the return value is specified by onError. For example, `"null"`, `"zero"`, `"false"`, `"throw"`. When `"throw"`, it will throw an exception, terminating expression execution. + +Default: `"throw"` |=== -*Returns:* `number` +*Returns:* `number` | `boolean` | `null` [float] diff --git a/docs/canvas/canvas-share-workpad.asciidoc b/docs/canvas/canvas-share-workpad.asciidoc index 0df8357bb3bd6..68078b74da171 100644 --- a/docs/canvas/canvas-share-workpad.asciidoc +++ b/docs/canvas/canvas-share-workpad.asciidoc @@ -4,6 +4,8 @@ When you've finished your workpad, you can share it outside of {kib}. +For information on how to create PDFs and POST URLs, refer to <>. + [float] [[export-single-workpad]] === Export workpads @@ -17,37 +19,12 @@ image::images/canvas-export-workpad.png[Export single workpad through JSON, from Want to export multiple workpads? Go to the *Canvas* home page, select the workpads you want to export, then click *Export*. -[float] -[[create-workpad-pdf]] -=== Create a PDF - -If you have a subscription that supports the {report-features}, you can create a PDF copy of your workpad that you can save and share outside {kib}. - -To begin, click *Share > PDF reports > Generate PDF*. - -[role="screenshot"] -image::images/canvas-generate-pdf.gif[Image showing how to generate a PDF] - -For more information, refer to <>. - -[float] -[[create-workpad-URL]] -=== Create a POST URL - -If you have a subscription that supports the {report-features}, you can create a POST URL that you can use to automatically generate PDF reports using <> or a script. - -To begin, click *Share > PDF reports > Advanced options > Copy POST URL*. - -[role="screenshot"] -image::images/canvas-create-URL.gif[Image showing how to create POST URL] - -For more information, refer to <>. - [float] [[add-workpad-website]] === Share the workpad on a website -beta[] Canvas allows you to create _shareables_, which are workpads that you download and securely share on any website. To customize the behavior of the workpad on your website, you can choose to autoplay the pages or hide the workpad toolbar. +beta[] Canvas allows you to create _shareables_, which are workpads that you download and securely share on any website. +To customize the behavior of the workpad on your website, you can choose to autoplay the pages or hide the workpad toolbar. . Click *Share > Share on a website*. diff --git a/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md b/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md index a50df950628b3..f6de959589eca 100644 --- a/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md +++ b/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md @@ -8,5 +8,5 @@ Signature: ```typescript -export declare type AppenderConfigType = ConsoleAppenderConfig | FileAppenderConfig | LegacyAppenderConfig | RollingFileAppenderConfig; +export declare type AppenderConfigType = ConsoleAppenderConfig | FileAppenderConfig | LegacyAppenderConfig | RewriteAppenderConfig | RollingFileAppenderConfig; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md index b0182a7c48e16..bd15b95d73ace 100644 --- a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md +++ b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md @@ -28,6 +28,7 @@ Should never be used in code outside of Core but is exported for documentation p | [requiredBundles](./kibana-plugin-core-server.pluginmanifest.requiredbundles.md) | readonly string[] | List of plugin ids that this plugin's UI code imports modules from that are not in requiredPlugins. | | [requiredPlugins](./kibana-plugin-core-server.pluginmanifest.requiredplugins.md) | readonly PluginName[] | An optional list of the other plugins that \*\*must be\*\* installed and enabled for this plugin to function properly. | | [server](./kibana-plugin-core-server.pluginmanifest.server.md) | boolean | Specifies whether plugin includes some server-side specific functionality. | +| [serviceFolders](./kibana-plugin-core-server.pluginmanifest.servicefolders.md) | readonly string[] | Only used for the automatically generated API documentation. Specifying service folders will cause your plugin API reference to be broken up into sub sections. | | [ui](./kibana-plugin-core-server.pluginmanifest.ui.md) | boolean | Specifies whether plugin includes some client/browser specific functionality that should be included into client bundle via public/ui_plugin.js file. | | [version](./kibana-plugin-core-server.pluginmanifest.version.md) | string | Version of the plugin. | diff --git a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.servicefolders.md b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.servicefolders.md new file mode 100644 index 0000000000000..8ee33bdfa0f3f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.servicefolders.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [PluginManifest](./kibana-plugin-core-server.pluginmanifest.md) > [serviceFolders](./kibana-plugin-core-server.pluginmanifest.servicefolders.md) + +## PluginManifest.serviceFolders property + +Only used for the automatically generated API documentation. Specifying service folders will cause your plugin API reference to be broken up into sub sections. + +Signature: + +```typescript +readonly serviceFolders?: readonly string[]; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternselectprops.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternselectprops.md index 5cfd5e1bc9929..80f4832ba5643 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternselectprops.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternselectprops.md @@ -12,5 +12,6 @@ export declare type IndexPatternSelectProps = Required void; + maxIndexPatterns?: number; }; ``` diff --git a/docs/index.asciidoc b/docs/index.asciidoc index eb6f794434f8a..b91af2ab51ebf 100644 --- a/docs/index.asciidoc +++ b/docs/index.asciidoc @@ -7,7 +7,7 @@ :blog-ref: https://www.elastic.co/blog/ :wikipedia: https://en.wikipedia.org/wiki -include::{docs-root}/shared/versions/stack/{source_branch}.asciidoc[] +include::{docs-root}/shared/versions/stack/7.10.asciidoc[] :docker-repo: docker.elastic.co/kibana/kibana :docker-image: docker.elastic.co/kibana/kibana:{version} diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index fa44d3a6ae41b..d6f5fb1baba8e 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -21,9 +21,12 @@ You can configure the following settings in the `kibana.yml` file. [cols="2*<"] |=== -| `xpack.encryptedSavedObjects.encryptionKey` +| `xpack.encryptedSavedObjects` +`.encryptionKey` | A string of 32 or more characters used to encrypt sensitive properties on alerts and actions before they're stored in {es}. Third party credentials — such as the username and password used to connect to an SMTP service — are an example of encrypted properties. + + + {kib} offers a <> to help generate this encryption key. + + + If not set, {kib} will generate a random key on startup, but all alert and action functions will be blocked. Generated keys are not allowed for alerts and actions because when a new key is generated on restart, existing encrypted data becomes inaccessible. For the same reason, alerts and actions in high-availability deployments of {kib} will behave unexpectedly if the key isn't the same on all instances of {kib}. + + Although the key can be specified in clear text in `kibana.yml`, it's recommended to store this key securely in the <>. diff --git a/docs/user/alerting/defining-alerts.asciidoc b/docs/user/alerting/defining-alerts.asciidoc index 5f13c6e1774a8..77a4e5cc41ef2 100644 --- a/docs/user/alerting/defining-alerts.asciidoc +++ b/docs/user/alerting/defining-alerts.asciidoc @@ -59,7 +59,7 @@ image::images/alert-flyout-action-type-selection.png[UI for selecting an action When an alert instance matches a condition, the alert is marked as _Active_ and assigned an action group. The actions in that group are triggered. When the condition is no longer detected, the alert is assigned to the _Recovered_ action group, which triggers any actions assigned to that group. -**Run When** allows you to assign an action to an _action group_. This will trigger the action in accordance with your **Notify every** setting. +**Run When** allows you to assign an action to an action group. This will trigger the action in accordance with your **Notify** setting. Each action must specify a <> instance. If no connectors exist for that action type, click *Add action* to create one. @@ -68,7 +68,20 @@ Each action type exposes different properties. For example an email action allow [role="screenshot"] image::images/alert-flyout-action-details.png[UI for defining an email action] -Using the https://mustache.github.io/[Mustache] template syntax `{{variable name}}`, you can pass alert values at the time a condition is detected to an action. Available variables differ by alert type, and the list of available variables can be accessed using the "add variable" button. +[float] +==== Action variables +Using the https://mustache.github.io/[Mustache] template syntax `{{variable name}}`, you can pass alert values at the time a condition is detected to an action. You can access the list of available variables using the "add variable" button. Although available variables differ by alert type, all alert types pass the following variables: + +`alertId`:: The ID of the alert. +`alertName`:: The name of the alert. +`spaceId`:: The ID of the space for the alert. +`tags`:: The list of tags applied to the alert. +`date`:: The date the alert scheduled the action, in ISO format. +`alertInstanceId`:: The ID of the alert instance that scheduled the action. +`alertActionGroup`:: The ID of the action group of the alert instance that scheduled the action. +`alertActionSubgroup`:: The action subgroup of the alert instance that scheduled the action. +`alertActionGroupName`:: The name of the action group of the alert instance that scheduled the action. +`kibanaBaseUrl`:: The configured <>. If not configured, this will be empty. [role="screenshot"] image::images/alert-flyout-action-variables.png[Passing alert values to an action] diff --git a/docs/user/alerting/images/alert-types-es-query-example-action-variable.png b/docs/user/alerting/images/alert-types-es-query-example-action-variable.png new file mode 100644 index 0000000000000..7e40499d78fdd Binary files /dev/null and b/docs/user/alerting/images/alert-types-es-query-example-action-variable.png differ diff --git a/docs/user/alerting/images/alert-types-index-threshold-conditions.png b/docs/user/alerting/images/alert-types-index-threshold-conditions.png index 5d66123ac733e..062b0a426b5d8 100644 Binary files a/docs/user/alerting/images/alert-types-index-threshold-conditions.png and b/docs/user/alerting/images/alert-types-index-threshold-conditions.png differ diff --git a/docs/user/alerting/images/alert-types-index-threshold-example-aggregation.png b/docs/user/alerting/images/alert-types-index-threshold-example-aggregation.png index 055b643ec3458..a43c4bf1f0d37 100644 Binary files a/docs/user/alerting/images/alert-types-index-threshold-example-aggregation.png and b/docs/user/alerting/images/alert-types-index-threshold-example-aggregation.png differ diff --git a/docs/user/alerting/images/alert-types-index-threshold-example-grouping.png b/docs/user/alerting/images/alert-types-index-threshold-example-grouping.png index 5be81b45612bc..9f4c2ccbec3c0 100644 Binary files a/docs/user/alerting/images/alert-types-index-threshold-example-grouping.png and b/docs/user/alerting/images/alert-types-index-threshold-example-grouping.png differ diff --git a/docs/user/alerting/images/alert-types-index-threshold-example-index.png b/docs/user/alerting/images/alert-types-index-threshold-example-index.png index b13201ce5d38a..b2f1c78f7add8 100644 Binary files a/docs/user/alerting/images/alert-types-index-threshold-example-index.png and b/docs/user/alerting/images/alert-types-index-threshold-example-index.png differ diff --git a/docs/user/alerting/images/alert-types-index-threshold-example-preview.png b/docs/user/alerting/images/alert-types-index-threshold-example-preview.png index 70e1355004c47..19ad52c45da1c 100644 Binary files a/docs/user/alerting/images/alert-types-index-threshold-example-preview.png and b/docs/user/alerting/images/alert-types-index-threshold-example-preview.png differ diff --git a/docs/user/alerting/images/alert-types-index-threshold-example-threshold.png b/docs/user/alerting/images/alert-types-index-threshold-example-threshold.png index 7e9432d8c8678..9d9262dd96a1e 100644 Binary files a/docs/user/alerting/images/alert-types-index-threshold-example-threshold.png and b/docs/user/alerting/images/alert-types-index-threshold-example-threshold.png differ diff --git a/docs/user/alerting/images/alert-types-index-threshold-example-timefield.png b/docs/user/alerting/images/alert-types-index-threshold-example-timefield.png index 4b1eaa631dc98..e7b13ed6e2cc0 100644 Binary files a/docs/user/alerting/images/alert-types-index-threshold-example-timefield.png and b/docs/user/alerting/images/alert-types-index-threshold-example-timefield.png differ diff --git a/docs/user/alerting/images/alert-types-index-threshold-example-window.png b/docs/user/alerting/images/alert-types-index-threshold-example-window.png index b4b272d2a241a..9b8e9a47ae91e 100644 Binary files a/docs/user/alerting/images/alert-types-index-threshold-example-window.png and b/docs/user/alerting/images/alert-types-index-threshold-example-window.png differ diff --git a/docs/user/alerting/images/alert-types-index-threshold-preview.png b/docs/user/alerting/images/alert-types-index-threshold-preview.png index b3b868dbc41e8..2065cbd117b75 100644 Binary files a/docs/user/alerting/images/alert-types-index-threshold-preview.png and b/docs/user/alerting/images/alert-types-index-threshold-preview.png differ diff --git a/docs/user/alerting/images/alert-types-index-threshold-select.png b/docs/user/alerting/images/alert-types-index-threshold-select.png index 18c28a703e966..7a68d8815b6d9 100644 Binary files a/docs/user/alerting/images/alert-types-index-threshold-select.png and b/docs/user/alerting/images/alert-types-index-threshold-select.png differ diff --git a/docs/user/alerting/stack-alerts/es-query.asciidoc b/docs/user/alerting/stack-alerts/es-query.asciidoc index 772178c9552da..9f4a882328b9f 100644 --- a/docs/user/alerting/stack-alerts/es-query.asciidoc +++ b/docs/user/alerting/stack-alerts/es-query.asciidoc @@ -28,6 +28,27 @@ condition. Aggregations are not supported at this time. Threshold:: This clause defines a threshold value and a comparison operator (`is above`, `is above or equals`, `is below`, `is below or equals`, or `is between`). The number of documents that match the specified query is compared to this threshold. Time window:: This clause determines how far back to search for documents, using the *time field* set in the *index* clause. Generally this value should be set to a value higher than the *check every* value in the <>, to avoid gaps in detection. +[float] +==== Action variables + +When the ES query alert condition is met, the following variables are available to use inside each action: + +`context.title`:: A preconstructed title for the alert. Example: `alert term match alert query matched`. +`context.message`:: A preconstructed message for the alert. Example: + +`alert 'term match alert' is active:` + +`- Value: 42` + +`- Conditions Met: count greater than 4 over 5m` + +`- Timestamp: 2020-01-01T00:00:00.000Z` + +`context.group`:: The name of the action group associated with the condition. Example: `query matched`. +`context.date`:: The date, in ISO format, that the alert met the condition. Example: `2020-01-01T00:00:00.000Z`. +`context.value`:: The value of the alert that met the condition. +`context.conditions`:: A description of the condition. Example: `count greater than 4`. +`context.hits`:: The most recent ES documents that matched the query. Using the https://mustache.github.io/[Mustache] template array syntax, you can iterate over these hits to get values from the ES documents into your actions. + +[role="screenshot"] +image::images/alert-types-es-query-example-action-variable.png[Iterate over hits using Mustache template syntax] + [float] ==== Testing your query diff --git a/docs/user/alerting/stack-alerts/index-threshold.asciidoc b/docs/user/alerting/stack-alerts/index-threshold.asciidoc index 424320aea3adc..6b45f69401c4a 100644 --- a/docs/user/alerting/stack-alerts/index-threshold.asciidoc +++ b/docs/user/alerting/stack-alerts/index-threshold.asciidoc @@ -31,6 +31,23 @@ If data is available and all clauses have been defined, a preview chart will ren [role="screenshot"] image::user/alerting/images/alert-types-index-threshold-preview.png[Five clauses define the condition to detect] +[float] +==== Action variables + +When the index threshold alert condition is met, the following variables are available to use inside each action: + +`context.title`:: A preconstructed title for the alert. Example: `alert kibana sites - high egress met threshold`. +`context.message`:: A preconstructed message for the alert. Example: + +`alert 'kibana sites - high egress' is active for group 'threshold met':` + +`- Value: 42` + +`- Conditions Met: count greater than 4 over 5m` + +`- Timestamp: 2020-01-01T00:00:00.000Z` + +`context.group`:: The name of the action group associated with the threshold condition. Example: `threshold met`. +`context.date`:: The date, in ISO format, that the alert met the threshold condition. Example: `2020-01-01T00:00:00.000Z`. +`context.value`:: The value for the alert that met the threshold condition. +`context.conditions`:: A description of the threshold condition. Example: `count greater than 4` + [float] ==== Example diff --git a/docs/user/api.asciidoc b/docs/user/api.asciidoc index 9916ab42186dc..459dbbdd34b27 100644 --- a/docs/user/api.asciidoc +++ b/docs/user/api.asciidoc @@ -1,37 +1,99 @@ [[api]] = REST API -[partintro] --- Some {kib} features are provided via a REST API, which is ideal for creating an integration with {kib}, or automating certain aspects of configuring and deploying {kib}. -Each API is experimental and can include breaking changes in any version of -{kib}, or might be entirely removed from {kib}. +[float] +[[using-apis]] +== Using the APIs -//// -Each API has one of the following labels: +Interact with the {kib} APIs through the `curl` command and HTTP and HTTPs protocols. -* *Stable* APIs should be safe to use extensively in production. Any breaking -changes to these APIs should only occur in major versions and will be -clearly documented in the breaking changes documentation for that release. +It is recommended that you use HTTPs on port 5601 because it is more secure. -* *Beta* APIs are on track to become stable, permanent features of {kib}. -Caution should be exercised in their use since it is possible we'd have to make -a breaking change to these APIs in a minor version, but we'll avoid this -wherever possible. +NOTE: The {kib} Console supports only Elasticsearch APIs. You are unable to interact with the {kib} APIs with the Console and must use `curl` or another HTTP tool instead. For more information, refer to <>. -* *Experimental* APIs are just that - an experiment. An experimental API might -have breaking changes in any version of {kib}, or it might even be removed -entirely. +[float] +[[api-authentication]] +=== Authentication +The {kib} APIs support key- and token-based authentication. -If a label is missing from an API, it is considered `experimental`. -//// +[float] +[[token-api-authentication]] +==== Token-based authentication + +To use token-based authentication, you use the same username and password that you use to log into Elastic. +In a given HTTP tool, and when available, you can select to use its 'Basic Authentication' option, +which is where the username and password are stored in order to be passed as part of the call. + +[float] +[[key-authentication]] +==== Key-based authentication + +To use key-based authentication, you create an API key using the Elastic Console, then specify the key in the header of your API calls. + +For information about API keys, refer to <>. + +[float] +[[api-calls]] +=== API calls +API calls are stateless. Each request that you make happens in isolation from other calls and must include all of the necessary information for {kib} to fulfill the request. API requests return JSON output, which is a format that is machine-readable and works well for automation. + +Calls to the API endpoints require different operations. To interact with the {kib} APIs, use the following operations: + +* *GET* - Fetches the information. + +* *POST* - Adds new information. + +* *PUT* - Updates the existing information. + +* *DELETE* - Removes the information. + +For example, the following `curl` command exports a dashboard: + +[source,sh] +-------------------------------------------- +curl -X POST api/kibana/dashboards/export?dashboard=942dcef0-b2cd-11e8-ad8e-85441f0c2e5c +-------------------------------------------- +// KIBANA + +[float] +[[api-request-headers]] +=== Request headers + +For all APIs, you must use a request header. The {kib} APIs support the `kbn-xsrf` and `Content-Type` headers. + +`kbn-xsrf: true`:: + By default, you must use `kbn-xsrf` for all API calls, except in the following scenarios: + +* The API endpoint uses the `GET` or `HEAD` operations +* The path is allowed using the <> setting +* XSRF protections are disabled using the <> setting + +`Content-Type: application/json`:: + Applicable only when you send a payload in the API request. {kib} API requests and responses use JSON. + Typically, if you include the `kbn-xsrf` header, you must also include the `Content-Type` header. + +Request header example: + +[source,sh] +-------------------------------------------- +curl -X POST \ + http://localhost:5601/api/spaces/space \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: true' \ + -d '{ + "id": "sales", + "name": "Sales", + "description": "This is your Sales Space!", + "disabledFeatures": [] +} +' +-------------------------------------------- --- -include::{kib-repo-dir}/api/using-api.asciidoc[] include::{kib-repo-dir}/api/features.asciidoc[] include::{kib-repo-dir}/api/spaces-management.asciidoc[] include::{kib-repo-dir}/api/role-management.asciidoc[] diff --git a/docs/user/dashboard/dashboard.asciidoc b/docs/user/dashboard/dashboard.asciidoc index 3c86c37f1fd30..d65fef9a512d7 100644 --- a/docs/user/dashboard/dashboard.asciidoc +++ b/docs/user/dashboard/dashboard.asciidoc @@ -253,10 +253,8 @@ When you're finished making changes, save the dashboard. To share the dashboard with a larger audience, click *Share* in the toolbar, then choose one of the following options: -* *Embed code* — Embed the dashboard as an iframe on a web page. Embedded dashboards are fully interactive, but you can - hide some parts of the dashboard using the menu options. User authentication is required, which the user provides or via reverse proxy.  - <> is also supported. For more details, - learn about <>. +* *Embed code* — Embed a fully interactive dashboard as an iframe on a web page. To access embedded dashboards, you can require users to +log in using their {kib} credentials, via reverse proxy, or enable <>. * *Permalinks* — Share a direct link to a {kib} dashboard. User authentication is required. diff --git a/docs/user/reporting/automating-report-generation.asciidoc b/docs/user/reporting/automating-report-generation.asciidoc index 413573e7ec182..4c5a87782618e 100644 --- a/docs/user/reporting/automating-report-generation.asciidoc +++ b/docs/user/reporting/automating-report-generation.asciidoc @@ -8,17 +8,16 @@ include::report-intervals.asciidoc[] [float] === Create a POST URL -Create the POST -URL that triggers a report to generate. +Create the POST URL that triggers a report to generate PDF and CSV reports. To create the POST URL for PDF reports: -. Open then main menu, click *Dashboard*, then open a dashboard. -+ -To specify a relative or absolute time period, use the time filter. +. Open the  dashboard, visualization, or **Canvas** workpad. . From the {kib} toolbar, click *Share*, then select *PDF Reports*. +. If you are using **Canvas**, click *Advanced options*. + . Click *Copy POST URL*. + [role="screenshot"] @@ -27,9 +26,7 @@ image::images/report-automate-pdf.png[Generate Visualize and Dashboard reports] To create the POST URL for CSV reports: -. Load the saved search in *Discover*. -+ -To specify a relative or absolute time period, use the time filter. +. In *Discover*, open the saved search. . From the {kib} toolbar, click *Share*, then select *CSV Reports*. diff --git a/docs/user/reporting/images/canvas-full-page-layout.png b/docs/user/reporting/images/canvas-full-page-layout.png index 06c94f05531b2..518a4bbb98b17 100644 Binary files a/docs/user/reporting/images/canvas-full-page-layout.png and b/docs/user/reporting/images/canvas-full-page-layout.png differ diff --git a/docs/user/reporting/images/preserve-layout-switch.png b/docs/user/reporting/images/preserve-layout-switch.png index 0aaefb14d7ee5..ed03d8237d0ba 100644 Binary files a/docs/user/reporting/images/preserve-layout-switch.png and b/docs/user/reporting/images/preserve-layout-switch.png differ diff --git a/docs/user/reporting/images/share-menu.png b/docs/user/reporting/images/share-menu.png index 7f1d9eda0b5bc..996ab92ed483a 100644 Binary files a/docs/user/reporting/images/share-menu.png and b/docs/user/reporting/images/share-menu.png differ diff --git a/docs/user/reporting/index.asciidoc b/docs/user/reporting/index.asciidoc index 6ebeafef1013e..dbe433466c961 100644 --- a/docs/user/reporting/index.asciidoc +++ b/docs/user/reporting/index.asciidoc @@ -11,7 +11,7 @@ saved search, or Canvas workpad. Depending on the object type, you can export th a PDF, PNG, or CSV document, which you can keep for yourself, or share with others. Reporting is available from the *Share* menu -in *Discover*, *Dashboard*, and *Canvas*. +in *Discover*, *Dashboard*, *Visualize Library*, and *Canvas*. [role="screenshot"] image::user/reporting/images/share-menu.png["Share"] @@ -38,31 +38,32 @@ for an example. [float] [[manually-generate-reports]] -== Generate a report manually +== Manually generate and download reports -. Open the dashboard, visualization, Canvas workpad, or saved search that you want to include in the report. +Generate and download PDF, PNG, and CSV files of dashboards, visualizations, **Canvas** workpads, and saved searches. -. In the {kib} toolbar, click *Share*. If you are working in Canvas, -click the share icon image:user/reporting/images/canvas-share-button.png["Canvas Share button"]. +. Open the dashboard, visualization, **Canvas** workpad, or saved search. -. Select the option appropriate for your object. You can export: -+ -** A dashboard or visualization as either a PNG or PDF document -** A Canvas workpad as a PDF document -** A saved search as a CSV document +. From the {kib} toolbar, click **Share**, then select one of the following options: + +** **PDF Reports** — Generates a PDF file of the dashboard, visualization, or **Canvas** workpad. +** **PNG Reports** — Generates a PNG file of the dashboard or visualization. +** **CSV Reports** — Generates a CSV report of the saved search. . Generate the report. + -A notification appears when the report is complete. +When the report completes, a notification appears. + +. Click **Download report**. -NOTE: When you export a data table or saved search from a dashboard report, the PDF includes only the visible data. +NOTE: When you create a dashboard report that includes a data table or saved search, the PDF includes only the visible data. [float] [[reporting-layout-sizing]] == Layout and sizing The layout and size of the PDF or PNG image depends on the {kib} app -with which the Reporting plugin is integrated. For Canvas, the -worksheet dimensions determine the size for Reporting. In other apps, +with which the Reporting plugin is integrated. For *Canvas*, the +worksheet dimensions determine the size for reports. In other apps, the dimensions are taken on the fly by looking at the size of the visualization elements or panels on the page. @@ -72,9 +73,9 @@ This resizes the shareable container before generating the report, so the desired dimensions are passed in the job parameters. In the following {kib} dashboard, the shareable container is highlighted. -The shareable container is captured when you click the -*Generate* or *Copy POST URL* button. It might take some trial and error -before you're satisfied with the layout and dimensions in the resulting +The shareable container is captured when you click +*Generate* or *Copy POST URL* from the *Share* menu. It might take some trial and error +before you're satisfied with the layout and dimensions in the PNG or PDF image. [role="screenshot"] @@ -107,16 +108,9 @@ image::user/reporting/images/canvas-full-page-layout.png["Full Page Layout"] == View and manage report history For a list of your reports, open the main menu, then click *Stack Management > Reporting*. -From this view, you can monitor the generation of a report and +From this view, you can monitor the status of a report and download reports that you previously generated. -[float] -[[automatically-generate-reports]] -== Automatically generate a report - -To automatically generate a report from a script or with -{watcher}, see <>. - -- include::automating-report-generation.asciidoc[] diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index b3be4d64921cd..022d1dd47f7ef 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -379,6 +379,7 @@ xpack.security.authc.providers: -------------------------------------------------------------------------------- [float] +[[anonymous-access-and-embedding]] ===== Anonymous access and embedding One of the most popular use cases for anonymous access is when you embed {kib} into other applications and don't want to force your users to log in to view it. If you configured {kib} to use anonymous access as the sole authentication mechanism, you don't need to do anything special while embedding {kib}. diff --git a/package.json b/package.json index 70918f02dcd41..90096bfdf1b80 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "test:ftr:runner": "node scripts/functional_test_runner", "checkLicenses": "node scripts/check_licenses --dev", "build": "node scripts/build --all-platforms", + "build:apidocs": "node scripts/build_api_docs", "start": "node scripts/kibana --dev", "debug": "node --nolazy --inspect scripts/kibana --dev", "debug-break": "node --nolazy --inspect-brk scripts/kibana --dev", @@ -74,11 +75,11 @@ "**/cross-fetch/node-fetch": "^2.6.1", "**/deepmerge": "^4.2.2", "**/fast-deep-equal": "^3.1.1", - "**/graphql-toolkit/lodash": "^4.17.15", + "**/graphql-toolkit/lodash": "^4.17.21", "**/hoist-non-react-statics": "^3.3.2", "**/isomorphic-fetch/node-fetch": "^2.6.1", "**/istanbul-instrumenter-loader/schema-utils": "1.0.0", - "**/load-grunt-config/lodash": "^4.17.20", + "**/load-grunt-config/lodash": "^4.17.21", "**/minimist": "^1.2.5", "**/node-jose/node-forge": "^0.10.0", "**/prismjs": "1.22.0", @@ -233,7 +234,7 @@ "json-stringify-safe": "5.0.1", "jsonwebtoken": "^8.5.1", "load-json-file": "^6.2.0", - "lodash": "^4.17.20", + "lodash": "^4.17.21", "lru-cache": "^4.1.5", "markdown-it": "^10.0.0", "md5": "^2.1.0", @@ -367,7 +368,7 @@ "@kbn/plugin-generator": "link:packages/kbn-plugin-generator", "@kbn/plugin-helpers": "link:packages/kbn-plugin-helpers", "@kbn/pm": "link:packages/kbn-pm", - "@kbn/release-notes": "link:packages/kbn-release-notes", + "@kbn/docs-utils": "link:packages/kbn-docs-utils", "@kbn/storybook": "link:packages/kbn-storybook", "@kbn/telemetry-tools": "link:packages/kbn-telemetry-tools", "@kbn/test": "link:packages/kbn-test", @@ -390,7 +391,6 @@ "@storybook/addon-essentials": "^6.0.26", "@storybook/addon-knobs": "^6.0.26", "@storybook/addon-storyshots": "^6.0.26", - "@storybook/addons": "^6.0.16", "@storybook/components": "^6.0.26", "@storybook/core": "^6.0.26", "@storybook/core-events": "^6.0.26", @@ -822,6 +822,7 @@ "tinycolor2": "1.4.1", "topojson-client": "3.0.0", "ts-loader": "^7.0.5", + "ts-morph": "^9.1.0", "tsd": "^0.13.1", "typescript": "4.1.3", "typescript-fsa": "^3.0.0", diff --git a/packages/kbn-dev-utils/src/plugins/parse_kibana_platform_plugin.ts b/packages/kbn-dev-utils/src/plugins/parse_kibana_platform_plugin.ts index 62a6f1d347c9b..8d3fdb0f390c5 100644 --- a/packages/kbn-dev-utils/src/plugins/parse_kibana_platform_plugin.ts +++ b/packages/kbn-dev-utils/src/plugins/parse_kibana_platform_plugin.ts @@ -29,6 +29,7 @@ interface Manifest { server: boolean; kibanaVersion: string; version: string; + serviceFolders: readonly string[]; requiredPlugins: readonly string[]; optionalPlugins: readonly string[]; requiredBundles: readonly string[]; @@ -64,6 +65,7 @@ export function parseKibanaPlatformPlugin(manifestPath: string): KibanaPlatformP id: manifest.id, version: manifest.version, kibanaVersion: manifest.kibanaVersion || manifest.version, + serviceFolders: manifest.serviceFolders || [], requiredPlugins: isValidDepsDeclaration(manifest.requiredPlugins, 'requiredPlugins'), optionalPlugins: isValidDepsDeclaration(manifest.optionalPlugins, 'optionalPlugins'), requiredBundles: isValidDepsDeclaration(manifest.requiredBundles, 'requiredBundles'), diff --git a/packages/kbn-release-notes/jest.config.js b/packages/kbn-docs-utils/jest.config.js similarity index 89% rename from packages/kbn-release-notes/jest.config.js rename to packages/kbn-docs-utils/jest.config.js index db5e48b1704fb..e9cdc1978231e 100644 --- a/packages/kbn-release-notes/jest.config.js +++ b/packages/kbn-docs-utils/jest.config.js @@ -9,5 +9,5 @@ module.exports = { preset: '@kbn/test', rootDir: '../..', - roots: ['/packages/kbn-release-notes'], + roots: ['/packages/kbn-docs-utils'], }; diff --git a/packages/kbn-release-notes/package.json b/packages/kbn-docs-utils/package.json similarity index 85% rename from packages/kbn-release-notes/package.json rename to packages/kbn-docs-utils/package.json index c325367c7d47e..089732e9e6b40 100644 --- a/packages/kbn-release-notes/package.json +++ b/packages/kbn-docs-utils/package.json @@ -1,5 +1,5 @@ { - "name": "@kbn/release-notes", + "name": "@kbn/docs-utils", "version": "1.0.0", "license": "SSPL-1.0 OR Elastic License 2.0", "private": "true", @@ -13,6 +13,7 @@ }, "dependencies": { "@kbn/utils": "link:../kbn-utils", + "@kbn/config": "link:../kbn-config", "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-docs-utils/src/api_docs/README.md b/packages/kbn-docs-utils/src/api_docs/README.md new file mode 100644 index 0000000000000..f980fe83b9596 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/README.md @@ -0,0 +1,12 @@ +# Autogenerated API documentation + +[RFC](../../../rfcs/text/0014_api_documentation.md) + +This is an experimental api documentation system that is managed by the Kibana Tech Leads until +we determine the value of such a system and what kind of maintenance burder it will incur. + +To generate the docs run + +``` +node scripts/build_api_docs +``` diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/buid_api_declaration.test.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/buid_api_declaration.test.ts new file mode 100644 index 0000000000000..31e0d59dcca8a --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/buid_api_declaration.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import { Project, Node } from 'ts-morph'; +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; + +import { TypeKind, ApiScope } from '../types'; +import { getKibanaPlatformPlugin } from '../tests/kibana_platform_plugin_mock'; +import { getDeclarationNodesForPluginScope } from '../get_declaration_nodes_for_plugin'; +import { buildApiDeclaration } from './build_api_declaration'; +import { isNamedNode } from '../tsmorph_utils'; + +const log = new ToolingLog({ + level: 'debug', + writeTo: process.stdout, +}); + +let nodes: Node[]; +let plugins: KibanaPlatformPlugin[]; + +function getNodeName(node: Node): string { + return isNamedNode(node) ? node.getName() : ''; +} + +beforeAll(() => { + const tsConfigFilePath = Path.resolve(__dirname, '../tests/__fixtures__/src/tsconfig.json'); + const project = new Project({ + tsConfigFilePath, + }); + + plugins = [getKibanaPlatformPlugin('pluginA')]; + + nodes = getDeclarationNodesForPluginScope(project, plugins[0], ApiScope.CLIENT, log); +}); + +it('Test number primitive doc def', () => { + const node = nodes.find((n) => getNodeName(n) === 'aNum'); + expect(node).toBeDefined(); + const def = buildApiDeclaration(node!, plugins, log, plugins[0].manifest.id, ApiScope.CLIENT); + + expect(def.type).toBe(TypeKind.NumberKind); +}); + +it('Function type is exported as type with signature', () => { + const node = nodes.find((n) => getNodeName(n) === 'FnWithGeneric'); + expect(node).toBeDefined(); + const def = buildApiDeclaration(node!, plugins, log, plugins[0].manifest.id, ApiScope.CLIENT); + expect(def).toBeDefined(); + expect(def?.type).toBe(TypeKind.TypeKind); + expect(def?.signature?.length).toBeGreaterThan(0); +}); + +it('Test Interface Kind doc def', () => { + const node = nodes.find((n) => getNodeName(n) === 'ExampleInterface'); + expect(node).toBeDefined(); + const def = buildApiDeclaration(node!, plugins, log, plugins[0].manifest.id, ApiScope.CLIENT); + + expect(def.type).toBe(TypeKind.InterfaceKind); + expect(def.children).toBeDefined(); + expect(def.children!.length).toBe(3); +}); + +it('Test union export', () => { + const node = nodes.find((n) => getNodeName(n) === 'aUnionProperty'); + expect(node).toBeDefined(); + const def = buildApiDeclaration(node!, plugins, log, plugins[0].manifest.id, ApiScope.CLIENT); + expect(def.type).toBe(TypeKind.CompoundTypeKind); +}); + +it('Function inside interface has a label', () => { + const node = nodes.find((n) => getNodeName(n) === 'ExampleInterface'); + expect(node).toBeDefined(); + const def = buildApiDeclaration(node!, plugins, log, plugins[0].manifest.id, ApiScope.CLIENT); + + const fn = def!.children?.find((c) => c.label === 'aFn'); + expect(fn).toBeDefined(); + expect(fn?.label).toBe('aFn'); + expect(fn?.type).toBe(TypeKind.FunctionKind); +}); diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_api_declaration.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_api_declaration.ts new file mode 100644 index 0000000000000..3ee6676cf5e32 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_api_declaration.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Node } from 'ts-morph'; +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { buildClassDec } from './build_class_dec'; +import { buildFunctionDec } from './build_function_dec'; +import { getCommentsFromNode } from './js_doc_utils'; +import { isNamedNode } from '../tsmorph_utils'; +import { AnchorLink, ApiDeclaration } from '../types'; +import { buildVariableDec } from './build_variable_dec'; +import { getApiSectionId } from '../utils'; +import { getSourceForNode } from './utils'; +import { buildTypeLiteralDec } from './build_type_literal_dec'; +import { ApiScope } from '../types'; +import { getSignature } from './get_signature'; +import { buildInterfaceDec } from './build_interface_dec'; +import { getTypeKind } from './get_type_kind'; + +/** + * A potentially recursive function, depending on the node type, that builds a JSON like structure + * that can be passed to the elastic-docs component for rendering as an API. Nodes like classes, + * interfaces, objects and functions will have children for their properties, members and parameters. + * + * @param node The ts-morph node to build an ApiDeclaration for. + * @param plugins The list of plugins registered is used for building cross plugin links by looking up + * the plugin by import path. We could accomplish the same thing via a regex on the import path, but this lets us + * decouple plugin path from plugin id. + * @param log Logs messages to console. + * @param pluginName The name of the plugin this declaration belongs to. + * @param scope The scope this declaration belongs to (server, public, or common). + * @param parentApiId If this declaration is nested inside another declaration, it should have a parent id. This + * is used to create the anchor link to this API item. + * @param name An optional name to pass through which will be used instead of node.getName, if it + * exists. For some types, like Parameters, the name comes on the parent node, but we want the doc def + * to be built from the TypedNode + */ +export function buildApiDeclaration( + node: Node, + plugins: KibanaPlatformPlugin[], + log: ToolingLog, + pluginName: string, + scope: ApiScope, + parentApiId?: string, + name?: string +): ApiDeclaration { + const apiName = name ? name : isNamedNode(node) ? node.getName() : 'Unnamed'; + log.debug(`Building API Declaration for ${apiName} of kind ${node.getKindName()}`); + const apiId = parentApiId ? parentApiId + '.' + apiName : apiName; + const anchorLink: AnchorLink = { scope, pluginName, apiName: apiId }; + + if (Node.isClassDeclaration(node)) { + return buildClassDec(node, plugins, anchorLink, log); + } else if (Node.isInterfaceDeclaration(node)) { + return buildInterfaceDec(node, plugins, anchorLink, log); + } else if ( + Node.isMethodSignature(node) || + Node.isFunctionDeclaration(node) || + Node.isMethodDeclaration(node) || + Node.isConstructorDeclaration(node) + ) { + return buildFunctionDec(node, plugins, anchorLink, log); + } else if ( + Node.isPropertySignature(node) || + Node.isPropertyDeclaration(node) || + Node.isShorthandPropertyAssignment(node) || + Node.isPropertyAssignment(node) || + Node.isVariableDeclaration(node) + ) { + return buildVariableDec(node, plugins, anchorLink, log); + } else if (Node.isTypeLiteralNode(node)) { + return buildTypeLiteralDec(node, plugins, anchorLink, log, apiName); + } + + return { + id: getApiSectionId(anchorLink), + type: getTypeKind(node), + label: apiName, + description: getCommentsFromNode(node), + source: getSourceForNode(node), + signature: getSignature(node, plugins, log), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_arrow_fn_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_arrow_fn_dec.ts new file mode 100644 index 0000000000000..146fcf4fa4d0a --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_arrow_fn_dec.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; + +import { + ArrowFunction, + VariableDeclaration, + PropertyDeclaration, + PropertySignature, + ShorthandPropertyAssignment, + PropertyAssignment, +} from 'ts-morph'; +import { getApiSectionId } from '../utils'; +import { getCommentsFromNode } from './js_doc_utils'; +import { AnchorLink, TypeKind } from '../types'; +import { getSourceForNode } from './utils'; +import { buildApiDecsForParameters } from './build_parameter_decs'; +import { getSignature } from './get_signature'; +import { getJSDocReturnTagComment } from './js_doc_utils'; + +/** + * Arrow functions are handled differently than regular functions because you need the arrow function + * initializer as well as the node. The initializer is where the parameters are grabbed from and the + * signature, while the node has the comments and name. + * + * @param node + * @param initializer + * @param plugins + * @param anchorLink + * @param log + */ +export function getArrowFunctionDec( + node: + | VariableDeclaration + | PropertyDeclaration + | PropertySignature + | ShorthandPropertyAssignment + | PropertyAssignment, + initializer: ArrowFunction, + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog +) { + log.debug( + `Getting Arrow Function doc def for node ${node.getName()} of kind ${node.getKindName()}` + ); + return { + id: getApiSectionId(anchorLink), + type: TypeKind.FunctionKind, + children: buildApiDecsForParameters(initializer.getParameters(), plugins, anchorLink, log), + signature: getSignature(initializer, plugins, log), + description: getCommentsFromNode(node), + label: node.getName(), + source: getSourceForNode(node), + returnComment: getJSDocReturnTagComment(node), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_class_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_class_dec.ts new file mode 100644 index 0000000000000..2ccce506dde53 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_class_dec.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { ClassDeclaration } from 'ts-morph'; +import { AnchorLink, ApiDeclaration, TypeKind } from '../types'; +import { getCommentsFromNode } from './js_doc_utils'; +import { buildApiDeclaration } from './build_api_declaration'; +import { getSourceForNode, isPrivate } from './utils'; +import { getApiSectionId } from '../utils'; +import { getSignature } from './get_signature'; + +export function buildClassDec( + node: ClassDeclaration, + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog +): ApiDeclaration { + return { + id: getApiSectionId(anchorLink), + type: TypeKind.ClassKind, + label: node.getName() || 'Missing label', + description: getCommentsFromNode(node), + signature: getSignature(node, plugins, log), + children: node.getMembers().reduce((acc, m) => { + if (!isPrivate(m)) { + acc.push( + buildApiDeclaration( + m, + plugins, + log, + anchorLink.pluginName, + anchorLink.scope, + anchorLink.apiName + ) + ); + } + return acc; + }, [] as ApiDeclaration[]), + source: getSourceForNode(node), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_dec.ts new file mode 100644 index 0000000000000..2936699152a83 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_dec.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + FunctionDeclaration, + MethodDeclaration, + ConstructorDeclaration, + Node, + MethodSignature, +} from 'ts-morph'; + +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { buildApiDecsForParameters } from './build_parameter_decs'; +import { AnchorLink, ApiDeclaration, TypeKind } from '../types'; +import { getCommentsFromNode } from './js_doc_utils'; +import { getApiSectionId } from '../utils'; +import { getJSDocReturnTagComment, getJSDocs, getJSDocTagNames } from './js_doc_utils'; +import { getSourceForNode } from './utils'; +import { getSignature } from './get_signature'; + +/** + * Takes the various function-like node declaration types and converts them into an ApiDeclaration. + * @param node + * @param plugins + * @param anchorLink + * @param log + */ +export function buildFunctionDec( + node: FunctionDeclaration | MethodDeclaration | ConstructorDeclaration | MethodSignature, + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog +): ApiDeclaration { + const label = Node.isConstructorDeclaration(node) + ? 'Constructor' + : node.getName() || '(WARN: Missing name)'; + log.debug(`Getting function doc def for node ${label} of kind ${node.getKindName()}`); + return { + id: getApiSectionId(anchorLink), + type: TypeKind.FunctionKind, + label, + signature: getSignature(node, plugins, log), + description: getCommentsFromNode(node), + children: buildApiDecsForParameters( + node.getParameters(), + plugins, + anchorLink, + log, + getJSDocs(node) + ), + tags: getJSDocTagNames(node), + returnComment: getJSDocReturnTagComment(node), + source: getSourceForNode(node), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_type_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_type_dec.ts new file mode 100644 index 0000000000000..3cd4317e23f0c --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_type_dec.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { FunctionTypeNode, JSDoc } from 'ts-morph'; +import { getApiSectionId } from '../utils'; +import { getCommentsFromNode } from './js_doc_utils'; +import { AnchorLink, ApiDeclaration, TypeKind } from '../types'; +import { buildApiDecsForParameters } from './build_parameter_decs'; +import { extractImportReferences } from './extract_import_refs'; +import { getJSDocReturnTagComment, getJSDocs, getJSDocTagNames } from './js_doc_utils'; +import { getSourceForNode } from './utils'; + +export function buildApiDecFromFunctionType( + name: string, + node: FunctionTypeNode, + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog, + jsDocs?: JSDoc[] +): ApiDeclaration { + log.debug(`Getting Function Type doc def for node ${name} of kind ${node.getKindName()}`); + return { + type: TypeKind.FunctionKind, + id: getApiSectionId(anchorLink), + label: name, + signature: extractImportReferences(node.getType().getText(), plugins, log), + description: getCommentsFromNode(node), + tags: jsDocs ? getJSDocTagNames(jsDocs) : [], + returnComment: jsDocs ? getJSDocReturnTagComment(jsDocs) : [], + children: buildApiDecsForParameters( + node.getParameters(), + plugins, + anchorLink, + log, + jsDocs || getJSDocs(node) + ), + source: getSourceForNode(node), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_interface_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_interface_dec.ts new file mode 100644 index 0000000000000..2329aa2190d95 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_interface_dec.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { InterfaceDeclaration } from 'ts-morph'; +import { AnchorLink, ApiDeclaration, TypeKind } from '../types'; +import { getCommentsFromNode } from './js_doc_utils'; +import { buildApiDeclaration } from './build_api_declaration'; +import { getSourceForNode } from './utils'; +import { getApiSectionId } from '../utils'; +import { getSignature } from './get_signature'; + +export function buildInterfaceDec( + node: InterfaceDeclaration, + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog +): ApiDeclaration { + return { + id: getApiSectionId(anchorLink), + type: TypeKind.InterfaceKind, + label: node.getName(), + signature: getSignature(node, plugins, log), + description: getCommentsFromNode(node), + children: node + .getMembers() + .map((m) => + buildApiDeclaration( + m, + plugins, + log, + anchorLink.pluginName, + anchorLink.scope, + anchorLink.apiName + ) + ), + source: getSourceForNode(node), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_parameter_decs.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_parameter_decs.ts new file mode 100644 index 0000000000000..e420f76357f75 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_parameter_decs.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ParameterDeclaration, JSDoc, SyntaxKind } from 'ts-morph'; +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { extractImportReferences } from './extract_import_refs'; +import { AnchorLink, ApiDeclaration } from '../types'; +import { buildApiDeclaration } from './build_api_declaration'; +import { getJSDocParamComment } from './js_doc_utils'; +import { getSourceForNode } from './utils'; +import { getTypeKind } from './get_type_kind'; + +/** + * A helper function to capture function parameters, whether it comes from an arrow function, a regular function or + * a function type. + * + * @param params + * @param plugins + * @param anchorLink + * @param log + * @param jsDocs + */ +export function buildApiDecsForParameters( + params: ParameterDeclaration[], + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog, + jsDocs?: JSDoc[] +): ApiDeclaration[] { + return params.reduce((acc, param) => { + const label = param.getName(); + log.debug(`Getting parameter doc def for ${label} of kind ${param.getKindName()}`); + // Literal types are non primitives that aren't references to other types. We add them as a more + // defined node, with children. + // If we don't want the docs to be too deeply nested we could avoid this special handling. + if (param.getTypeNode() && param.getTypeNode()!.getKind() === SyntaxKind.TypeLiteral) { + acc.push( + buildApiDeclaration( + param.getTypeNode()!, + plugins, + log, + anchorLink.pluginName, + anchorLink.scope, + anchorLink.apiName, + label + ) + ); + } else { + acc.push({ + type: getTypeKind(param), + label, + isRequired: param.getType().isNullable() === false, + signature: extractImportReferences(param.getType().getText(), plugins, log), + description: jsDocs ? getJSDocParamComment(jsDocs, label) : [], + source: getSourceForNode(param), + }); + } + return acc; + }, [] as ApiDeclaration[]); +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_type_literal_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_type_literal_dec.ts new file mode 100644 index 0000000000000..39d47d5bacba1 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_type_literal_dec.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { TypeLiteralNode } from 'ts-morph'; +import { getApiSectionId } from '../utils'; +import { getCommentsFromNode } from './js_doc_utils'; +import { AnchorLink, ApiDeclaration, TypeKind } from '../types'; +import { buildApiDeclaration } from './build_api_declaration'; +import { getSourceForNode } from './utils'; + +/** + * This captures function parameters that are object types, and makes sure their + * properties are recursively walked so they are expandable in the docs. + * + * The test verifying `crazyFunction` will fail without this special handling. + * + * @param node + * @param plugins + * @param anchorLink + * @param log + * @param name + */ +export function buildTypeLiteralDec( + node: TypeLiteralNode, + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog, + name: string +): ApiDeclaration { + return { + id: getApiSectionId(anchorLink), + type: TypeKind.ObjectKind, + label: name, + description: getCommentsFromNode(node), + children: node + .getMembers() + .map((m) => + buildApiDeclaration( + m, + plugins, + log, + anchorLink.pluginName, + anchorLink.scope, + anchorLink.apiName + ) + ), + source: getSourceForNode(node), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_variable_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_variable_dec.ts new file mode 100644 index 0000000000000..3e0b48de1e18b --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_variable_dec.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { + VariableDeclaration, + Node, + PropertyAssignment, + PropertyDeclaration, + PropertySignature, + ShorthandPropertyAssignment, +} from 'ts-morph'; +import { getApiSectionId } from '../utils'; +import { getCommentsFromNode } from './js_doc_utils'; +import { AnchorLink, ApiDeclaration, TypeKind } from '../types'; +import { getArrowFunctionDec } from './build_arrow_fn_dec'; +import { buildApiDeclaration } from './build_api_declaration'; +import { getSourceForNode } from './utils'; +import { getSignature } from './get_signature'; +import { getTypeKind } from './get_type_kind'; + +/** + * Special handling for objects and arrow functions which are variable or property node types. + * Objects and arrow functions need their children extracted recursively. This uses the name from the + * node, but checks for an initializer to get inline arrow functions and objects defined recursively. + * + * @param node + * @param plugins + * @param anchorLink + * @param log + */ +export function buildVariableDec( + node: + | VariableDeclaration + | PropertyAssignment + | PropertyDeclaration + | PropertySignature + | ShorthandPropertyAssignment, + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog +): ApiDeclaration { + log.debug('buildVariableDec for ' + node.getName()); + const initializer = node.getInitializer(); + // Recusively list object properties as children. + if (initializer && Node.isObjectLiteralExpression(initializer)) { + return { + id: getApiSectionId(anchorLink), + type: TypeKind.ObjectKind, + children: initializer.getProperties().map((prop) => { + return buildApiDeclaration( + prop, + plugins, + log, + anchorLink.pluginName, + anchorLink.scope, + anchorLink.apiName + ); + }), + description: getCommentsFromNode(node), + label: node.getName(), + source: getSourceForNode(node), + }; + } else if (initializer && Node.isArrowFunction(initializer)) { + return getArrowFunctionDec(node, initializer, plugins, anchorLink, log); + } + + // Otherwise return it just as a single entry. + return { + id: getApiSectionId(anchorLink), + type: getTypeKind(node), + label: node.getName(), + description: getCommentsFromNode(node), + source: getSourceForNode(node), + signature: getSignature(node, plugins, log), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.test.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.test.ts new file mode 100644 index 0000000000000..a757df2ece366 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPlatformPlugin, ToolingLog } from '@kbn/dev-utils'; +import { getPluginApiDocId } from '../utils'; +import { extractImportReferences } from './extract_import_refs'; +import { ApiScope, Reference } from '../types'; +import { getKibanaPlatformPlugin } from '../tests/kibana_platform_plugin_mock'; + +const plugin = getKibanaPlatformPlugin('pluginA'); +const plugins: KibanaPlatformPlugin[] = [plugin]; + +const log = new ToolingLog({ + level: 'debug', + writeTo: process.stdout, +}); + +it('when there are no imports', () => { + const results = extractImportReferences(`(param: string) => Bar`, plugins, log); + expect(results.length).toBe(1); + expect(results[0]).toBe('(param: string) => Bar'); +}); + +it('test extractImportReference', () => { + const results = extractImportReferences( + `(param: string) => import("${plugin.directory}/public/bar").Bar`, + plugins, + log + ); + expect(results.length).toBe(2); + expect(results[0]).toBe('(param: string) => '); + expect(results[1]).toEqual({ + text: 'Bar', + docId: getPluginApiDocId('plugin_a', log), + section: 'def-public.Bar', + pluginId: 'pluginA', + scope: ApiScope.CLIENT, + }); +}); + +it('test extractImportReference with public folder nested under server folder', () => { + const results = extractImportReferences( + `import("${plugin.directory}/server/routes/public/bar").Bar`, + plugins, + log + ); + expect(results.length).toBe(1); + expect(results[0]).toEqual({ + text: 'Bar', + docId: getPluginApiDocId('plugin_a', log), + section: 'def-server.Bar', + pluginId: 'pluginA', + scope: ApiScope.SERVER, + }); +}); + +it('test extractImportReference with two imports', () => { + const results = extractImportReferences( + ``, + plugins, + log + ); + expect(results.length).toBe(5); + expect(results[0]).toBe(''); +}); + +it('test extractImportReference with unknown imports', () => { + const results = extractImportReferences( + ``, + plugins, + log + ); + expect(results.length).toBe(3); + expect(results[0]).toBe(''); +}); + +it('test single link', () => { + const results = extractImportReferences( + `import("${plugin.directory}/public/foo/index").FooFoo`, + plugins, + log + ); + expect(results.length).toBe(1); + expect((results[0] as Reference).text).toBe('FooFoo'); +}); diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.ts new file mode 100644 index 0000000000000..1147e15a1acb6 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPlatformPlugin, ToolingLog } from '@kbn/dev-utils'; +import { getApiSectionId, getPluginApiDocId, getPluginForPath } from '../utils'; +import { ApiScope, TextWithLinks } from '../types'; + +/** + * + * @param text A string that may include an API item that was imported from another file. For example: + * "export type foo = string | import("kibana/src/plugins/a_plugin/public/path").Bar". + * @param plugins The list of registered Kibana plugins. Used to get the plugin id, which is then used to create + * the DocLink to that plugin's page, based off the relative path of any imports. + * @param log Logging utility for debuging + * + * @returns An array structure that can be used to create react DocLinks. For example, the above text would return + * something like: + * [ "export type foo = string | ", // Just a string for the pretext + * { id: "a_plugin", section: "public.Bar", text: "Bar" } // An object with info to create the DocLink. + * ] + */ +export function extractImportReferences( + text: string, + plugins: KibanaPlatformPlugin[], + log: ToolingLog +): TextWithLinks { + const texts: TextWithLinks = []; + let pos = 0; + let textSegment: string | undefined = text; + const max = 5; + while (textSegment) { + pos++; + if (pos > max) break; + + const ref = extractImportRef(textSegment); + if (ref) { + const { name, path, index, length } = ref; + if (index !== 0) { + texts.push(textSegment.substr(0, index)); + } + const plugin = getPluginForPath(path, plugins); + + if (!plugin) { + if (path.indexOf('plugin') >= 0) { + log.warning('WARN: no plugin found for reference path ' + path); + } + // If we can't create a link for this, still remove the import("..."). part to make + // it easier to read. + const str = textSegment.substr(index + length - name.length, name.length); + if (str && str !== '') { + texts.push(str); + } + } else { + const section = getApiSectionId({ + pluginName: plugin.manifest.id, + scope: getScopeFromPath(path, plugin, log), + apiName: name, + }); + texts.push({ + pluginId: plugin.manifest.id, + scope: getScopeFromPath(path, plugin, log), + docId: getPluginApiDocId(plugin.manifest.id, log, { + serviceFolders: plugin.manifest.serviceFolders, + apiPath: path, + directory: plugin.directory, + }), + section, + text: name, + }); + } + textSegment = textSegment.substr(index + length); + } else { + if (textSegment && textSegment !== '') { + texts.push(textSegment); + } + textSegment = undefined; + } + } + return texts; +} + +function extractImportRef( + str: string +): { path: string; name: string; index: number; length: number } | undefined { + const groups = str.match(/import\("(.*?)"\)\.(\w*)/); + if (groups) { + const path = groups[1]; + const name = groups[2]; + const index = groups.index!; + const length = groups[0].length; + return { path, name, index, length }; + } +} + +/** + * + * @param path An absolute path to a file inside a plugin directory. + */ +function getScopeFromPath(path: string, plugin: KibanaPlatformPlugin, log: ToolingLog): ApiScope { + if (path.startsWith(`${plugin.directory}/public/`)) { + return ApiScope.CLIENT; + } else if (path.startsWith(`${plugin.directory}/server/`)) { + return ApiScope.SERVER; + } else if (path.startsWith(`${plugin.directory}/common/`)) { + return ApiScope.COMMON; + } else { + log.warning(`Unexpected path encountered ${path}`); + return ApiScope.COMMON; + } +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/get_signature.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/get_signature.ts new file mode 100644 index 0000000000000..b7df94a03638f --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/get_signature.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPlatformPlugin, ToolingLog } from '@kbn/dev-utils'; +import { Node, Type } from 'ts-morph'; +import { isNamedNode } from '../tsmorph_utils'; +import { Reference } from '../types'; +import { extractImportReferences } from './extract_import_refs'; +import { getTypeKind } from './get_type_kind'; + +/** + * Special logic for creating the signature based on the type of node. See https://github.com/dsherret/ts-morph/issues/923#issue-795332729 + * for some issues that have been encountered in getting these accurate. + * + * By passing node to `getText`, ala `node.getType().getText(node)`, all reference links + * will be lost. However, if you do _not_ pass node, there are quite a few situations where it returns a reference + * to itself and has no helpful information. + * + * @param node + * @param plugins + * @param log + */ +export function getSignature( + node: Node, + plugins: KibanaPlatformPlugin[], + log: ToolingLog +): Array | undefined { + let signature = ''; + // node.getType() on a TypeAliasDeclaration is just a reference to itself. If we don't special case this, then + // `export type Foo = string | number;` would show up with a signagure of `Foo` that is a link to itself, instead of + // `string | number`. + if (Node.isTypeAliasDeclaration(node)) { + signature = getSignatureForTypeAlias(node.getType(), log, node); + } else if (Node.isFunctionDeclaration(node)) { + // See https://github.com/dsherret/ts-morph/issues/907#issue-770284331. + // Unfortunately this has to be manually pieced together, or it comes up as "typeof TheFunction" + const params = node + .getParameters() + .map((p) => `${p.getName()}: ${p.getType().getText()}`) + .join(', '); + const returnType = node.getReturnType().getText(); + signature = `(${params}) => ${returnType}`; + } else if (Node.isInterfaceDeclaration(node) || Node.isClassDeclaration(node)) { + // Need to tack on manually any type parameters or "extends/implements" section. + const heritageClause = node + .getHeritageClauses() + .map((h) => { + const heritance = h.getText().indexOf('implements') > -1 ? 'implements' : 'extends'; + return `${heritance} ${h.getTypeNodes().map((n) => n.getType().getText())}`; + }) + .join(' '); + signature = `${node.getType().getText()}${heritageClause ? ' ' + heritageClause : ''}`; + } else { + // Here, 'node' is explicitly *not* passed in to `getText` otherwise arrow functions won't + // include reference links. Tests will break if you add it in here, or remove it from above. + // There is test coverage for all this oddness. + signature = node.getType().getText(); + } + + // Don't return the signature if it's the same as the type (string, string) + if (getTypeKind(node).toString() === signature) return undefined; + + const referenceLinks = extractImportReferences(signature, plugins, log); + + // Don't return the signature if it's a single self referential link. + if ( + isNamedNode(node) && + referenceLinks.length === 1 && + typeof referenceLinks[0] === 'object' && + (referenceLinks[0] as Reference).text === node.getName() + ) { + return undefined; + } + + return referenceLinks; +} + +/** + * Not all types are handled here, but does return links for the more common ones. + */ +function getSignatureForTypeAlias(type: Type, log: ToolingLog, node?: Node): string { + if (type.isUnion()) { + return type + .getUnionTypes() + .map((nestedType) => getSignatureForTypeAlias(nestedType, log)) + .join(' | '); + } else if (node && type.getCallSignatures().length >= 1) { + return type + .getCallSignatures() + .map((sig) => { + const params = sig + .getParameters() + .map((p) => `${p.getName()}: ${p.getTypeAtLocation(node).getText()}`) + .join(', '); + const returnType = sig.getReturnType().getText(); + return `(${params}) => ${returnType}`; + }) + .join(' '); + } else if (node) { + const symbol = node.getSymbol(); + if (symbol) { + const declarations = symbol + .getDeclarations() + .map((d) => d.getType().getText(node)) + .join(' '); + if (symbol.getDeclarations().length !== 1) { + log.error( + `Node is type alias declaration with more than one declaration. This is not handled! ${declarations} and node is ${node.getText()}` + ); + } + return declarations; + } + } + return type.getText(); +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/get_type_kind.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/get_type_kind.ts new file mode 100644 index 0000000000000..592833f40cebf --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/get_type_kind.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Type, Node } from 'ts-morph'; +import { TypeKind } from '../types'; + +export function getTypeKind(node: Node): TypeKind { + if (Node.isTypeAliasDeclaration(node)) { + return TypeKind.TypeKind; + } else { + return getTypeKindForType(node.getType()); + } +} + +function getTypeKindForType(type: Type): TypeKind { + // I think a string literal is also a string... but just in case, checking both. + if (type.isString() || type.isStringLiteral()) { + return TypeKind.StringKind; + } else if (type.isNumber() || type.isNumberLiteral()) { + return TypeKind.NumberKind; + + // I could be wrong about this logic. Does this existance of a call signature mean it's a function? + } else if (type.getCallSignatures().length > 0) { + return TypeKind.FunctionKind; + } else if (type.isArray()) { + // Arrays are also objects, check this first. + return TypeKind.ArrayKind; + } else if (type.isObject()) { + return TypeKind.ObjectKind; + } else if (type.isBoolean() || type.isBooleanLiteral()) { + return TypeKind.BooleanKind; + } else if (type.isEnum() || type.isEnumLiteral()) { + return TypeKind.EnumKind; + } else if (type.isUnion()) { + // Special handling for "type | undefined" which happens alot and should be represented in docs as + // "type", but with an "optional" flag. Anything more complicated will just be returned as a + // "CompoundType". + if (getIsTypeOptional(type) && type.getUnionTypes().length === 2) { + const otherType = type.getUnionTypes().find((u) => u.isUndefined() === false); + if (otherType) { + return getTypeKindForType(otherType); + } + } + } else if (type.isAny()) { + return TypeKind.AnyKind; + } else if (type.isUnknown()) { + return TypeKind.UnknownKind; + } + + if (type.isUnionOrIntersection()) { + return TypeKind.CompoundTypeKind; + } + + return TypeKind.Uncategorized; +} + +function getIsTypeOptional(type: Type): boolean { + if (type.isUnion()) { + const unions = type.getUnionTypes(); + return unions.find((u) => u.isUndefined()) !== undefined; + } else { + return false; + } +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/js_doc_utils.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/js_doc_utils.ts new file mode 100644 index 0000000000000..baac7153dfb75 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/js_doc_utils.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { JSDoc, JSDocTag, Node } from 'ts-morph'; +import { TextWithLinks } from '../types'; + +/** + * Extracts comments out of the node to use as the description. + */ +export function getCommentsFromNode(node: Node): TextWithLinks | undefined { + let comments: TextWithLinks | undefined; + const jsDocs = getJSDocs(node); + if (jsDocs) { + return getTextWithLinks(jsDocs.map((jsDoc) => jsDoc.getDescription()).join('\n')); + } else { + comments = getTextWithLinks( + node + .getLeadingCommentRanges() + .map((c) => c.getText()) + .join('\n') + ); + } + + return comments; +} + +export function getJSDocs(node: Node): JSDoc[] | undefined { + if (Node.isJSDocableNode(node)) { + return node.getJsDocs(); + } else if (Node.isVariableDeclaration(node)) { + const gparent = node.getParent()?.getParent(); + if (Node.isJSDocableNode(gparent)) { + return gparent.getJsDocs(); + } + } +} + +export function getJSDocReturnTagComment(node: Node | JSDoc[]): TextWithLinks { + const tags = getJSDocTags(node); + const returnTag = tags.find((tag) => Node.isJSDocReturnTag(tag)); + if (returnTag) return getTextWithLinks(returnTag.getComment()); + return []; +} + +export function getJSDocParamComment(node: Node | JSDoc[], name: string): TextWithLinks { + const tags = getJSDocTags(node); + const paramTag = tags.find((tag) => Node.isJSDocParameterTag(tag) && tag.getName() === name); + if (paramTag) return getTextWithLinks(paramTag.getComment()); + return []; +} + +export function getJSDocTagNames(node: Node | JSDoc[]): string[] { + return getJSDocTags(node).reduce((tags, tag) => { + if (tag.getTagName() !== 'param' && tag.getTagName() !== 'returns') { + tags.push(tag.getTagName()); + } + return tags; + }, [] as string[]); +} + +function getJSDocTags(node: Node | JSDoc[]): JSDocTag[] { + const jsDocs = node instanceof Array ? node : getJSDocs(node); + if (!jsDocs) return []; + + return jsDocs.reduce((tagsAcc, jsDoc) => { + tagsAcc.push(...jsDoc.getTags()); + return tagsAcc; + }, [] as JSDocTag[]); +} + +/** + * TODO. This feature is not implemented yet. It will be used to create links for comments + * that use {@link AnotherAPIItemInThisPlugin}. + * + * @param text + */ +function getTextWithLinks(text?: string): TextWithLinks { + if (text) return [text]; + else return []; + // TODO: + // Replace `@links` in comments with relative api links. +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/utils.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/utils.ts new file mode 100644 index 0000000000000..9efa96b6e9676 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/utils.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import Path from 'path'; +import { REPO_ROOT, kibanaPackageJson } from '@kbn/utils'; +import { ParameterDeclaration, ClassMemberTypes, Node } from 'ts-morph'; +import { SourceLink } from '../types'; + +export function isPrivate(node: ParameterDeclaration | ClassMemberTypes): boolean { + return node.getModifiers().find((mod) => mod.getText() === 'private') !== undefined; +} + +/** + * Change the absolute path into a relative one. + */ +function getRelativePath(fullPath: string): string { + return Path.relative(REPO_ROOT, fullPath); +} + +export function getSourceForNode(node: Node): SourceLink { + const path = getRelativePath(node.getSourceFile().getFilePath()); + const lineNumber = node.getStartLineNumber(); + return { + path, + lineNumber, + link: `https://github.com/elastic/kibana/tree/${kibanaPackageJson.branch}${path}#L${lineNumber}`, + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_docs_cli.ts b/packages/kbn-docs-utils/src/api_docs/build_api_docs_cli.ts new file mode 100644 index 0000000000000..ac6d6088c25c0 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_docs_cli.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Fs from 'fs'; +import Path from 'path'; + +import { REPO_ROOT, run } from '@kbn/dev-utils'; +import { Project } from 'ts-morph'; + +import { getPluginApi } from './get_plugin_api'; +import { writePluginDocs } from './mdx/write_plugin_mdx_docs'; +import { ApiDeclaration, PluginApi } from './types'; +import { findPlugins } from './find_plugins'; +import { removeBrokenLinks } from './utils'; + +export interface PluginInfo { + apiCount: number; + apiCountMissingComments: number; + id: string; + missingApiItems: string[]; +} + +export function runBuildApiDocsCli() { + run( + async ({ log }) => { + const project = getTsProject(REPO_ROOT); + + const plugins = findPlugins(); + + const pluginInfos: { + [key: string]: PluginInfo; + } = {}; + + const outputFolder = Path.resolve(REPO_ROOT, 'api_docs'); + if (!Fs.existsSync(outputFolder)) { + Fs.mkdirSync(outputFolder); + } else { + // Delete all files except the README that warns about the auto-generated nature of + // the folder. + const files = Fs.readdirSync(outputFolder); + files.forEach((file) => { + if (file.indexOf('README.md') < 0) { + Fs.rmSync(Path.resolve(outputFolder, file)); + } + }); + } + + const pluginApiMap: { [key: string]: PluginApi } = {}; + plugins.map((plugin) => { + pluginApiMap[plugin.manifest.id] = getPluginApi(project, plugin, plugins, log); + }); + + const missingApiItems: { [key: string]: string[] } = {}; + + plugins.forEach((plugin) => { + const id = plugin.manifest.id; + const pluginApi = pluginApiMap[id]; + removeBrokenLinks(pluginApi, missingApiItems, pluginApiMap); + }); + + plugins.forEach((plugin) => { + const id = plugin.manifest.id; + const pluginApi = pluginApiMap[id]; + const info = { + id, + apiCount: countApiForPlugin(pluginApi), + apiCountMissingComments: countMissingCommentsApiForPlugin(pluginApi), + missingApiItems: missingApiItems[id], + }; + + if (info.apiCount > 0) { + writePluginDocs(outputFolder, pluginApi, log); + pluginInfos[id] = info; + } + }); + + // eslint-disable-next-line no-console + console.table(pluginInfos); + }, + { + log: { + defaultLevel: 'debug', + }, + } + ); +} + +function getTsProject(repoPath: string) { + const xpackTsConfig = `${repoPath}/tsconfig.json`; + const project = new Project({ + tsConfigFilePath: xpackTsConfig, + }); + project.addSourceFilesAtPaths(`${repoPath}/x-pack/plugins/**/*{.d.ts,.ts}`); + project.resolveSourceFileDependencies(); + return project; +} + +function countMissingCommentsApiForPlugin(doc: PluginApi) { + return ( + doc.client.reduce((sum, def) => { + return sum + countMissingCommentsForApi(def); + }, 0) + + doc.server.reduce((sum, def) => { + return sum + countMissingCommentsForApi(def); + }, 0) + + doc.common.reduce((sum, def) => { + return sum + countMissingCommentsForApi(def); + }, 0) + ); +} + +function countMissingCommentsForApi(doc: ApiDeclaration): number { + const missingCnt = doc.description && doc.description.length > 0 ? 0 : 1; + if (!doc.children) return missingCnt; + else + return ( + missingCnt + + doc.children.reduce((sum, child) => { + return sum + countMissingCommentsForApi(child); + }, 0) + ); +} + +function countApiForPlugin(doc: PluginApi) { + return ( + doc.client.reduce((sum, def) => { + return sum + countApi(def); + }, 0) + + doc.server.reduce((sum, def) => { + return sum + countApi(def); + }, 0) + + doc.common.reduce((sum, def) => { + return sum + countApi(def); + }, 0) + ); +} + +function countApi(doc: ApiDeclaration): number { + if (!doc.children) return 1; + else + return ( + 1 + + doc.children.reduce((sum, child) => { + return sum + countApi(child); + }, 0) + ); +} diff --git a/packages/kbn-docs-utils/src/api_docs/find_plugins.ts b/packages/kbn-docs-utils/src/api_docs/find_plugins.ts new file mode 100644 index 0000000000000..004124f13889d --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/find_plugins.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; + +import { getPluginSearchPaths } from '@kbn/config'; +import { simpleKibanaPlatformPluginDiscovery, REPO_ROOT } from '@kbn/dev-utils'; + +export function findPlugins() { + const pluginSearchPaths = getPluginSearchPaths({ + rootDir: REPO_ROOT, + oss: false, + examples: false, + }); + + return simpleKibanaPlatformPluginDiscovery(pluginSearchPaths, [ + // discover "core" as a plugin + Path.resolve(REPO_ROOT, 'src/core'), + ]); +} diff --git a/packages/kbn-docs-utils/src/api_docs/get_declaration_nodes_for_plugin.ts b/packages/kbn-docs-utils/src/api_docs/get_declaration_nodes_for_plugin.ts new file mode 100644 index 0000000000000..6676c5e753c9b --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/get_declaration_nodes_for_plugin.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import { KibanaPlatformPlugin, ToolingLog } from '@kbn/dev-utils'; +import { Project, SourceFile, Node } from 'ts-morph'; +import { ApiScope } from './types'; +import { isNamedNode, getSourceFileMatching } from './tsmorph_utils'; + +/** + * Determines which file in the project to grab nodes from, depending on the plugin and scope, then returns those nodes. + * + * @param project - TS project. + * @param plugin - The plugin we are interested in. + * @param scope - The "scope" of the API we want to extract: public, server or common. + * @param log - logging utility. + * + * @return Every publically exported Node from the given plugin and scope (public, server, common). + */ +export function getDeclarationNodesForPluginScope( + project: Project, + plugin: KibanaPlatformPlugin, + scope: ApiScope, + log: ToolingLog +): Node[] { + const path = Path.join(`${plugin.directory}`, scope.toString(), 'index.ts'); + const file = getSourceFileMatching(project, path); + + if (file) { + return getExportedFileDeclarations(file, log); + } else { + log.debug(`No file found: ${path}`); + return []; + } +} + +/** + * + * @param source the file we want to extract exported declaration nodes from. + * @param log + */ +function getExportedFileDeclarations(source: SourceFile, log: ToolingLog): Node[] { + const nodes: Node[] = []; + const exported = source.getExportedDeclarations(); + + // Filter out the exported declarations that exist only for the plugin system itself. + exported.forEach((val) => { + val.forEach((ed) => { + const name: string = isNamedNode(ed) ? ed.getName() : ''; + + // Every plugin will have an export called "plugin". Don't bother listing + // it, it's only for the plugin infrastructure. + // Config is also a common export on the server side that is just for the + // plugin infrastructure. + if (name === 'plugin' || name === 'config') { + return; + } + if (name && name !== '') { + nodes.push(ed); + } else { + log.warning(`API with missing name encountered.`); + } + }); + }); + + log.debug(`Collected ${nodes.length} exports from file ${source.getFilePath()}`); + return nodes; +} diff --git a/packages/kbn-docs-utils/src/api_docs/get_plugin_api.ts b/packages/kbn-docs-utils/src/api_docs/get_plugin_api.ts new file mode 100644 index 0000000000000..ede60e95ce2b9 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/get_plugin_api.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import { Node, Project, Type } from 'ts-morph'; +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { ApiScope, Lifecycle } from './types'; +import { ApiDeclaration, PluginApi } from './types'; +import { buildApiDeclaration } from './build_api_declarations/build_api_declaration'; +import { getDeclarationNodesForPluginScope } from './get_declaration_nodes_for_plugin'; +import { getSourceFileMatching } from './tsmorph_utils'; + +/** + * Collects all the information neccessary to generate this plugins mdx api file(s). + */ +export function getPluginApi( + project: Project, + plugin: KibanaPlatformPlugin, + plugins: KibanaPlatformPlugin[], + log: ToolingLog +): PluginApi { + const client = getDeclarations(project, plugin, ApiScope.CLIENT, plugins, log); + const server = getDeclarations(project, plugin, ApiScope.SERVER, plugins, log); + const common = getDeclarations(project, plugin, ApiScope.COMMON, plugins, log); + return { + id: plugin.manifest.id, + client, + server, + common, + serviceFolders: plugin.manifest.serviceFolders, + }; +} + +/** + * + * @returns All exported ApiDeclarations for the given plugin and scope (client, server, common), broken into + * groups of typescript kinds (functions, classes, interfaces, etc). + */ +function getDeclarations( + project: Project, + plugin: KibanaPlatformPlugin, + scope: ApiScope, + plugins: KibanaPlatformPlugin[], + log: ToolingLog +): ApiDeclaration[] { + const nodes = getDeclarationNodesForPluginScope(project, plugin, scope, log); + + const contractTypes = getContractTypes(project, plugin, scope); + + const declarations = nodes.reduce((acc, node) => { + const apiDec = buildApiDeclaration(node, plugins, log, plugin.manifest.id, scope); + // Filter out apis with the @internal flag on them. + if (!apiDec.tags || apiDec.tags.indexOf('internal') < 0) { + // buildApiDeclaration doesn't set the lifecycle, so we set it here. + const lifecycle = getLifecycle(node, contractTypes); + acc.push({ + ...apiDec, + lifecycle, + initialIsOpen: lifecycle !== undefined, + }); + } + return acc; + }, []); + + // We have all the ApiDeclarations, now lets group them by typescript kinds. + return declarations; +} + +/** + * Checks if this node is one of the special start or setup contract interface types. We pull these + * to the top of the API docs. + * + * @param node ts-morph node + * @param contractTypeNames the start and setup contract interface names + * @returns Which, if any, lifecycle contract this node happens to represent. + */ +function getLifecycle( + node: Node, + contractTypeNames: { start?: Type; setup?: Type } +): Lifecycle | undefined { + // Note this logic is not tested if a plugin uses "as", + // like export { Setup as MyPluginSetup } from ..." + if (contractTypeNames.start && node.getType() === contractTypeNames.start) { + return Lifecycle.START; + } + + if (contractTypeNames.setup && node.getType() === contractTypeNames.setup) { + return Lifecycle.SETUP; + } +} + +/** + * + * @param project + * @param plugin the plugin we are interested in. + * @param scope Whether we are interested in the client or server plugin contracts. + * Common scope will never return anything. + * @returns the name of the two types used for Start and Setup contracts, if they + * exist and were exported from the plugin class. + */ +function getContractTypes( + project: Project, + plugin: KibanaPlatformPlugin, + scope: ApiScope +): { setup?: Type; start?: Type } { + const contractTypes: { setup?: Type; start?: Type } = {}; + const file = getSourceFileMatching( + project, + Path.join(`${plugin.directory}`, scope.toString(), 'plugin.ts') + ); + if (file) { + file.getClasses().forEach((c) => { + c.getImplements().forEach((i) => { + let index = 0; + i.getType() + .getTypeArguments() + .forEach((arg) => { + // Setup type comes first + if (index === 0) { + contractTypes.setup = arg; + } else if (index === 1) { + contractTypes.start = arg; + } + index++; + }); + }); + }); + } + return contractTypes; +} diff --git a/packages/kbn-docs-utils/src/api_docs/index.ts b/packages/kbn-docs-utils/src/api_docs/index.ts new file mode 100644 index 0000000000000..8dca507764a79 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './build_api_docs_cli'; diff --git a/packages/kbn-docs-utils/src/api_docs/mdx/split_apis_by_folder.test.ts b/packages/kbn-docs-utils/src/api_docs/mdx/split_apis_by_folder.test.ts new file mode 100644 index 0000000000000..67e17c95d2298 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/mdx/split_apis_by_folder.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import { Project } from 'ts-morph'; +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; + +import { PluginApi } from '../types'; +import { getKibanaPlatformPlugin } from '../tests/kibana_platform_plugin_mock'; +import { getPluginApi } from '../get_plugin_api'; +import { splitApisByFolder } from './write_plugin_split_by_folder'; + +const log = new ToolingLog({ + level: 'debug', + writeTo: process.stdout, +}); + +let doc: PluginApi; + +beforeAll(() => { + const tsConfigFilePath = Path.resolve(__dirname, '../tests/__fixtures__/src/tsconfig.json'); + const project = new Project({ + tsConfigFilePath, + }); + + expect(project.getSourceFiles().length).toBeGreaterThan(0); + + const pluginA = getKibanaPlatformPlugin('pluginA'); + pluginA.manifest.serviceFolders = ['foo']; + const plugins: KibanaPlatformPlugin[] = [pluginA]; + + doc = getPluginApi(project, plugins[0], plugins, log); +}); + +test('foo service has all exports', () => { + expect(doc?.client.length).toBe(33); + const split = splitApisByFolder(doc); + expect(split.length).toBe(2); + + const fooDoc = split.find((d) => d.id === 'pluginA.foo'); + const mainDoc = split.find((d) => d.id === 'pluginA'); + + expect(fooDoc?.common.length).toBe(1); + expect(fooDoc?.client.length).toBe(2); + expect(mainDoc?.client.length).toBe(31); +}); diff --git a/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_mdx_docs.ts b/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_mdx_docs.ts new file mode 100644 index 0000000000000..b35515eb9d209 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_mdx_docs.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog } from '@kbn/dev-utils'; +import fs from 'fs'; +import Path from 'path'; +import dedent from 'dedent'; +import { PluginApi, ScopeApi } from '../types'; +import { + countScopeApi, + getPluginApiDocId, + snakeToCamel, + camelToSnake, + groupPluginApi, +} from '../utils'; +import { writePluginDocSplitByFolder } from './write_plugin_split_by_folder'; + +/** + * Converts the plugin doc to mdx and writes it into the file system. If the plugin, + * has serviceFolders specified in it's kibana.json, multiple mdx files will be written. + * + * @param folder The location the mdx files will be written too. + * @param doc Contains the information of the plugin that will be written into mdx. + * @param log Used for logging debug and error information. + */ +export function writePluginDocs(folder: string, doc: PluginApi, log: ToolingLog): void { + if (doc.serviceFolders) { + log.debug(`Splitting plugin ${doc.id}`); + writePluginDocSplitByFolder(folder, doc, log); + } else { + writePluginDoc(folder, doc, log); + } +} + +function hasPublicApi(doc: PluginApi): boolean { + return doc.client.length > 0 || doc.server.length > 0 || doc.common.length > 0; +} + +/** + * Converts the plugin doc to mdx and writes it into the file system. Ignores + * the serviceFolders setting. Use {@link writePluginDocs} if you wish to split + * the plugin into potentially multiple mdx files. + * + * @param folder The location the mdx file will be written too. + * @param doc Contains the information of the plugin that will be written into mdx. + * @param log Used for logging debug and error information. + */ +export function writePluginDoc(folder: string, doc: PluginApi, log: ToolingLog): void { + if (!hasPublicApi(doc)) { + log.debug(`${doc.id} does not have a public api. Skipping.`); + return; + } + + log.debug(`Writing plugin file for ${doc.id}`); + + const fileName = getFileName(doc.id); + // Append "obj" to avoid special names in here. 'case' is one in particular that + // caused issues. + const json = getJsonName(fileName) + 'Obj'; + let mdx = + dedent(` +--- +id: ${getPluginApiDocId(doc.id, log)} +slug: /kibana-dev-docs/${doc.id}PluginApi +title: ${doc.id} +image: https://source.unsplash.com/400x175/?github +summary: API docs for the ${doc.id} plugin +date: 2020-11-16 +tags: ['contributor', 'dev', 'apidocs', 'kibana', '${doc.id}'] +--- + +import ${json} from './${fileName}.json'; + +`) + '\n\n'; + + const scopedDoc = { + ...doc, + client: groupPluginApi(doc.client), + common: groupPluginApi(doc.common), + server: groupPluginApi(doc.server), + }; + fs.writeFileSync(Path.resolve(folder, fileName + '.json'), JSON.stringify(scopedDoc)); + + mdx += scopApiToMdx(scopedDoc.client, 'Client', json, 'client'); + mdx += scopApiToMdx(scopedDoc.server, 'Server', json, 'server'); + mdx += scopApiToMdx(scopedDoc.common, 'Common', json, 'common'); + + fs.writeFileSync(Path.resolve(folder, fileName + '.mdx'), mdx); +} + +function getJsonName(name: string): string { + return snakeToCamel(getFileName(name)); +} + +function getFileName(name: string): string { + return camelToSnake(name.replace('.', '_')); +} + +function scopApiToMdx(scope: ScopeApi, title: string, json: string, scopeName: string): string { + let mdx = ''; + if (countScopeApi(scope) > 0) { + mdx += `## ${title}\n\n`; + + if (scope.setup) { + mdx += `### Setup\n`; + mdx += `\n`; + } + if (scope.start) { + mdx += `### Start\n`; + mdx += `\n`; + } + if (scope.objects.length > 0) { + mdx += `### Objects\n`; + mdx += `\n`; + } + if (scope.functions.length > 0) { + mdx += `### Functions\n`; + mdx += `\n`; + } + if (scope.classes.length > 0) { + mdx += `### Classes\n`; + mdx += `\n`; + } + if (scope.interfaces.length > 0) { + mdx += `### Interfaces\n`; + mdx += `\n`; + } + if (scope.enums.length > 0) { + mdx += `### Enums\n`; + mdx += `\n`; + } + if (scope.misc.length > 0) { + mdx += `### Consts, variables and types\n`; + mdx += `\n`; + } + } + return mdx; +} diff --git a/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_split_by_folder.test.ts b/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_split_by_folder.test.ts new file mode 100644 index 0000000000000..153b3299d8136 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_split_by_folder.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Project } from 'ts-morph'; +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { splitApisByFolder } from './write_plugin_split_by_folder'; +import { getPluginApi } from '../get_plugin_api'; +import { getKibanaPlatformPlugin } from '../tests/kibana_platform_plugin_mock'; + +const log = new ToolingLog({ + level: 'debug', + writeTo: process.stdout, +}); + +it('splitApisByFolder test splitting plugin by service folder', () => { + const project = new Project({ useInMemoryFileSystem: true }); + project.createSourceFile( + 'src/plugins/example/public/index.ts', + ` +import { bar } from './a_service/foo/bar'; +import { Zed, zed } from './a_service/zed'; +import { util } from './utils'; + +export { bar, Zed, zed, mainFoo, util }; +` + ); + project.createSourceFile( + 'src/plugins/example/public/a_service/zed.ts', + `export const zed: string = 'hi'; +export interface Zed = { zed: string }` + ); + project.createSourceFile( + 'src/plugins/example/public/a_service/foo/bar.ts', + `export const bar: string = 'bar';` + ); + project.createSourceFile( + 'src/plugins/example/public/utils.ts', + `export const util: string = 'Util';` + ); + + const plugin = getKibanaPlatformPlugin('example', '/src/plugins/example'); + const plugins: KibanaPlatformPlugin[] = [ + { + ...plugin, + manifest: { + ...plugin.manifest, + serviceFolders: ['a_service'], + }, + }, + ]; + + const doc = getPluginApi(project, plugins[0], plugins, log); + const docs = splitApisByFolder(doc); + + // The api at the main level, and one on a service level. + expect(docs.length).toBe(2); + + const mainDoc = docs.find((d) => d.id === 'example'); + + expect(mainDoc).toBeDefined(); + + const serviceDoc = docs.find((d) => d.id === 'example.aService'); + + expect(serviceDoc).toBeDefined(); + + expect(serviceDoc?.client.length).toBe(3); +}); diff --git a/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_split_by_folder.ts b/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_split_by_folder.ts new file mode 100644 index 0000000000000..f5d547fc03520 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_split_by_folder.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog } from '@kbn/dev-utils'; +import { snakeToCamel } from '../utils'; +import { PluginApi, ApiDeclaration } from '../types'; +import { writePluginDoc } from './write_plugin_mdx_docs'; + +export function writePluginDocSplitByFolder(folder: string, doc: PluginApi, log: ToolingLog) { + const apisByFolder = splitApisByFolder(doc); + + log.debug(`Split ${doc.id} into ${apisByFolder.length} services`); + apisByFolder.forEach((docDef) => { + writePluginDoc(folder, docDef, log); + }); +} + +export function splitApisByFolder(pluginDoc: PluginApi): PluginApi[] { + const pluginDocDefsByFolder: { [key: string]: PluginApi } = {}; + const mainPluginDocDef = createServicePluginDocDef(pluginDoc); + + pluginDoc.client.forEach((dec: ApiDeclaration) => { + addSection(dec, 'client', mainPluginDocDef, pluginDocDefsByFolder, pluginDoc.serviceFolders!); + }); + pluginDoc.server.forEach((dec: ApiDeclaration) => { + addSection(dec, 'server', mainPluginDocDef, pluginDocDefsByFolder, pluginDoc.serviceFolders!); + }); + pluginDoc.common.forEach((dec: ApiDeclaration) => { + addSection(dec, 'common', mainPluginDocDef, pluginDocDefsByFolder, pluginDoc.serviceFolders!); + }); + + return [...Object.values(pluginDocDefsByFolder), mainPluginDocDef]; +} + +function addSection( + dec: ApiDeclaration, + scope: 'client' | 'server' | 'common', + mainPluginDocDef: PluginApi, + pluginServices: { [key: string]: PluginApi }, + serviceFolders: readonly string[] +) { + const scopeFolder = scope === 'client' ? 'public' : scope; + const matchGroup = dec.source.path.match(`.*?\/${scopeFolder}\/([^\/]*?)\/`); + const serviceFolderName = matchGroup ? matchGroup[1] : undefined; + + if (serviceFolderName && serviceFolders.find((f) => f === serviceFolderName)) { + const service = snakeToCamel(serviceFolderName); + if (!pluginServices[service]) { + pluginServices[service] = createServicePluginDocDef(mainPluginDocDef, service); + } + pluginServices[service][scope].push(dec); + } else { + mainPluginDocDef[scope].push(dec); + } +} + +function createServicePluginDocDef(pluginDoc: PluginApi, service?: string): PluginApi { + return { + id: service ? pluginDoc.id + '.' + service : pluginDoc.id, + client: [], + server: [], + common: [], + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts new file mode 100644 index 0000000000000..198856dbb10c4 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const commonFoo = 'COMMON VAR!'; diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts new file mode 100644 index 0000000000000..3fb7e375b2542 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { commonFoo } from './foo'; + +export interface ImACommonType { + goo: number; +} diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/kibana.json b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/kibana.json new file mode 100644 index 0000000000000..84b46caa70802 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "pluginA", + "summary": "This an example plugin for testing the api documentation system", + "version": "kibana", + "serviceFolders": ["foo"] + } + \ No newline at end of file diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts new file mode 100644 index 0000000000000..c68d146f71502 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable max-classes-per-file */ + +import { ImAType } from './types'; + +/** + * An interface with a generic. + */ +export interface WithGen { + t: T; +} + +export interface AnotherInterface { + t: T; +} + +export class ExampleClass implements AnotherInterface { + /** + * This should not be exposed in the docs! + */ + private privateVar: string; + + public component?: React.ComponentType; + + constructor(public t: T) { + this.privateVar = 'hi'; + } + + /** + * an arrow fn on a class. + * @param a im a string + */ + arrowFn = (a: ImAType): ImAType => a; + + /** + * A function on a class. + * @param a a param + */ + getVar(a: ImAType) { + return this.privateVar; + } +} + +export class CrazyClass

extends ExampleClass> {} + +/** + * This is an example interface so we can see how it appears inside the API + * documentation system. + */ +export interface ExampleInterface extends AnotherInterface { + /** + * This gets a promise that resolves to a string. + */ + getAPromiseThatResolvesToString: () => Promise; + + /** + * This function takes a generic. It was sometimes being tripped on + * and returned as an unknown type with no signature. + */ + aFnWithGen: (t: T) => void; + + /** + * These are not coming back properly. + */ + aFn(): void; +} + +/** + * An interface that has a react component. + */ +export interface IReturnAReactComponent { + component: React.ComponentType; +} diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts new file mode 100644 index 0000000000000..52abd520f8259 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CrazyClass } from './classes'; +import { notAnArrowFn } from './fns'; +import { ImAType } from './types'; + +/** + * Some of the plugins wrap static exports in an object to create + * a namespace like this. + */ +export const aPretendNamespaceObj = { + /** + * The docs should show this inline comment. + */ + notAnArrowFn, + + /** + * Should this comment show up? + */ + aPropertyMisdirection: notAnArrowFn, + + /** + * I'm a property inline fun. + */ + aPropertyInlineFn: (a: ImAType): ImAType => { + return a; + }, + + /** + * The only way for this to have a comment is to grab this. + */ + aPropertyStr: 'Hi', + + /** + * Will this nested object have it's children extracted appropriately? + */ + nestedObj: { + foo: 'string', + }, +}; + +/** + * This is a complicated union type + */ +export const aUnionProperty: string | number | (() => string) | CrazyClass = '6'; + +/** + * This is an array of strings. The type is explicit. + */ +export const aStrArray: string[] = ['hi', 'bye']; + +/** + * This is an array of numbers. The type is implied. + */ +export const aNumArray = [1, 3, 4]; + +/** + * A string that says hi to you! + */ +export const aStr: string = 'hi'; + +/** + * It's a number. A special number. + */ +export const aNum = 10; + +/** + * I'm a type of string, but more specifically, a literal string type. + */ +export const literalString = 'HI'; diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts new file mode 100644 index 0000000000000..c341a80c0875d --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { TypeWithGeneric, ImAType } from './types'; + +/** + * This is a non arrow function. + * + * @param a The letter A + * @param b Feed me to the function + * @param c So many params + * @param d a great param + * @param e Another comment + * @returns something! + */ +export function notAnArrowFn( + a: string, + b: number | undefined, + c: TypeWithGeneric, + d: ImAType, + e?: string +): TypeWithGeneric { + return ['hi']; +} + +/** + * This is an arrow function. + * + * @param a The letter A + * @param b Feed me to the function + * @param c So many params + * @param d a great param + * @param e Another comment + * @returns something! + */ +export const arrowFn = ( + a: string, + b: number | undefined, + c: TypeWithGeneric, + d: ImAType, + e?: string +): TypeWithGeneric => { + return ['hi']; +}; + +/** + * Who would write such a complicated function?? Ewwww. + * + * According to https://jsdoc.app/tags-param.html#parameters-with-properties, + * this is how destructured arguements should be commented. + * + * @param obj A very crazy parameter that is destructured when passing in. + * @param objWithFn Im an object with a function. Destructed! + * @param objWithFn.fn A fn. + * @param objWithStr Im an object with a string. Destructed! + * @param objWithStr.str A str. + * + * @returns I have no idea. + * + */ +export const crazyFunction = ( + obj: { hi: string }, + { fn }: { fn: (foo: { param: string }) => number }, + { str }: { str: string } +) => () => () => fn({ param: str }); + +interface ImNotExported { + foo: string; +} + +export const fnWithNonExportedRef = (a: ImNotExported) => 'shi'; + +export type NotAnArrowFnType = typeof notAnArrowFn; diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts new file mode 100644 index 0000000000000..67db6d1a15db5 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const doTheFooFnThing = () => {}; + +export type FooType = () => 'foo'; + +export type ImNotExportedFromIndex = () => { bar: string }; diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts new file mode 100644 index 0000000000000..89bfb07e515ff --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginA, Setup, Start, SearchSpec } from './plugin'; +export { Setup, Start, SearchSpec }; + +export { doTheFooFnThing, FooType } from './foo'; + +export * from './fns'; +export * from './classes'; +export * from './const_vars'; +export * from './types'; + +export const imAnAny: any = 'hi'; +export const imAnUnknown: unknown = 'hi'; + +export function plugin() { + return new PluginA(); +} diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts new file mode 100644 index 0000000000000..839dc828c1886 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// The logic for grabbing Setup and Start types relies on implementing an +// interface with at least two type args. Since the test code isn't adding +// every import file, use this mock, otherwise it won't have the type and will +// fail. +interface PluginMock { + setup(): Sp; + start(): St; +} + +/** + * The SearchSpec interface contains settings for creating a new SearchService, like + * username and password. + */ +export interface SearchSpec { + /** + * Stores the username. Duh, + */ + username: string; + /** + * Stores the password. I hope it's encrypted! + */ + password: string; +} + +/** + * The type of search language. + */ +export enum SearchLanguage { + /** + * The SQL SearchLanguage type + */ + SQL, + /** + * The EQL SearchLanguage type. Support sequences. + */ + EQL, + /** + * The ES DSL SearchLanguage type. It's the default. + */ + ES_DSL, +} + +/** + * Access start functionality from your plugin's start function by adding the example + * plugin as a dependency. + * + * ```ts + * Class MyPlugin { + * start(core: CoreDependencies, { example }: PluginDependencies) { + * // Here you can access this functionality. + * example.getSearchLanguage(); + * } + * } + * ``` + */ +export interface Start { + /** + * @returns The currently selected {@link SearchLanguage} + */ + getSearchLanguage: () => SearchLanguage; +} + +/** + * Access setup functionality from your plugin's setup function by adding the example + * plugin as a dependency. + * + * ```ts + * Class MyPlugin { + * setup(core: CoreDependencies, { example }: PluginDependencies) { + * // Here you can access this functionality. + * example.getSearchService(); + * } + * } + * ``` + */ +export interface Setup { + /** + * A factory function that returns a new instance of Foo based + * on the spec. We aren't sure if this is a good function so it's marked + * beta. That should be clear in the docs because of the js doc tag. + * + * @param searchSpec Provide the settings neccessary to create a new Search Service + * + * @returns the id of the search service. + * + * @beta + */ + getSearchService: (searchSpec: SearchSpec) => string; + + /** + * This uses an inlined object type rather than referencing an exported type, which is discouraged. + * prefer the way {@link getSearchService} is typed. + * + * @param searchSpec Provide the settings neccessary to create a new Search Service + */ + getSearchService2: (searchSpec: { username: string; password: string }) => string; + + /** + * This function does the thing and it's so good at it! But we decided to deprecate it + * anyway. I hope that's clear to developers in the docs! + * + * @param thingOne Thing one comment + * @param thingTwo ThingTwo comment + * @param thingThree Thing three is an object with a nested var + * + * @deprecated + * + */ + doTheThing: (thingOne: number, thingTwo: string, thingThree: { nestedVar: number }) => void; + + /** + * Who would write such a complicated function?? Ew, how will the obj parameter appear in docs? + * + * @param obj A funky parameter. + * + * @returns It's hard to tell but I think this returns a function that returns an object with a + * property that is a function that returns a string. Whoa. + * + */ + fnWithInlineParams: (obj: { + fn: (foo: { param: string }) => number; + }) => () => { retFoo: () => string }; + + /** + * Hi, I'm a comment for an id string! + */ + id: string; +} + +/** + * This comment won't show up in the API docs. + */ +function getSearchService() { + return 'hi'; +} + +function fnWithInlineParams() { + return () => ({ + retFoo: () => 'hi', + }); +} + +/** + * The example search plugin is a fake plugin that is built only to test our api documentation system. + * + */ +export class PluginA implements PluginMock { + setup() { + return { + // Don't put comments here - they won't show up. What's here shouldn't matter because + // the API documentation system works off the type `Setup`. + doTheThing: () => {}, + fnWithInlineParams, + getSearchService, + getSearchService2: getSearchService, + registerSearch: () => {}, + id: '123', + }; + } + + start() { + return { getSearchLanguage: () => SearchLanguage.EQL }; + } +} diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts new file mode 100644 index 0000000000000..0f06f08018c22 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ImACommonType } from '../common'; +import { FooType, ImNotExportedFromIndex } from './foo'; + +/** + * How should a potentially undefined type show up. + */ +export type StringOrUndefinedType = string | undefined; + +export type TypeWithGeneric = T[]; + +export type ImAType = string | number | TypeWithGeneric | FooType | ImACommonType; + +/** + * This is a type that defines a function. + * + * @param t This is a generic T type. It can be anything. + */ +export type FnWithGeneric = (t: T) => TypeWithGeneric; + +/** + * Comments on enums. + */ +export enum DayOfWeek { + THURSDAY, + FRIDAY, // How about this comment, hmmm? + SATURDAY, +} + +/** + * Calling node.getSymbol().getDeclarations() will return > 1 declaration. + */ +export type MultipleDeclarationsType = TypeWithGeneric; + +export type IRefANotExportedType = ImNotExportedFromIndex | { zed: 'hi' }; +export interface ImAnObject { + foo: FnWithGeneric; +} diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/tsconfig.json b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/tsconfig.json new file mode 100644 index 0000000000000..57353d8847ae1 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "incremental": false, + "strictNullChecks": true, + }, + "include": ["./**/*"] +} \ No newline at end of file diff --git a/packages/kbn-docs-utils/src/api_docs/tests/api_doc_suite.test.ts b/packages/kbn-docs-utils/src/api_docs/tests/api_doc_suite.test.ts new file mode 100644 index 0000000000000..f32e58bdfc023 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/api_doc_suite.test.ts @@ -0,0 +1,397 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import fs from 'fs'; +import Path from 'path'; + +import { Project } from 'ts-morph'; +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; + +import { writePluginDocs } from '../mdx/write_plugin_mdx_docs'; +import { ApiDeclaration, PluginApi, Reference, TextWithLinks, TypeKind } from '../types'; +import { getKibanaPlatformPlugin } from './kibana_platform_plugin_mock'; +import { getPluginApi } from '../get_plugin_api'; +import { groupPluginApi } from '../utils'; + +const log = new ToolingLog({ + level: 'debug', + writeTo: process.stdout, +}); + +let doc: PluginApi; +let mdxOutputFolder: string; + +function linkCount(signature: TextWithLinks): number { + return signature.reduce((cnt, next) => (typeof next === 'string' ? cnt : cnt + 1), 0); +} + +function fnIsCorrect(fn: ApiDeclaration | undefined) { + expect(fn).toBeDefined(); + expect(fn?.type).toBe(TypeKind.FunctionKind); + // The signature should contain a link to ExampleInterface param. + expect(fn?.signature).toBeDefined(); + expect(linkCount(fn!.signature!)).toBe(3); + + expect(fn?.children!.length).toBe(5); + expect(fn?.returnComment!.length).toBe(1); + + const p1 = fn?.children!.find((c) => c.label === 'a'); + expect(p1).toBeDefined(); + expect(p1!.type).toBe(TypeKind.StringKind); + expect(p1!.isRequired).toBe(true); + expect(p1!.signature?.length).toBe(1); + expect(linkCount(p1!.signature!)).toBe(0); + + const p2 = fn?.children!.find((c) => c.label === 'b'); + expect(p2).toBeDefined(); + expect(p2!.isRequired).toBe(false); + expect(p2!.type).toBe(TypeKind.NumberKind); + expect(p2!.signature?.length).toBe(1); + expect(linkCount(p2!.signature!)).toBe(0); + + const p3 = fn?.children!.find((c) => c.label === 'c'); + expect(p3).toBeDefined(); + expect(p3!.isRequired).toBe(true); + expect(p3!.type).toBe(TypeKind.ArrayKind); + expect(linkCount(p3!.signature!)).toBe(1); + + const p4 = fn?.children!.find((c) => c.label === 'd'); + expect(p4).toBeDefined(); + expect(p4!.isRequired).toBe(true); + expect(p4!.type).toBe(TypeKind.CompoundTypeKind); + expect(p4!.signature?.length).toBe(1); + expect(linkCount(p4!.signature!)).toBe(1); + + const p5 = fn?.children!.find((c) => c.label === 'e'); + expect(p5).toBeDefined(); + expect(p5!.isRequired).toBe(false); + expect(p5!.type).toBe(TypeKind.StringKind); + expect(p5!.signature?.length).toBe(1); + expect(linkCount(p5!.signature!)).toBe(0); +} + +beforeAll(() => { + const tsConfigFilePath = Path.resolve(__dirname, '__fixtures__/src/tsconfig.json'); + const project = new Project({ + tsConfigFilePath, + }); + + expect(project.getSourceFiles().length).toBeGreaterThan(0); + + const pluginA = getKibanaPlatformPlugin('pluginA'); + pluginA.manifest.serviceFolders = ['foo']; + const plugins: KibanaPlatformPlugin[] = [pluginA]; + + doc = getPluginApi(project, plugins[0], plugins, log); + + mdxOutputFolder = Path.resolve(__dirname, 'snapshots'); + writePluginDocs(mdxOutputFolder, doc, log); +}); + +it('Setup type is extracted', () => { + const grouped = groupPluginApi(doc.client); + expect(grouped.setup).toBeDefined(); +}); + +it('service mdx file was created', () => { + expect(fs.existsSync(Path.resolve(mdxOutputFolder, 'plugin_a_foo.mdx'))).toBe(true); +}); + +it('Setup type has comment', () => { + const grouped = groupPluginApi(doc.client); + expect(grouped.setup!.description).toBeDefined(); + expect(grouped.setup!.description).toMatchInlineSnapshot(` + Array [ + " + Access setup functionality from your plugin's setup function by adding the example + plugin as a dependency. + + \`\`\`ts + Class MyPlugin { + setup(core: CoreDependencies, { example }: PluginDependencies) { + // Here you can access this functionality. + example.getSearchService(); + } + } + \`\`\`", + ] + `); +}); + +it('const exported from common folder is correct', () => { + const fooConst = doc.common.find((c) => c.label === 'commonFoo'); + expect(fooConst).toBeDefined(); + + expect(fooConst!.source.path.replace(Path.sep, '/')).toContain( + 'src/plugin_a/common/foo/index.ts' + ); + expect(fooConst!.signature![0]).toBe('"COMMON VAR!"'); +}); + +describe('functions', () => { + it('function referencing missing type has link removed', () => { + const fn = doc.client.find((c) => c.label === 'fnWithNonExportedRef'); + expect(linkCount(fn?.signature!)).toBe(0); + }); + it('arrow function is exported correctly', () => { + const fn = doc.client.find((c) => c.label === 'arrowFn'); + // Using the same data as the not an arrow function so this is refactored. + fnIsCorrect(fn); + }); + + it('non arrow function is exported correctly', () => { + const fn = doc.client.find((c) => c.label === 'notAnArrowFn'); + // Using the same data as the arrow function so this is refactored. + fnIsCorrect(fn); + }); + + it('crazyFunction is typed correctly', () => { + const fn = doc.client!.find((c) => c.label === 'crazyFunction'); + + expect(fn).toBeDefined(); + + const obj = fn?.children?.find((c) => c.label === 'obj'); + expect(obj).toBeDefined(); + expect(obj!.children?.length).toBe(1); + + const hi = obj?.children?.find((c) => c.label === 'hi'); + expect(hi).toBeDefined(); + + const obj2 = fn?.children?.find((c) => c.label === '{ fn }'); + expect(obj2).toBeDefined(); + expect(obj2!.children?.length).toBe(1); + + const fn2 = obj2?.children?.find((c) => c.label === 'fn'); + expect(fn2).toBeDefined(); + expect(fn2?.type).toBe(TypeKind.FunctionKind); + }); +}); + +describe('objects', () => { + it('Object exported correctly', () => { + const obj = doc.client.find((c) => c.label === 'aPretendNamespaceObj'); + expect(obj).toBeDefined(); + + const fn = obj?.children?.find((c) => c.label === 'notAnArrowFn'); + expect(fn?.signature).toBeDefined(); + // Should just be typeof notAnArrowFn. + expect(linkCount(fn?.signature!)).toBe(1); + // Comment should be the inline one. + expect(fn?.description).toMatchInlineSnapshot(` + Array [ + "/** + * The docs should show this inline comment. + */", + ] + `); + + const fn2 = obj?.children?.find((c) => c.label === 'aPropertyInlineFn'); + expect(fn2?.signature).toBeDefined(); + // Should include 2 links to ImAType + expect(linkCount(fn2?.signature!)).toBe(2); + expect(fn2?.children).toBeDefined(); + + const nestedObj = obj?.children?.find((c) => c.label === 'nestedObj'); + // We aren't giving objects a signature. The children should contain all the information. + expect(nestedObj?.signature).toBeUndefined(); + expect(nestedObj?.children).toBeDefined(); + expect(nestedObj?.type).toBe(TypeKind.ObjectKind); + const foo = nestedObj?.children?.find((c) => c.label === 'foo'); + expect(foo?.type).toBe(TypeKind.StringKind); + }); +}); + +describe('Misc types', () => { + it('Explicitly typed array is returned with the correct type', () => { + const aStrArray = doc.client.find((c) => c.label === 'aStrArray'); + expect(aStrArray).toBeDefined(); + expect(aStrArray?.type).toBe(TypeKind.ArrayKind); + }); + + it('Implicitly typed array is returned with the correct type', () => { + const aNumArray = doc.client.find((c) => c.label === 'aNumArray'); + expect(aNumArray).toBeDefined(); + expect(aNumArray?.type).toBe(TypeKind.ArrayKind); + }); + + it('Explicitly typed string is returned with the correct type', () => { + const aStr = doc.client.find((c) => c.label === 'aStr'); + expect(aStr).toBeDefined(); + expect(aStr?.type).toBe(TypeKind.StringKind); + // signature would be the same as type, so it should be removed. + expect(aStr?.signature).toBeUndefined(); + }); + + it('Implicitly typed number is returned with the correct type', () => { + const aNum = doc.client.find((c) => c.label === 'aNum'); + expect(aNum).toBeDefined(); + expect(aNum?.type).toBe(TypeKind.NumberKind); + }); + + it('aUnionProperty is exported as a CompoundType with a call signature', () => { + const prop = doc.client.find((c) => c.label === 'aUnionProperty'); + expect(prop).toBeDefined(); + expect(prop?.type).toBe(TypeKind.CompoundTypeKind); + expect(linkCount(prop?.signature!)).toBe(1); + }); + + it('Function type is exported correctly', () => { + const fnType = doc.client.find((c) => c.label === 'FnWithGeneric'); + expect(fnType).toBeDefined(); + expect(fnType?.type).toBe(TypeKind.TypeKind); + expect(fnType?.signature!).toMatchInlineSnapshot(` + Array [ + "(t: T) => ", + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric", + }, + "", + ] + `); + expect(linkCount(fnType?.signature!)).toBe(1); + }); + + it('Union type is exported correctly', () => { + const type = doc.client.find((c) => c.label === 'ImAType'); + expect(type).toBeDefined(); + expect(type?.type).toBe(TypeKind.TypeKind); + expect(type?.signature).toBeDefined(); + expect(type?.signature!).toMatchInlineSnapshot(` + Array [ + "string | number | ", + Object { + "docId": "kibPluginAFooPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.FooType", + "text": "FooType", + }, + " | ", + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric", + }, + " | ", + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "common", + "section": "def-common.ImACommonType", + "text": "ImACommonType", + }, + ] + `); + + expect(linkCount(type?.signature!)).toBe(3); + expect((type!.signature![1] as Reference).docId).toBe('kibPluginAFooPluginApi'); + }); +}); + +describe('interfaces and classes', () => { + it('Basic interface exported correctly', () => { + const anInterface = doc.client.find((c) => c.label === 'IReturnAReactComponent'); + expect(anInterface).toBeDefined(); + + // Make sure it doesn't include a self referential link. + expect(anInterface?.signature).toBeUndefined(); + }); + + it('Interface which extends exported correctly', () => { + const exampleInterface = doc.client.find((c) => c.label === 'ExampleInterface'); + expect(exampleInterface).toBeDefined(); + expect(exampleInterface?.signature).toBeDefined(); + expect(exampleInterface?.type).toBe(TypeKind.InterfaceKind); + + expect(linkCount(exampleInterface?.signature!)).toBe(2); + + // TODO: uncomment if the bug is fixed. + // This is wrong, the link should be to `AnotherInterface` + // Another bug, this link is not being captured. + expect(exampleInterface?.signature).toMatchInlineSnapshot(` + Array [ + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.ExampleInterface", + "text": "ExampleInterface", + }, + " extends ", + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.AnotherInterface", + "text": "AnotherInterface", + }, + "", + ] + `); + }); + + it('Non arrow function on interface is exported as function type', () => { + const exampleInterface = doc.client.find((c) => c.label === 'ExampleInterface'); + expect(exampleInterface).toBeDefined(); + + const fn = exampleInterface!.children?.find((c) => c.label === 'aFn'); + expect(fn).toBeDefined(); + expect(fn?.type).toBe(TypeKind.FunctionKind); + }); + + it('Class exported correctly', () => { + const clss = doc.client.find((c) => c.label === 'CrazyClass'); + expect(clss).toBeDefined(); + expect(clss?.signature).toBeDefined(); + expect(clss?.type).toBe(TypeKind.ClassKind); + expect(clss?.signature).toMatchInlineSnapshot(` + Array [ + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.CrazyClass", + "text": "CrazyClass", + }, + "

extends ", + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.ExampleClass", + "text": "ExampleClass", + }, + "<", + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.WithGen", + "text": "WithGen", + }, + "

>", + ] + `); + expect(linkCount(clss?.signature!)).toBe(3); + }); + + it('Function with generic inside interface is exported with function type', () => { + const exampleInterface = doc.client.find((c) => c.label === 'ExampleInterface'); + expect(exampleInterface).toBeDefined(); + + const fnWithGeneric = exampleInterface?.children?.find((c) => c.label === 'aFnWithGen'); + expect(fnWithGeneric).toBeDefined(); + expect(fnWithGeneric?.type).toBe(TypeKind.FunctionKind); + }); +}); diff --git a/packages/kbn-docs-utils/src/api_docs/tests/kibana_platform_plugin_mock.ts b/packages/kbn-docs-utils/src/api_docs/tests/kibana_platform_plugin_mock.ts new file mode 100644 index 0000000000000..9debca91b7ca8 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/kibana_platform_plugin_mock.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPlatformPlugin } from '@kbn/dev-utils'; +import Path from 'path'; + +export function getKibanaPlatformPlugin(id: string, dir?: string): KibanaPlatformPlugin { + const directory = dir ?? Path.resolve(__dirname, '__fixtures__/src/plugin_a'); + return { + manifest: { + id, + ui: true, + server: true, + kibanaVersion: '1', + version: '1', + serviceFolders: [], + requiredPlugins: [], + requiredBundles: [], + optionalPlugins: [], + extraPublicDirs: [], + }, + directory, + manifestPath: Path.resolve(directory, 'kibana.json'), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.json b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.json new file mode 100644 index 0000000000000..db25b8c4f021e --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.json @@ -0,0 +1 @@ +{"id":"pluginA","client":{"classes":[{"id":"def-public.ExampleClass","type":"Class","label":"ExampleClass","description":[],"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ExampleClass","text":"ExampleClass"}," implements ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.AnotherInterface","text":"AnotherInterface"},""],"children":[{"id":"def-public.ExampleClass.component","type":"CompoundType","label":"component","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":30,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L30"},"signature":["React.ComponentClass<{}, any> | React.FunctionComponent<{}> | undefined"]},{"id":"def-public.ExampleClass.Unnamed","type":"Function","label":"Constructor","signature":["any"],"description":[],"children":[{"type":"Uncategorized","label":"t","isRequired":true,"signature":["T"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":32,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L32"}}],"tags":[],"returnComment":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":32,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L32"}},{"id":"def-public.ExampleClass.arrowFn","type":"Function","children":[{"type":"CompoundType","label":"a","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":40,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L40"}}],"signature":["(a: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},") => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":["\nan arrow fn on a class."],"label":"arrowFn","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":40,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L40"},"returnComment":[]},{"id":"def-public.ExampleClass.getVar","type":"Function","label":"getVar","signature":["(a: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},") => string"],"description":["\nA function on a class."],"children":[{"type":"CompoundType","label":"a","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":["a param"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":46,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L46"}}],"tags":[],"returnComment":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":46,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L46"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":24,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L24"},"initialIsOpen":false},{"id":"def-public.CrazyClass","type":"Class","label":"CrazyClass","description":[],"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.CrazyClass","text":"CrazyClass"},"

extends ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ExampleClass","text":"ExampleClass"},"<",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.WithGen","text":"WithGen"},"

>"],"children":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":51,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L51"},"initialIsOpen":false}],"functions":[{"id":"def-public.notAnArrowFn","type":"Function","label":"notAnArrowFn","signature":["(a: string, b: number | undefined, c: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},", d: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},", e: string | undefined) => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"description":["\nThis is a non arrow function.\n"],"children":[{"type":"string","label":"a","isRequired":true,"signature":["string"],"description":["The letter A"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":22,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L22"}},{"type":"number","label":"b","isRequired":false,"signature":["number | undefined"],"description":["Feed me to the function"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":23,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L23"}},{"type":"Array","label":"c","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"description":["So many params"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":24,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L24"}},{"type":"CompoundType","label":"d","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":["a great param"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":25,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L25"}},{"type":"string","label":"e","isRequired":false,"signature":["string | undefined"],"description":["Another comment"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":26,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L26"}}],"tags":[],"returnComment":["something!"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":21,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L21"},"initialIsOpen":false},{"id":"def-public.arrowFn","type":"Function","children":[{"type":"string","label":"a","isRequired":true,"signature":["string"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":42,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L42"}},{"type":"number","label":"b","isRequired":false,"signature":["number | undefined"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":43,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L43"}},{"type":"Array","label":"c","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":44,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L44"}},{"type":"CompoundType","label":"d","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":45,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L45"}},{"type":"string","label":"e","isRequired":false,"signature":["string | undefined"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":46,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L46"}}],"signature":["(a: string, b: number | undefined, c: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},", d: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},", e?: string | undefined) => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"description":["\nThis is an arrow function.\n"],"label":"arrowFn","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":41,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L41"},"returnComment":["something!"],"initialIsOpen":false},{"id":"def-public.crazyFunction","type":"Function","children":[{"id":"def-public.crazyFunction.obj","type":"Object","label":"obj","description":[],"children":[{"id":"def-public.crazyFunction.obj.hi","type":"string","label":"hi","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":67,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L67"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":67,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L67"}},{"id":"def-public.crazyFunction.{-fn }","type":"Object","label":"{ fn }","description":[],"children":[{"id":"def-public.crazyFunction.{-fn }.fn","type":"Function","label":"fn","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":68,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L68"},"signature":["(foo: { param: string; }) => number"]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":68,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L68"}},{"id":"def-public.crazyFunction.{-str }","type":"Object","label":"{ str }","description":[],"children":[{"id":"def-public.crazyFunction.{-str }.str","type":"string","label":"str","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":69,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L69"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":69,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L69"}}],"signature":["(obj: { hi: string; }, { fn }: { fn: (foo: { param: string; }) => number; }, { str }: { str: string; }) => () => () => number"],"description":["\nWho would write such a complicated function?? Ewwww.\n\nAccording to https://jsdoc.app/tags-param.html#parameters-with-properties,\nthis is how destructured arguements should be commented.\n"],"label":"crazyFunction","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":66,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L66"},"returnComment":["I have no idea."],"initialIsOpen":false},{"id":"def-public.fnWithNonExportedRef","type":"Function","children":[{"type":"Object","label":"a","isRequired":true,"signature":["ImNotExported"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":76,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L76"}}],"signature":["(a: ImNotExported) => string"],"description":[],"label":"fnWithNonExportedRef","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":76,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L76"},"returnComment":[],"initialIsOpen":false}],"interfaces":[{"id":"def-public.SearchSpec","type":"Interface","label":"SearchSpec","description":["\nThe SearchSpec interface contains settings for creating a new SearchService, like\nusername and password."],"children":[{"id":"def-public.SearchSpec.username","type":"string","label":"username","description":["\nStores the username. Duh,"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":26,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L26"}},{"id":"def-public.SearchSpec.password","type":"string","label":"password","description":["\nStores the password. I hope it's encrypted!"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":30,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L30"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":22,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L22"},"initialIsOpen":false},{"id":"def-public.WithGen","type":"Interface","label":"WithGen","signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.WithGen","text":"WithGen"},""],"description":["\nAn interface with a generic."],"children":[{"id":"def-public.WithGen.t","type":"Uncategorized","label":"t","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":17,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L17"},"signature":["T"]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":16,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L16"},"initialIsOpen":false},{"id":"def-public.AnotherInterface","type":"Interface","label":"AnotherInterface","signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.AnotherInterface","text":"AnotherInterface"},""],"description":[],"children":[{"id":"def-public.AnotherInterface.t","type":"Uncategorized","label":"t","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":21,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L21"},"signature":["T"]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":20,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L20"},"initialIsOpen":false},{"id":"def-public.ExampleInterface","type":"Interface","label":"ExampleInterface","signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ExampleInterface","text":"ExampleInterface"}," extends ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.AnotherInterface","text":"AnotherInterface"},""],"description":["\nThis is an example interface so we can see how it appears inside the API\ndocumentation system."],"children":[{"id":"def-public.ExampleInterface.getAPromiseThatResolvesToString","type":"Function","label":"getAPromiseThatResolvesToString","description":["\nThis gets a promise that resolves to a string."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":61,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L61"},"signature":["() => Promise"]},{"id":"def-public.ExampleInterface.aFnWithGen","type":"Function","label":"aFnWithGen","description":["\nThis function takes a generic. It was sometimes being tripped on\nand returned as an unknown type with no signature."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":67,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L67"},"signature":["(t: T) => void"]},{"id":"def-public.ExampleInterface.aFn","type":"Function","label":"aFn","signature":["() => void"],"description":["\nThese are not coming back properly."],"children":[],"tags":[],"returnComment":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":72,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L72"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":57,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L57"},"initialIsOpen":false},{"id":"def-public.IReturnAReactComponent","type":"Interface","label":"IReturnAReactComponent","description":["\nAn interface that has a react component."],"children":[{"id":"def-public.IReturnAReactComponent.component","type":"CompoundType","label":"component","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":79,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L79"},"signature":["React.ComponentType<{}>"]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":78,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L78"},"initialIsOpen":false},{"id":"def-public.ImAnObject","type":"Interface","label":"ImAnObject","description":[],"children":[{"id":"def-public.ImAnObject.foo","type":"Function","label":"foo","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":44,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L44"},"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.FnWithGeneric","text":"FnWithGeneric"}]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":43,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L43"},"initialIsOpen":false}],"enums":[{"id":"def-public.DayOfWeek","type":"Enum","label":"DayOfWeek","description":["\nComments on enums."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":31,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L31"},"initialIsOpen":false}],"misc":[{"id":"def-public.imAnAny","type":"Any","label":"imAnAny","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts","lineNumber":19,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts#L19"},"signature":["any"],"initialIsOpen":false},{"id":"def-public.imAnUnknown","type":"Unknown","label":"imAnUnknown","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts","lineNumber":20,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts#L20"},"signature":["unknown"],"initialIsOpen":false},{"id":"def-public.NotAnArrowFnType","type":"Type","label":"NotAnArrowFnType","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":78,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L78"},"signature":["(a: string, b: number | undefined, c: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},", d: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},", e: string | undefined) => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"initialIsOpen":false},{"id":"def-public.aUnionProperty","type":"CompoundType","label":"aUnionProperty","description":["\nThis is a complicated union type"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":51,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L51"},"signature":["string | number | (() => string) | ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.CrazyClass","text":"CrazyClass"},""],"initialIsOpen":false},{"id":"def-public.aStrArray","type":"Array","label":"aStrArray","description":["\nThis is an array of strings. The type is explicit."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":56,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L56"},"signature":["string[]"],"initialIsOpen":false},{"id":"def-public.aNumArray","type":"Array","label":"aNumArray","description":["\nThis is an array of numbers. The type is implied."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":61,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L61"},"signature":["number[]"],"initialIsOpen":false},{"id":"def-public.aStr","type":"string","label":"aStr","description":["\nA string that says hi to you!"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":66,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L66"},"initialIsOpen":false},{"id":"def-public.aNum","type":"number","label":"aNum","description":["\nIt's a number. A special number."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":71,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L71"},"signature":["10"],"initialIsOpen":false},{"id":"def-public.literalString","type":"string","label":"literalString","description":["\nI'm a type of string, but more specifically, a literal string type."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":76,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L76"},"signature":["\"HI\""],"initialIsOpen":false},{"id":"def-public.StringOrUndefinedType","type":"Type","label":"StringOrUndefinedType","description":["\nHow should a potentially undefined type show up."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":15,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L15"},"signature":["undefined | string"],"initialIsOpen":false},{"id":"def-public.TypeWithGeneric","type":"Type","label":"TypeWithGeneric","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":17,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L17"},"signature":["T[]"],"initialIsOpen":false},{"id":"def-public.ImAType","type":"Type","label":"ImAType","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":19,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L19"},"signature":["string | number | ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAFooPluginApi","section":"def-public.FooType","text":"FooType"}," | ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"}," | ",{"pluginId":"pluginA","scope":"common","docId":"kibPluginAPluginApi","section":"def-common.ImACommonType","text":"ImACommonType"}],"initialIsOpen":false},{"id":"def-public.FnWithGeneric","type":"Type","label":"FnWithGeneric","description":["\nThis is a type that defines a function.\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":26,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L26"},"signature":["(t: T) => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"initialIsOpen":false},{"id":"def-public.MultipleDeclarationsType","type":"Type","label":"MultipleDeclarationsType","description":["\nCalling node.getSymbol().getDeclarations() will return > 1 declaration."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":40,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L40"},"signature":["(typeof DayOfWeek)[]"],"initialIsOpen":false},{"id":"def-public.IRefANotExportedType","type":"Type","label":"IRefANotExportedType","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":42,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L42"},"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAFooPluginApi","section":"def-public.ImNotExportedFromIndex","text":"ImNotExportedFromIndex"}," | { zed: \"hi\"; }"],"initialIsOpen":false}],"objects":[{"id":"def-public.aPretendNamespaceObj","type":"Object","children":[{"id":"def-public.aPretendNamespaceObj.notAnArrowFn","type":"Function","label":"notAnArrowFn","description":["/**\n * The docs should show this inline comment.\n */"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":21,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L21"},"signature":["typeof ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.notAnArrowFn","text":"notAnArrowFn"}]},{"id":"def-public.aPretendNamespaceObj.aPropertyMisdirection","type":"Function","label":"aPropertyMisdirection","description":["/**\n * Should this comment show up?\n */"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":26,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L26"},"signature":["typeof ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.notAnArrowFn","text":"notAnArrowFn"}]},{"id":"def-public.aPretendNamespaceObj.aPropertyInlineFn","type":"Function","children":[{"type":"CompoundType","label":"a","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":31,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L31"}}],"signature":["(a: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},") => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":["/**\n * I'm a property inline fun.\n */"],"label":"aPropertyInlineFn","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":31,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L31"},"returnComment":[]},{"id":"def-public.aPretendNamespaceObj.aPropertyStr","type":"string","label":"aPropertyStr","description":["/**\n * The only way for this to have a comment is to grab this.\n */"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":38,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L38"}},{"id":"def-public.aPretendNamespaceObj.nestedObj","type":"Object","children":[{"id":"def-public.aPretendNamespaceObj.nestedObj.foo","type":"string","label":"foo","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":44,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L44"}}],"description":["/**\n * Will this nested object have it's children extracted appropriately?\n */"],"label":"nestedObj","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":43,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L43"}}],"description":["\nSome of the plugins wrap static exports in an object to create\na namespace like this."],"label":"aPretendNamespaceObj","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":17,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L17"},"initialIsOpen":false}],"setup":{"id":"def-public.Setup","type":"Interface","label":"Setup","description":["\nAccess setup functionality from your plugin's setup function by adding the example\nplugin as a dependency.\n\n```ts\nClass MyPlugin {\n setup(core: CoreDependencies, { example }: PluginDependencies) {\n // Here you can access this functionality.\n example.getSearchService();\n }\n}\n```"],"children":[{"id":"def-public.Setup.getSearchService","type":"Function","label":"getSearchService","description":["\nA factory function that returns a new instance of Foo based\non the spec. We aren't sure if this is a good function so it's marked\nbeta. That should be clear in the docs because of the js doc tag.\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":96,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L96"},"signature":["(searchSpec: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.SearchSpec","text":"SearchSpec"},") => string"]},{"id":"def-public.Setup.getSearchService2","type":"Function","label":"getSearchService2","description":["\nThis uses an inlined object type rather than referencing an exported type, which is discouraged.\nprefer the way {@link getSearchService} is typed.\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":104,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L104"},"signature":["(searchSpec: { username: string; password: string; }) => string"]},{"id":"def-public.Setup.doTheThing","type":"Function","label":"doTheThing","description":["\nThis function does the thing and it's so good at it! But we decided to deprecate it\nanyway. I hope that's clear to developers in the docs!\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":117,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L117"},"signature":["(thingOne: number, thingTwo: string, thingThree: { nestedVar: number; }) => void"]},{"id":"def-public.Setup.fnWithInlineParams","type":"Function","label":"fnWithInlineParams","description":["\nWho would write such a complicated function?? Ew, how will the obj parameter appear in docs?\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":128,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L128"},"signature":["(obj: { fn: (foo: { param: string; }) => number; }) => () => { retFoo: () => string; }"]},{"id":"def-public.Setup.id","type":"string","label":"id","description":["\nHi, I'm a comment for an id string!"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":135,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L135"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":84,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L84"},"lifecycle":"setup","initialIsOpen":true},"start":{"id":"def-public.Start","type":"Interface","label":"Start","description":["\nAccess start functionality from your plugin's start function by adding the example\nplugin as a dependency.\n\n```ts\nClass MyPlugin {\n start(core: CoreDependencies, { example }: PluginDependencies) {\n // Here you can access this functionality.\n example.getSearchLanguage();\n }\n}\n```"],"children":[{"id":"def-public.Start.getSearchLanguage","type":"Function","label":"getSearchLanguage","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":68,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L68"},"signature":["() => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.SearchLanguage","text":"SearchLanguage"}]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":64,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L64"},"lifecycle":"start","initialIsOpen":true}},"server":{"classes":[],"functions":[],"interfaces":[],"enums":[],"misc":[],"objects":[]},"common":{"classes":[],"functions":[],"interfaces":[{"id":"def-common.ImACommonType","type":"Interface","label":"ImACommonType","description":[],"children":[{"id":"def-common.ImACommonType.goo","type":"number","label":"goo","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts","lineNumber":12,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts#L12"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts","lineNumber":11,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts#L11"},"initialIsOpen":false}],"enums":[],"misc":[],"objects":[]}} \ No newline at end of file diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.mdx b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.mdx new file mode 100644 index 0000000000000..615bf7cb2460d --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.mdx @@ -0,0 +1,34 @@ +--- +id: kibPluginAPluginApi +slug: /kibana-dev-docs/pluginAPluginApi +title: pluginA +image: https://source.unsplash.com/400x175/?github +summary: API docs for the pluginA plugin +date: 2020-11-16 +tags: ['contributor', 'dev', 'apidocs', 'kibana', 'pluginA'] +--- + +import pluginAObj from './plugin_a.json'; + +## Client + +### Setup + +### Start + +### Objects + +### Functions + +### Classes + +### Interfaces + +### Enums + +### Consts, variables and types + +## Common + +### Interfaces + diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.json b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.json new file mode 100644 index 0000000000000..8b5ec5f3da960 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.json @@ -0,0 +1 @@ +{"id":"pluginA.foo","client":{"classes":[],"functions":[{"id":"def-public.doTheFooFnThing","type":"Function","children":[],"signature":["() => void"],"description":[],"label":"doTheFooFnThing","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts","lineNumber":9,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts#L9"},"returnComment":[],"initialIsOpen":false}],"interfaces":[],"enums":[],"misc":[{"id":"def-public.FooType","type":"Type","label":"FooType","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts","lineNumber":11,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts#L11"},"signature":["() => \"foo\""],"initialIsOpen":false}],"objects":[]},"server":{"classes":[],"functions":[],"interfaces":[],"enums":[],"misc":[],"objects":[]},"common":{"classes":[],"functions":[],"interfaces":[],"enums":[],"misc":[{"id":"def-common.commonFoo","type":"string","label":"commonFoo","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts","lineNumber":9,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts#L9"},"signature":["\"COMMON VAR!\""],"initialIsOpen":false}],"objects":[]}} \ No newline at end of file diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.mdx b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.mdx new file mode 100644 index 0000000000000..a4f05fdeb2076 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.mdx @@ -0,0 +1,22 @@ +--- +id: kibPluginAFooPluginApi +slug: /kibana-dev-docs/pluginA.fooPluginApi +title: pluginA.foo +image: https://source.unsplash.com/400x175/?github +summary: API docs for the pluginA.foo plugin +date: 2020-11-16 +tags: ['contributor', 'dev', 'apidocs', 'kibana', 'pluginA.foo'] +--- + +import pluginAFooObj from './plugin_a_foo.json'; + +## Client + +### Functions + +### Consts, variables and types + +## Common + +### Consts, variables and types + diff --git a/packages/kbn-docs-utils/src/api_docs/tsmorph_utils.ts b/packages/kbn-docs-utils/src/api_docs/tsmorph_utils.ts new file mode 100644 index 0000000000000..f78c538019e2f --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tsmorph_utils.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Node, SourceFile, Project } from 'ts-morph'; + +export interface NamedNode extends Node { + getName(): string; +} + +/** + * ts-morph has a Node.isNamedNode fn but it isn't returning true for all types + * that will have node.getName. + */ +export function isNamedNode(node: Node | NamedNode): node is NamedNode { + return (node as NamedNode).getName !== undefined; +} + +/** + * Helper function to find a source file at a given location. Used to extract + * index.ts files at a given scope. + * + * @param project The ts morph project which contains all the source files + * @param absolutePath The absolute path of the file we want to find + * @returns a source file that exists at the location of the relative path. + */ +export function getSourceFileMatching( + project: Project, + absolutePath: string +): SourceFile | undefined { + return project.getSourceFiles().find((file) => { + return file.getFilePath().startsWith(absolutePath); + }); +} diff --git a/packages/kbn-docs-utils/src/api_docs/types.ts b/packages/kbn-docs-utils/src/api_docs/types.ts new file mode 100644 index 0000000000000..c41cd42e6b424 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/types.ts @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export interface AnchorLink { + /** + * The plugin that contains the API being referenced. + */ + pluginName: string; + /** + * It's possible the client and the server both emit an API with + * the same name so we need scope in here to add uniqueness. + */ + scope: ApiScope; + /** + * The name of the api. + */ + apiName: string; +} + +/** + * The kinds of typescript types we want to show in the docs. `Unknown` is used if + * we aren't accounting for a particular type. See {@link getPropertyTypeKind} + */ +export enum TypeKind { + ClassKind = 'Class', + FunctionKind = 'Function', + ObjectKind = 'Object', + EnumKind = 'Enum', + InterfaceKind = 'Interface', + /** + * Maps to the typescript syntax kind `TypeReferences`. For example, + * export type FooFn = () => string will be a TypeKind, not a FunctionKind. + */ + TypeKind = 'Type', + /** + * Uncategorized is used if a type is encountered that isn't handled. + */ + Uncategorized = 'Uncategorized', + UnknownKind = 'Unknown', // Maps to the unknown typescript type + AnyKind = 'Any', // Maps to the any typescript type + StringKind = 'string', + NumberKind = 'number', + BooleanKind = 'boolean', + ArrayKind = 'Array', + /** + * This will cover things like string | number, or A & B, for lack of something better to put here. + */ + CompoundTypeKind = 'CompoundType', +} + +export interface ScopeApi { + setup?: ApiDeclaration; + start?: ApiDeclaration; + functions: ApiDeclaration[]; + objects: ApiDeclaration[]; + classes: ApiDeclaration[]; + interfaces: ApiDeclaration[]; + enums: ApiDeclaration[]; + misc: ApiDeclaration[]; +} + +export interface PluginApi { + id: string; + serviceFolders?: readonly string[]; + client: ApiDeclaration[]; + server: ApiDeclaration[]; + common: ApiDeclaration[]; +} + +/** + * This is used for displaying code or comments that may contain reference links. For example, a function + * signature that is `(a: import("src/plugin_b").Bar) => void` will be parsed into the following Array: + * + * ```ts + * [ + * '(a: ', + * { docId: 'pluginB', section: 'Bar', text: 'Bar' }, + * ') => void' + * ] + * ``` + * + * This is then used to render text with nested DocLinks so it looks like this: + * + * `(a: => ) => void` + */ +export type TextWithLinks = Array; + +/** + * The information neccessary to build a DocLink. + */ +export interface Reference { + pluginId: string; + scope: ApiScope; + docId: string; + section: string; + text: string; +} + +/** + * This type should eventually be replaced by something inside elastic-docs. + * It's what will be passed to an elastic-docs supplied component to make + * the API docs pretty. + */ +export interface ApiDeclaration { + /** + * Used for an anchor link to this Api. Can't use label as there can be two labels with the same + * text within the Client section and the Server section. + */ + id?: string; + + /** + * The name of the api. + */ + label: string; + + /** + * Should the list be expanded or collapsed initially? + */ + initialIsOpen?: boolean; + + /** + * The kind of type this API represents, e.g. string, number, Object, Interface, Class. + */ + type: TypeKind; + + /** + * Certain types have children. For instance classes have class members, functions will list + * their parameters here, classes will list their class members here, and objects and interfaces + * will list their properties. + */ + children?: ApiDeclaration[]; + + /** + * TODO + */ + isRequired?: boolean; + + /** + * Api node comment. + */ + description?: TextWithLinks; + + /** + * If the type is a function, it's signature should be displayed. Currently this overlaps with type + * sometimes, and will sometimes be left empty for large types (like classes and interfaces). + */ + signature?: TextWithLinks; + + /** + * Relevant for functions with @returns comments. + */ + returnComment?: TextWithLinks; + + /** + * Will contain the tags on a comment, like `beta` or `deprecated`. + * Won't include param or returns tags. + */ + tags?: string[]; + + /** + * Every plugn that exposes functionality from their setup and start contract + * should have a single exported type for each. These get pulled to the top because + * they are accessed differently than other exported functionality and types. + */ + lifecycle?: Lifecycle; + + /** + * Used to create links to github to view the code for this API. + */ + source: SourceLink; +} + +export interface SourceLink { + path: string; + lineNumber: number; + link: string; +} + +/** + * Developers will need to know whether these APIs are available on the client, server, or both. + */ +export enum ApiScope { + CLIENT = 'public', + SERVER = 'server', + COMMON = 'common', +} + +/** + * Start and Setup interfaces are special - their functionality is not imported statically but + * accessible via the dependent plugins start and setup functions. + */ +export enum Lifecycle { + START = 'start', + SETUP = 'setup', +} diff --git a/packages/kbn-docs-utils/src/api_docs/utils.test.ts b/packages/kbn-docs-utils/src/api_docs/utils.test.ts new file mode 100644 index 0000000000000..a506405616a47 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/utils.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPlatformPlugin, ToolingLog } from '@kbn/dev-utils'; +import Path from 'path'; +import { Project } from 'ts-morph'; +import { findPlugins } from './find_plugins'; +import { getPluginApi } from './get_plugin_api'; +import { getKibanaPlatformPlugin } from './tests/kibana_platform_plugin_mock'; +import { PluginApi } from './types'; +import { getPluginForPath, getServiceForPath, removeBrokenLinks } from './utils'; + +const log = new ToolingLog({ + level: 'debug', + writeTo: process.stdout, +}); + +it('test getPluginForPath', () => { + const plugins = findPlugins(); + const path = Path.resolve(__dirname, '../../../../src/plugins/embeddable/public/service/file.ts'); + expect(getPluginForPath(path, plugins)).toBeDefined(); +}); + +it('test getServiceForPath', () => { + expect(getServiceForPath('src/plugins/embed/public/service/file.ts', 'src/plugins/embed')).toBe( + 'service' + ); + expect( + getServiceForPath('src/plugins/embed/public/service/subfolder/file.ts', 'src/plugins/embed') + ).toBe('service'); + expect( + getServiceForPath('src/plugins/embed/public/file.ts', 'src/plugins/embed') + ).toBeUndefined(); + expect( + getServiceForPath('/src/plugins/embed/server/another_service/index', '/src/plugins/embed') + ).toBe('another_service'); + expect(getServiceForPath('src/plugins/embed/server/no_ending', 'src/plugins/embed')).toBe( + undefined + ); + expect( + getServiceForPath('src/plugins/embed/server/routes/public/foo/index.ts', 'src/plugins/embed') + ).toBe('routes'); + expect(getServiceForPath('src/plugins/embed/server/f.ts', 'src/plugins/embed')).toBeUndefined(); + + expect( + getServiceForPath( + '/var/lib/jenkins/workspace/elastic+kibana+pipeline-pull-request/kibana/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index', + '/var/lib/jenkins/workspace/elastic+kibana+pipeline-pull-request/kibana/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a' + ) + ).toBe('foo'); +}); + +it('test removeBrokenLinks', () => { + const tsConfigFilePath = Path.resolve(__dirname, 'tests/__fixtures__/src/tsconfig.json'); + const project = new Project({ + tsConfigFilePath, + }); + + expect(project.getSourceFiles().length).toBeGreaterThan(0); + + const pluginA = getKibanaPlatformPlugin('pluginA'); + pluginA.manifest.serviceFolders = ['foo']; + const plugins: KibanaPlatformPlugin[] = [pluginA]; + + const pluginApiMap: { [key: string]: PluginApi } = {}; + plugins.map((plugin) => { + pluginApiMap[plugin.manifest.id] = getPluginApi(project, plugin, plugins, log); + }); + + const missingApiItems: { [key: string]: string[] } = {}; + + plugins.forEach((plugin) => { + const id = plugin.manifest.id; + const pluginApi = pluginApiMap[id]; + removeBrokenLinks(pluginApi, missingApiItems, pluginApiMap); + }); + expect(missingApiItems.pluginA.indexOf('public.ImNotExportedFromIndex')).toBeGreaterThan(-1); +}); diff --git a/packages/kbn-docs-utils/src/api_docs/utils.ts b/packages/kbn-docs-utils/src/api_docs/utils.ts new file mode 100644 index 0000000000000..34162aa330911 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/utils.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPlatformPlugin, ToolingLog } from '@kbn/dev-utils'; +import { + AnchorLink, + ApiDeclaration, + ScopeApi, + TypeKind, + Lifecycle, + PluginApi, + ApiScope, +} from './types'; + +function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export const camelToSnake = (str: string): string => str.replace(/([A-Z])/g, '_$1').toLowerCase(); + +export const snakeToCamel = (str: string): string => + str.replace(/([-_][a-z])/g, (group) => group.toUpperCase().replace('-', '').replace('_', '')); + +/** + * Returns the plugin that the file belongs to. + * @param path An absolute file path that can point to a file nested inside a plugin + * @param plugins A list of plugins to search through. + */ +export function getPluginForPath( + path: string, + plugins: KibanaPlatformPlugin[] +): KibanaPlatformPlugin | undefined { + return plugins.find((plugin) => path.startsWith(plugin.directory)); +} + +/** + * Groups ApiDeclarations by typescript kind - classes, functions, enums, etc, so they + * can be displayed separately in the mdx files. + */ +export function groupPluginApi(declarations: ApiDeclaration[]): ScopeApi { + const scope = createEmptyScope(); + + declarations.forEach((declaration) => { + addApiDeclarationToScope(declaration, scope); + }); + + return scope; +} + +function escapeRegExp(regexp: string) { + return regexp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +/** + * If the file is at the top level, returns undefined, otherwise returns the + * name of the first nested folder in the plugin. For example a path of + * 'src/plugins/data/public/search_services/file.ts' would return 'search_service' while + * 'src/plugin/data/server/file.ts' would return undefined. + * @param path + */ +export function getServiceForPath(path: string, pluginDirectory: string): string | undefined { + const dir = escapeRegExp(pluginDirectory); + const publicMatchGroups = path.match(`${dir}\/public\/([^\/]*)\/`); + const serverMatchGroups = path.match(`${dir}\/server\/([^\/]*)\/`); + const commonMatchGroups = path.match(`${dir}\/common\/([^\/]*)\/`); + + if (publicMatchGroups && publicMatchGroups.length > 1) { + return publicMatchGroups[1]; + } else if (serverMatchGroups && serverMatchGroups.length > 1) { + return serverMatchGroups[1]; + } else if (commonMatchGroups && commonMatchGroups.length > 1) { + return commonMatchGroups[1]; + } +} + +export function getPluginApiDocId( + id: string, + log: ToolingLog, + serviceInfo?: { + serviceFolders: readonly string[]; + apiPath: string; + directory: string; + } +) { + let service = ''; + const cleanName = id.replace('.', '_'); + if (serviceInfo) { + const serviceName = getServiceForPath(serviceInfo.apiPath, serviceInfo.directory); + log.debug( + `Service for path ${serviceInfo.apiPath} and ${serviceInfo.directory} is ${serviceName}` + ); + const serviceFolder = serviceInfo.serviceFolders?.find((f) => f === serviceName); + + if (serviceFolder) { + service = snakeToCamel(serviceFolder); + } + } + + return `kib${capitalize(snakeToCamel(cleanName)) + capitalize(service)}PluginApi`; +} + +export function getApiSectionId(link: AnchorLink) { + const id = `def-${link.scope}.${link.apiName}`.replace(' ', '-'); + return id; +} + +export function countScopeApi(api: ScopeApi): number { + return ( + (api.setup ? 1 : 0) + + (api.start ? 1 : 0) + + api.classes.length + + api.interfaces.length + + api.functions.length + + api.objects.length + + api.enums.length + + api.misc.length + ); +} + +export function createEmptyScope(): ScopeApi { + return { + classes: [], + functions: [], + interfaces: [], + enums: [], + misc: [], + objects: [], + }; +} + +/** + * Takes the ApiDeclaration and puts it in the appropriate section of the ScopeApi based + * on its TypeKind. + */ +export function addApiDeclarationToScope(declaration: ApiDeclaration, scope: ScopeApi): void { + if (declaration.lifecycle === Lifecycle.SETUP) { + scope.setup = declaration; + } else if (declaration.lifecycle === Lifecycle.START) { + scope.start = declaration; + } else { + switch (declaration.type) { + case TypeKind.ClassKind: + scope.classes.push(declaration); + break; + case TypeKind.InterfaceKind: + scope.interfaces.push(declaration); + break; + case TypeKind.EnumKind: + scope.enums.push(declaration); + break; + case TypeKind.FunctionKind: + scope.functions.push(declaration); + break; + case TypeKind.ObjectKind: + scope.objects.push(declaration); + break; + default: + scope.misc.push(declaration); + } + } +} + +export function removeBrokenLinks( + pluginApi: PluginApi, + missingApiItems: { [key: string]: string[] }, + pluginApiMap: { [key: string]: PluginApi } +) { + (['client', 'common', 'server'] as Array<'client' | 'server' | 'common'>).forEach((scope) => { + pluginApi[scope].forEach((api) => { + if (api.signature) { + api.signature = api.signature.map((sig) => { + if (typeof sig !== 'string') { + if (apiItemExists(sig.text, sig.scope, pluginApiMap[sig.pluginId]) === false) { + if (missingApiItems[sig.pluginId] === undefined) { + missingApiItems[sig.pluginId] = []; + } + missingApiItems[sig.pluginId].push(`${sig.scope}.${sig.text}`); + return sig.text; + } + } + return sig; + }); + } + }); + }); +} + +function apiItemExists(name: string, scope: ApiScope, pluginApi: PluginApi): boolean { + return ( + pluginApi[scopeAccessor(scope)].findIndex((dec: ApiDeclaration) => dec.label === name) >= 0 + ); +} + +function scopeAccessor(scope: ApiScope): 'server' | 'common' | 'client' { + switch (scope) { + case ApiScope.CLIENT: + return 'client'; + case ApiScope.SERVER: + return 'server'; + default: + return 'common'; + } +} diff --git a/packages/kbn-docs-utils/src/index.ts b/packages/kbn-docs-utils/src/index.ts new file mode 100644 index 0000000000000..24aef1bf891f6 --- /dev/null +++ b/packages/kbn-docs-utils/src/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './release_notes'; +export * from './api_docs'; diff --git a/packages/kbn-release-notes/src/cli.ts b/packages/kbn-docs-utils/src/release_notes/cli.ts similarity index 100% rename from packages/kbn-release-notes/src/cli.ts rename to packages/kbn-docs-utils/src/release_notes/cli.ts diff --git a/packages/kbn-release-notes/src/formats/asciidoc.ts b/packages/kbn-docs-utils/src/release_notes/formats/asciidoc.ts similarity index 100% rename from packages/kbn-release-notes/src/formats/asciidoc.ts rename to packages/kbn-docs-utils/src/release_notes/formats/asciidoc.ts diff --git a/packages/kbn-release-notes/src/formats/csv.ts b/packages/kbn-docs-utils/src/release_notes/formats/csv.ts similarity index 100% rename from packages/kbn-release-notes/src/formats/csv.ts rename to packages/kbn-docs-utils/src/release_notes/formats/csv.ts diff --git a/packages/kbn-release-notes/src/formats/format.ts b/packages/kbn-docs-utils/src/release_notes/formats/format.ts similarity index 100% rename from packages/kbn-release-notes/src/formats/format.ts rename to packages/kbn-docs-utils/src/release_notes/formats/format.ts diff --git a/packages/kbn-release-notes/src/formats/index.ts b/packages/kbn-docs-utils/src/release_notes/formats/index.ts similarity index 100% rename from packages/kbn-release-notes/src/formats/index.ts rename to packages/kbn-docs-utils/src/release_notes/formats/index.ts diff --git a/packages/kbn-release-notes/src/index.ts b/packages/kbn-docs-utils/src/release_notes/index.ts similarity index 100% rename from packages/kbn-release-notes/src/index.ts rename to packages/kbn-docs-utils/src/release_notes/index.ts diff --git a/packages/kbn-release-notes/src/lib/classify_pr.ts b/packages/kbn-docs-utils/src/release_notes/lib/classify_pr.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/classify_pr.ts rename to packages/kbn-docs-utils/src/release_notes/lib/classify_pr.ts diff --git a/packages/kbn-release-notes/src/lib/get_fix_references.test.ts b/packages/kbn-docs-utils/src/release_notes/lib/get_fix_references.test.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/get_fix_references.test.ts rename to packages/kbn-docs-utils/src/release_notes/lib/get_fix_references.test.ts diff --git a/packages/kbn-release-notes/src/lib/get_fix_references.ts b/packages/kbn-docs-utils/src/release_notes/lib/get_fix_references.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/get_fix_references.ts rename to packages/kbn-docs-utils/src/release_notes/lib/get_fix_references.ts diff --git a/packages/kbn-release-notes/src/lib/get_note_from_description.test.ts b/packages/kbn-docs-utils/src/release_notes/lib/get_note_from_description.test.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/get_note_from_description.test.ts rename to packages/kbn-docs-utils/src/release_notes/lib/get_note_from_description.test.ts diff --git a/packages/kbn-release-notes/src/lib/get_note_from_description.ts b/packages/kbn-docs-utils/src/release_notes/lib/get_note_from_description.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/get_note_from_description.ts rename to packages/kbn-docs-utils/src/release_notes/lib/get_note_from_description.ts diff --git a/packages/kbn-release-notes/src/lib/index.ts b/packages/kbn-docs-utils/src/release_notes/lib/index.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/index.ts rename to packages/kbn-docs-utils/src/release_notes/lib/index.ts diff --git a/packages/kbn-release-notes/src/lib/irrelevant_pr_summary.ts b/packages/kbn-docs-utils/src/release_notes/lib/irrelevant_pr_summary.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/irrelevant_pr_summary.ts rename to packages/kbn-docs-utils/src/release_notes/lib/irrelevant_pr_summary.ts diff --git a/packages/kbn-release-notes/src/lib/is_pr_relevant.ts b/packages/kbn-docs-utils/src/release_notes/lib/is_pr_relevant.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/is_pr_relevant.ts rename to packages/kbn-docs-utils/src/release_notes/lib/is_pr_relevant.ts diff --git a/packages/kbn-release-notes/src/lib/pr_api.ts b/packages/kbn-docs-utils/src/release_notes/lib/pr_api.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/pr_api.ts rename to packages/kbn-docs-utils/src/release_notes/lib/pr_api.ts diff --git a/packages/kbn-release-notes/src/lib/streams.ts b/packages/kbn-docs-utils/src/release_notes/lib/streams.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/streams.ts rename to packages/kbn-docs-utils/src/release_notes/lib/streams.ts diff --git a/packages/kbn-release-notes/src/lib/type_helpers.ts b/packages/kbn-docs-utils/src/release_notes/lib/type_helpers.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/type_helpers.ts rename to packages/kbn-docs-utils/src/release_notes/lib/type_helpers.ts diff --git a/packages/kbn-release-notes/src/lib/version.test.ts b/packages/kbn-docs-utils/src/release_notes/lib/version.test.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/version.test.ts rename to packages/kbn-docs-utils/src/release_notes/lib/version.test.ts diff --git a/packages/kbn-release-notes/src/lib/version.ts b/packages/kbn-docs-utils/src/release_notes/lib/version.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/version.ts rename to packages/kbn-docs-utils/src/release_notes/lib/version.ts diff --git a/packages/kbn-release-notes/src/release_notes_config.ts b/packages/kbn-docs-utils/src/release_notes/release_notes_config.ts similarity index 100% rename from packages/kbn-release-notes/src/release_notes_config.ts rename to packages/kbn-docs-utils/src/release_notes/release_notes_config.ts diff --git a/packages/kbn-release-notes/tsconfig.json b/packages/kbn-docs-utils/tsconfig.json similarity index 81% rename from packages/kbn-release-notes/tsconfig.json rename to packages/kbn-docs-utils/tsconfig.json index 02209a29e5817..3c683f487b9f2 100644 --- a/packages/kbn-release-notes/tsconfig.json +++ b/packages/kbn-docs-utils/tsconfig.json @@ -8,5 +8,8 @@ }, "include": [ "src/**/*" + ], + "exclude": [ + "**/__fixtures__/**/*" ] } diff --git a/packages/kbn-logging/src/appenders.ts b/packages/kbn-logging/src/appenders.ts index 1b128c0f29201..48422db34b336 100644 --- a/packages/kbn-logging/src/appenders.ts +++ b/packages/kbn-logging/src/appenders.ts @@ -15,6 +15,24 @@ import { LogRecord } from './log_record'; */ export interface Appender { append(record: LogRecord): void; + /** + * Appenders can be "attached" to one another so that they are able to act + * as a sort of middleware by calling `append` on a different appender. + * + * As appenders cannot be attached to each other until they are configured, + * the `addAppender` method can be used to pass in a newly configured appender + * to attach. + */ + addAppender?(appenderRef: string, appender: Appender): void; + /** + * For appenders which implement `addAppender`, they should declare a list of + * `appenderRefs`, which specify the names of the appenders that their configuration + * depends on. + * + * Note that these are the appender key names that the user specifies in their + * config, _not_ the names of the appender types themselves. + */ + appenderRefs?: string[]; } /** diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 51e8a617e2446..f9ddbcac1e09d 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -94,7 +94,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _cli__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "run", function() { return _cli__WEBPACK_IMPORTED_MODULE_0__["run"]; }); -/* harmony import */ var _production__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(519); +/* harmony import */ var _production__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(518); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildBazelProductionProjects", function() { return _production__WEBPACK_IMPORTED_MODULE_1__["buildBazelProductionProjects"]; }); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildNonBazelProductionProjects", function() { return _production__WEBPACK_IMPORTED_MODULE_1__["buildNonBazelProductionProjects"]; }); @@ -108,7 +108,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(251); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "transformDependencies", function() { return _utils_package_json__WEBPACK_IMPORTED_MODULE_4__["transformDependencies"]; }); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(518); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(517); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "getProjectPaths", function() { return _config__WEBPACK_IMPORTED_MODULE_5__["getProjectPaths"]; }); /* @@ -59481,7 +59481,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var is_path_inside__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(is_path_inside__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var _yarn_lock__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(365); /* harmony import */ var _projects__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(248); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(518); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(517); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } @@ -59638,9 +59638,9 @@ class Kibana { "use strict"; const minimatch = __webpack_require__(150); -const arrayUnion = __webpack_require__(515); -const arrayDiffer = __webpack_require__(516); -const arrify = __webpack_require__(517); +const arrayUnion = __webpack_require__(145); +const arrayDiffer = __webpack_require__(515); +const arrify = __webpack_require__(516); module.exports = (list, patterns, options = {}) => { list = arrify(list); @@ -59670,18 +59670,6 @@ module.exports = (list, patterns, options = {}) => { "use strict"; -module.exports = (...arguments_) => { - return [...new Set([].concat(...arguments_))]; -}; - - -/***/ }), -/* 516 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - const arrayDiffer = (array, ...values) => { const rest = new Set([].concat(...values)); return array.filter(element => !rest.has(element)); @@ -59691,7 +59679,7 @@ module.exports = arrayDiffer; /***/ }), -/* 517 */ +/* 516 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59721,7 +59709,7 @@ module.exports = arrify; /***/ }), -/* 518 */ +/* 517 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -59780,12 +59768,12 @@ function getProjectPaths({ } /***/ }), -/* 519 */ +/* 518 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); -/* harmony import */ var _build_bazel_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(520); +/* harmony import */ var _build_bazel_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(519); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildBazelProductionProjects", function() { return _build_bazel_production_projects__WEBPACK_IMPORTED_MODULE_0__["buildBazelProductionProjects"]; }); /* harmony import */ var _build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(745); @@ -59802,15 +59790,15 @@ __webpack_require__.r(__webpack_exports__); /***/ }), -/* 520 */ +/* 519 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "buildBazelProductionProjects", function() { return buildBazelProductionProjects; }); -/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(521); +/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(520); /* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(cpy__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var globby__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(737); +/* harmony import */ var globby__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(736); /* harmony import */ var globby__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(globby__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); @@ -59910,7 +59898,7 @@ async function applyCorrectPermissions(project, kibanaRoot, buildRoot) { } /***/ }), -/* 521 */ +/* 520 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59918,14 +59906,14 @@ async function applyCorrectPermissions(project, kibanaRoot, buildRoot) { const EventEmitter = __webpack_require__(156); const path = __webpack_require__(4); const os = __webpack_require__(121); -const pMap = __webpack_require__(522); -const arrify = __webpack_require__(517); -const globby = __webpack_require__(525); -const hasGlob = __webpack_require__(721); -const cpFile = __webpack_require__(723); -const junk = __webpack_require__(733); -const pFilter = __webpack_require__(734); -const CpyError = __webpack_require__(736); +const pMap = __webpack_require__(521); +const arrify = __webpack_require__(516); +const globby = __webpack_require__(524); +const hasGlob = __webpack_require__(720); +const cpFile = __webpack_require__(722); +const junk = __webpack_require__(732); +const pFilter = __webpack_require__(733); +const CpyError = __webpack_require__(735); const defaultOptions = { ignoreJunk: true @@ -60076,12 +60064,12 @@ module.exports = (source, destination, { /***/ }), -/* 522 */ +/* 521 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const AggregateError = __webpack_require__(523); +const AggregateError = __webpack_require__(522); module.exports = async ( iterable, @@ -60164,12 +60152,12 @@ module.exports = async ( /***/ }), -/* 523 */ +/* 522 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const indentString = __webpack_require__(524); +const indentString = __webpack_require__(523); const cleanStack = __webpack_require__(244); const cleanInternalStack = stack => stack.replace(/\s+at .*aggregate-error\/index.js:\d+:\d+\)?/g, ''); @@ -60218,7 +60206,7 @@ module.exports = AggregateError; /***/ }), -/* 524 */ +/* 523 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60260,17 +60248,17 @@ module.exports = (string, count = 1, options) => { /***/ }), -/* 525 */ +/* 524 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const arrayUnion = __webpack_require__(526); +const arrayUnion = __webpack_require__(525); const glob = __webpack_require__(147); -const fastGlob = __webpack_require__(528); -const dirGlob = __webpack_require__(714); -const gitignore = __webpack_require__(717); +const fastGlob = __webpack_require__(527); +const dirGlob = __webpack_require__(713); +const gitignore = __webpack_require__(716); const DEFAULT_FILTER = () => false; @@ -60415,12 +60403,12 @@ module.exports.gitignore = gitignore; /***/ }), -/* 526 */ +/* 525 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var arrayUniq = __webpack_require__(527); +var arrayUniq = __webpack_require__(526); module.exports = function () { return arrayUniq([].concat.apply([], arguments)); @@ -60428,7 +60416,7 @@ module.exports = function () { /***/ }), -/* 527 */ +/* 526 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60497,10 +60485,10 @@ if ('Set' in global) { /***/ }), -/* 528 */ +/* 527 */ /***/ (function(module, exports, __webpack_require__) { -const pkg = __webpack_require__(529); +const pkg = __webpack_require__(528); module.exports = pkg.async; module.exports.default = pkg.async; @@ -60513,19 +60501,19 @@ module.exports.generateTasks = pkg.generateTasks; /***/ }), -/* 529 */ +/* 528 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var optionsManager = __webpack_require__(530); -var taskManager = __webpack_require__(531); -var reader_async_1 = __webpack_require__(685); -var reader_stream_1 = __webpack_require__(709); -var reader_sync_1 = __webpack_require__(710); -var arrayUtils = __webpack_require__(712); -var streamUtils = __webpack_require__(713); +var optionsManager = __webpack_require__(529); +var taskManager = __webpack_require__(530); +var reader_async_1 = __webpack_require__(684); +var reader_stream_1 = __webpack_require__(708); +var reader_sync_1 = __webpack_require__(709); +var arrayUtils = __webpack_require__(711); +var streamUtils = __webpack_require__(712); /** * Synchronous API. */ @@ -60591,7 +60579,7 @@ function isString(source) { /***/ }), -/* 530 */ +/* 529 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60629,13 +60617,13 @@ exports.prepare = prepare; /***/ }), -/* 531 */ +/* 530 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var patternUtils = __webpack_require__(532); +var patternUtils = __webpack_require__(531); /** * Generate tasks based on parent directory of each pattern. */ @@ -60726,16 +60714,16 @@ exports.convertPatternGroupToTask = convertPatternGroupToTask; /***/ }), -/* 532 */ +/* 531 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(4); -var globParent = __webpack_require__(533); +var globParent = __webpack_require__(532); var isGlob = __webpack_require__(172); -var micromatch = __webpack_require__(536); +var micromatch = __webpack_require__(535); var GLOBSTAR = '**'; /** * Return true for static pattern. @@ -60881,15 +60869,15 @@ exports.matchAny = matchAny; /***/ }), -/* 533 */ +/* 532 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var path = __webpack_require__(4); -var isglob = __webpack_require__(534); -var pathDirname = __webpack_require__(535); +var isglob = __webpack_require__(533); +var pathDirname = __webpack_require__(534); var isWin32 = __webpack_require__(121).platform() === 'win32'; module.exports = function globParent(str) { @@ -60912,7 +60900,7 @@ module.exports = function globParent(str) { /***/ }), -/* 534 */ +/* 533 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -60943,7 +60931,7 @@ module.exports = function isGlob(str) { /***/ }), -/* 535 */ +/* 534 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -61093,7 +61081,7 @@ module.exports.win32 = win32; /***/ }), -/* 536 */ +/* 535 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -61104,18 +61092,18 @@ module.exports.win32 = win32; */ var util = __webpack_require__(112); -var braces = __webpack_require__(537); -var toRegex = __webpack_require__(538); -var extend = __webpack_require__(651); +var braces = __webpack_require__(536); +var toRegex = __webpack_require__(537); +var extend = __webpack_require__(650); /** * Local dependencies */ -var compilers = __webpack_require__(653); -var parsers = __webpack_require__(680); -var cache = __webpack_require__(681); -var utils = __webpack_require__(682); +var compilers = __webpack_require__(652); +var parsers = __webpack_require__(679); +var cache = __webpack_require__(680); +var utils = __webpack_require__(681); var MAX_LENGTH = 1024 * 64; /** @@ -61977,7 +61965,7 @@ module.exports = micromatch; /***/ }), -/* 537 */ +/* 536 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -61987,18 +61975,18 @@ module.exports = micromatch; * Module dependencies */ -var toRegex = __webpack_require__(538); -var unique = __webpack_require__(560); -var extend = __webpack_require__(561); +var toRegex = __webpack_require__(537); +var unique = __webpack_require__(559); +var extend = __webpack_require__(560); /** * Local dependencies */ -var compilers = __webpack_require__(563); -var parsers = __webpack_require__(576); -var Braces = __webpack_require__(580); -var utils = __webpack_require__(564); +var compilers = __webpack_require__(562); +var parsers = __webpack_require__(575); +var Braces = __webpack_require__(579); +var utils = __webpack_require__(563); var MAX_LENGTH = 1024 * 64; var cache = {}; @@ -62302,16 +62290,16 @@ module.exports = braces; /***/ }), -/* 538 */ +/* 537 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var safe = __webpack_require__(539); -var define = __webpack_require__(545); -var extend = __webpack_require__(553); -var not = __webpack_require__(557); +var safe = __webpack_require__(538); +var define = __webpack_require__(544); +var extend = __webpack_require__(552); +var not = __webpack_require__(556); var MAX_LENGTH = 1024 * 64; /** @@ -62464,10 +62452,10 @@ module.exports.makeRe = makeRe; /***/ }), -/* 539 */ +/* 538 */ /***/ (function(module, exports, __webpack_require__) { -var parse = __webpack_require__(540); +var parse = __webpack_require__(539); var types = parse.types; module.exports = function (re, opts) { @@ -62513,13 +62501,13 @@ function isRegExp (x) { /***/ }), -/* 540 */ +/* 539 */ /***/ (function(module, exports, __webpack_require__) { -var util = __webpack_require__(541); -var types = __webpack_require__(542); -var sets = __webpack_require__(543); -var positions = __webpack_require__(544); +var util = __webpack_require__(540); +var types = __webpack_require__(541); +var sets = __webpack_require__(542); +var positions = __webpack_require__(543); module.exports = function(regexpStr) { @@ -62801,11 +62789,11 @@ module.exports.types = types; /***/ }), -/* 541 */ +/* 540 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(542); -var sets = __webpack_require__(543); +var types = __webpack_require__(541); +var sets = __webpack_require__(542); // All of these are private and only used by randexp. @@ -62918,7 +62906,7 @@ exports.error = function(regexp, msg) { /***/ }), -/* 542 */ +/* 541 */ /***/ (function(module, exports) { module.exports = { @@ -62934,10 +62922,10 @@ module.exports = { /***/ }), -/* 543 */ +/* 542 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(542); +var types = __webpack_require__(541); var INTS = function() { return [{ type: types.RANGE , from: 48, to: 57 }]; @@ -63022,10 +63010,10 @@ exports.anyChar = function() { /***/ }), -/* 544 */ +/* 543 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(542); +var types = __webpack_require__(541); exports.wordBoundary = function() { return { type: types.POSITION, value: 'b' }; @@ -63045,7 +63033,7 @@ exports.end = function() { /***/ }), -/* 545 */ +/* 544 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63058,8 +63046,8 @@ exports.end = function() { -var isobject = __webpack_require__(546); -var isDescriptor = __webpack_require__(547); +var isobject = __webpack_require__(545); +var isDescriptor = __webpack_require__(546); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -63090,7 +63078,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 546 */ +/* 545 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63109,7 +63097,7 @@ module.exports = function isObject(val) { /***/ }), -/* 547 */ +/* 546 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63122,9 +63110,9 @@ module.exports = function isObject(val) { -var typeOf = __webpack_require__(548); -var isAccessor = __webpack_require__(549); -var isData = __webpack_require__(551); +var typeOf = __webpack_require__(547); +var isAccessor = __webpack_require__(548); +var isData = __webpack_require__(550); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -63138,7 +63126,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 548 */ +/* 547 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -63273,7 +63261,7 @@ function isBuffer(val) { /***/ }), -/* 549 */ +/* 548 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63286,7 +63274,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(550); +var typeOf = __webpack_require__(549); // accessor descriptor properties var accessor = { @@ -63349,7 +63337,7 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 550 */ +/* 549 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -63484,7 +63472,7 @@ function isBuffer(val) { /***/ }), -/* 551 */ +/* 550 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63497,7 +63485,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(552); +var typeOf = __webpack_require__(551); module.exports = function isDataDescriptor(obj, prop) { // data descriptor properties @@ -63540,7 +63528,7 @@ module.exports = function isDataDescriptor(obj, prop) { /***/ }), -/* 552 */ +/* 551 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -63675,14 +63663,14 @@ function isBuffer(val) { /***/ }), -/* 553 */ +/* 552 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(554); -var assignSymbols = __webpack_require__(556); +var isExtendable = __webpack_require__(553); +var assignSymbols = __webpack_require__(555); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -63742,7 +63730,7 @@ function isEnum(obj, key) { /***/ }), -/* 554 */ +/* 553 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63755,7 +63743,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(555); +var isPlainObject = __webpack_require__(554); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -63763,7 +63751,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 555 */ +/* 554 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63776,7 +63764,7 @@ module.exports = function isExtendable(val) { -var isObject = __webpack_require__(546); +var isObject = __webpack_require__(545); function isObjectObject(o) { return isObject(o) === true @@ -63807,7 +63795,7 @@ module.exports = function isPlainObject(o) { /***/ }), -/* 556 */ +/* 555 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63854,14 +63842,14 @@ module.exports = function(receiver, objects) { /***/ }), -/* 557 */ +/* 556 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(558); -var safe = __webpack_require__(539); +var extend = __webpack_require__(557); +var safe = __webpack_require__(538); /** * The main export is a function that takes a `pattern` string and an `options` object. @@ -63933,14 +63921,14 @@ module.exports = toRegex; /***/ }), -/* 558 */ +/* 557 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(559); -var assignSymbols = __webpack_require__(556); +var isExtendable = __webpack_require__(558); +var assignSymbols = __webpack_require__(555); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -64000,7 +63988,7 @@ function isEnum(obj, key) { /***/ }), -/* 559 */ +/* 558 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64013,7 +64001,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(555); +var isPlainObject = __webpack_require__(554); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -64021,7 +64009,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 560 */ +/* 559 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64071,13 +64059,13 @@ module.exports.immutable = function uniqueImmutable(arr) { /***/ }), -/* 561 */ +/* 560 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(562); +var isObject = __webpack_require__(561); module.exports = function extend(o/*, objects*/) { if (!isObject(o)) { o = {}; } @@ -64111,7 +64099,7 @@ function hasOwn(obj, key) { /***/ }), -/* 562 */ +/* 561 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64131,13 +64119,13 @@ module.exports = function isExtendable(val) { /***/ }), -/* 563 */ +/* 562 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(564); +var utils = __webpack_require__(563); module.exports = function(braces, options) { braces.compiler @@ -64420,25 +64408,25 @@ function hasQueue(node) { /***/ }), -/* 564 */ +/* 563 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var splitString = __webpack_require__(565); +var splitString = __webpack_require__(564); var utils = module.exports; /** * Module dependencies */ -utils.extend = __webpack_require__(561); -utils.flatten = __webpack_require__(568); -utils.isObject = __webpack_require__(546); -utils.fillRange = __webpack_require__(569); -utils.repeat = __webpack_require__(575); -utils.unique = __webpack_require__(560); +utils.extend = __webpack_require__(560); +utils.flatten = __webpack_require__(567); +utils.isObject = __webpack_require__(545); +utils.fillRange = __webpack_require__(568); +utils.repeat = __webpack_require__(574); +utils.unique = __webpack_require__(559); utils.define = function(obj, key, val) { Object.defineProperty(obj, key, { @@ -64770,7 +64758,7 @@ utils.escapeRegex = function(str) { /***/ }), -/* 565 */ +/* 564 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64783,7 +64771,7 @@ utils.escapeRegex = function(str) { -var extend = __webpack_require__(566); +var extend = __webpack_require__(565); module.exports = function(str, options, fn) { if (typeof str !== 'string') { @@ -64948,14 +64936,14 @@ function keepEscaping(opts, str, idx) { /***/ }), -/* 566 */ +/* 565 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(567); -var assignSymbols = __webpack_require__(556); +var isExtendable = __webpack_require__(566); +var assignSymbols = __webpack_require__(555); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -65015,7 +65003,7 @@ function isEnum(obj, key) { /***/ }), -/* 567 */ +/* 566 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65028,7 +65016,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(555); +var isPlainObject = __webpack_require__(554); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -65036,7 +65024,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 568 */ +/* 567 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65065,7 +65053,7 @@ function flat(arr, res) { /***/ }), -/* 569 */ +/* 568 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65079,10 +65067,10 @@ function flat(arr, res) { var util = __webpack_require__(112); -var isNumber = __webpack_require__(570); -var extend = __webpack_require__(561); -var repeat = __webpack_require__(573); -var toRegex = __webpack_require__(574); +var isNumber = __webpack_require__(569); +var extend = __webpack_require__(560); +var repeat = __webpack_require__(572); +var toRegex = __webpack_require__(573); /** * Return a range of numbers or letters. @@ -65280,7 +65268,7 @@ module.exports = fillRange; /***/ }), -/* 570 */ +/* 569 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65293,7 +65281,7 @@ module.exports = fillRange; -var typeOf = __webpack_require__(571); +var typeOf = __webpack_require__(570); module.exports = function isNumber(num) { var type = typeOf(num); @@ -65309,10 +65297,10 @@ module.exports = function isNumber(num) { /***/ }), -/* 571 */ +/* 570 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(572); +var isBuffer = __webpack_require__(571); var toString = Object.prototype.toString; /** @@ -65431,7 +65419,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 572 */ +/* 571 */ /***/ (function(module, exports) { /*! @@ -65458,7 +65446,7 @@ function isSlowBuffer (obj) { /***/ }), -/* 573 */ +/* 572 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65535,7 +65523,7 @@ function repeat(str, num) { /***/ }), -/* 574 */ +/* 573 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65548,8 +65536,8 @@ function repeat(str, num) { -var repeat = __webpack_require__(573); -var isNumber = __webpack_require__(570); +var repeat = __webpack_require__(572); +var isNumber = __webpack_require__(569); var cache = {}; function toRegexRange(min, max, options) { @@ -65836,7 +65824,7 @@ module.exports = toRegexRange; /***/ }), -/* 575 */ +/* 574 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65861,14 +65849,14 @@ module.exports = function repeat(ele, num) { /***/ }), -/* 576 */ +/* 575 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Node = __webpack_require__(577); -var utils = __webpack_require__(564); +var Node = __webpack_require__(576); +var utils = __webpack_require__(563); /** * Braces parsers @@ -66228,15 +66216,15 @@ function concatNodes(pos, node, parent, options) { /***/ }), -/* 577 */ +/* 576 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(546); -var define = __webpack_require__(578); -var utils = __webpack_require__(579); +var isObject = __webpack_require__(545); +var define = __webpack_require__(577); +var utils = __webpack_require__(578); var ownNames; /** @@ -66727,7 +66715,7 @@ exports = module.exports = Node; /***/ }), -/* 578 */ +/* 577 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -66740,7 +66728,7 @@ exports = module.exports = Node; -var isDescriptor = __webpack_require__(547); +var isDescriptor = __webpack_require__(546); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -66765,13 +66753,13 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 579 */ +/* 578 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var typeOf = __webpack_require__(571); +var typeOf = __webpack_require__(570); var utils = module.exports; /** @@ -67791,17 +67779,17 @@ function assert(val, message) { /***/ }), -/* 580 */ +/* 579 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(561); -var Snapdragon = __webpack_require__(581); -var compilers = __webpack_require__(563); -var parsers = __webpack_require__(576); -var utils = __webpack_require__(564); +var extend = __webpack_require__(560); +var Snapdragon = __webpack_require__(580); +var compilers = __webpack_require__(562); +var parsers = __webpack_require__(575); +var utils = __webpack_require__(563); /** * Customize Snapdragon parser and renderer @@ -67902,17 +67890,17 @@ module.exports = Braces; /***/ }), -/* 581 */ +/* 580 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Base = __webpack_require__(582); -var define = __webpack_require__(609); -var Compiler = __webpack_require__(619); -var Parser = __webpack_require__(648); -var utils = __webpack_require__(628); +var Base = __webpack_require__(581); +var define = __webpack_require__(608); +var Compiler = __webpack_require__(618); +var Parser = __webpack_require__(647); +var utils = __webpack_require__(627); var regexCache = {}; var cache = {}; @@ -68083,20 +68071,20 @@ module.exports.Parser = Parser; /***/ }), -/* 582 */ +/* 581 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var define = __webpack_require__(583); -var CacheBase = __webpack_require__(584); -var Emitter = __webpack_require__(585); -var isObject = __webpack_require__(546); -var merge = __webpack_require__(603); -var pascal = __webpack_require__(606); -var cu = __webpack_require__(607); +var define = __webpack_require__(582); +var CacheBase = __webpack_require__(583); +var Emitter = __webpack_require__(584); +var isObject = __webpack_require__(545); +var merge = __webpack_require__(602); +var pascal = __webpack_require__(605); +var cu = __webpack_require__(606); /** * Optionally define a custom `cache` namespace to use. @@ -68525,7 +68513,7 @@ module.exports.namespace = namespace; /***/ }), -/* 583 */ +/* 582 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68538,7 +68526,7 @@ module.exports.namespace = namespace; -var isDescriptor = __webpack_require__(547); +var isDescriptor = __webpack_require__(546); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -68563,21 +68551,21 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 584 */ +/* 583 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(546); -var Emitter = __webpack_require__(585); -var visit = __webpack_require__(586); -var toPath = __webpack_require__(589); -var union = __webpack_require__(590); -var del = __webpack_require__(594); -var get = __webpack_require__(592); -var has = __webpack_require__(599); -var set = __webpack_require__(602); +var isObject = __webpack_require__(545); +var Emitter = __webpack_require__(584); +var visit = __webpack_require__(585); +var toPath = __webpack_require__(588); +var union = __webpack_require__(589); +var del = __webpack_require__(593); +var get = __webpack_require__(591); +var has = __webpack_require__(598); +var set = __webpack_require__(601); /** * Create a `Cache` constructor that when instantiated will @@ -68831,7 +68819,7 @@ module.exports.namespace = namespace; /***/ }), -/* 585 */ +/* 584 */ /***/ (function(module, exports, __webpack_require__) { @@ -69000,7 +68988,7 @@ Emitter.prototype.hasListeners = function(event){ /***/ }), -/* 586 */ +/* 585 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69013,8 +69001,8 @@ Emitter.prototype.hasListeners = function(event){ -var visit = __webpack_require__(587); -var mapVisit = __webpack_require__(588); +var visit = __webpack_require__(586); +var mapVisit = __webpack_require__(587); module.exports = function(collection, method, val) { var result; @@ -69037,7 +69025,7 @@ module.exports = function(collection, method, val) { /***/ }), -/* 587 */ +/* 586 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69050,7 +69038,7 @@ module.exports = function(collection, method, val) { -var isObject = __webpack_require__(546); +var isObject = __webpack_require__(545); module.exports = function visit(thisArg, method, target, val) { if (!isObject(thisArg) && typeof thisArg !== 'function') { @@ -69077,14 +69065,14 @@ module.exports = function visit(thisArg, method, target, val) { /***/ }), -/* 588 */ +/* 587 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var visit = __webpack_require__(587); +var visit = __webpack_require__(586); /** * Map `visit` over an array of objects. @@ -69121,7 +69109,7 @@ function isObject(val) { /***/ }), -/* 589 */ +/* 588 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69134,7 +69122,7 @@ function isObject(val) { -var typeOf = __webpack_require__(571); +var typeOf = __webpack_require__(570); module.exports = function toPath(args) { if (typeOf(args) !== 'arguments') { @@ -69161,16 +69149,16 @@ function filter(arr) { /***/ }), -/* 590 */ +/* 589 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(562); -var union = __webpack_require__(591); -var get = __webpack_require__(592); -var set = __webpack_require__(593); +var isObject = __webpack_require__(561); +var union = __webpack_require__(590); +var get = __webpack_require__(591); +var set = __webpack_require__(592); module.exports = function unionValue(obj, prop, value) { if (!isObject(obj)) { @@ -69198,7 +69186,7 @@ function arrayify(val) { /***/ }), -/* 591 */ +/* 590 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69234,7 +69222,7 @@ module.exports = function union(init) { /***/ }), -/* 592 */ +/* 591 */ /***/ (function(module, exports) { /*! @@ -69290,7 +69278,7 @@ function toString(val) { /***/ }), -/* 593 */ +/* 592 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69303,10 +69291,10 @@ function toString(val) { -var split = __webpack_require__(565); -var extend = __webpack_require__(561); -var isPlainObject = __webpack_require__(555); -var isObject = __webpack_require__(562); +var split = __webpack_require__(564); +var extend = __webpack_require__(560); +var isPlainObject = __webpack_require__(554); +var isObject = __webpack_require__(561); module.exports = function(obj, prop, val) { if (!isObject(obj)) { @@ -69352,7 +69340,7 @@ function isValidKey(key) { /***/ }), -/* 594 */ +/* 593 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69365,8 +69353,8 @@ function isValidKey(key) { -var isObject = __webpack_require__(546); -var has = __webpack_require__(595); +var isObject = __webpack_require__(545); +var has = __webpack_require__(594); module.exports = function unset(obj, prop) { if (!isObject(obj)) { @@ -69391,7 +69379,7 @@ module.exports = function unset(obj, prop) { /***/ }), -/* 595 */ +/* 594 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69404,9 +69392,9 @@ module.exports = function unset(obj, prop) { -var isObject = __webpack_require__(596); -var hasValues = __webpack_require__(598); -var get = __webpack_require__(592); +var isObject = __webpack_require__(595); +var hasValues = __webpack_require__(597); +var get = __webpack_require__(591); module.exports = function(obj, prop, noZero) { if (isObject(obj)) { @@ -69417,7 +69405,7 @@ module.exports = function(obj, prop, noZero) { /***/ }), -/* 596 */ +/* 595 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69430,7 +69418,7 @@ module.exports = function(obj, prop, noZero) { -var isArray = __webpack_require__(597); +var isArray = __webpack_require__(596); module.exports = function isObject(val) { return val != null && typeof val === 'object' && isArray(val) === false; @@ -69438,7 +69426,7 @@ module.exports = function isObject(val) { /***/ }), -/* 597 */ +/* 596 */ /***/ (function(module, exports) { var toString = {}.toString; @@ -69449,7 +69437,7 @@ module.exports = Array.isArray || function (arr) { /***/ }), -/* 598 */ +/* 597 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69492,7 +69480,7 @@ module.exports = function hasValue(o, noZero) { /***/ }), -/* 599 */ +/* 598 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69505,9 +69493,9 @@ module.exports = function hasValue(o, noZero) { -var isObject = __webpack_require__(546); -var hasValues = __webpack_require__(600); -var get = __webpack_require__(592); +var isObject = __webpack_require__(545); +var hasValues = __webpack_require__(599); +var get = __webpack_require__(591); module.exports = function(val, prop) { return hasValues(isObject(val) && prop ? get(val, prop) : val); @@ -69515,7 +69503,7 @@ module.exports = function(val, prop) { /***/ }), -/* 600 */ +/* 599 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69528,8 +69516,8 @@ module.exports = function(val, prop) { -var typeOf = __webpack_require__(601); -var isNumber = __webpack_require__(570); +var typeOf = __webpack_require__(600); +var isNumber = __webpack_require__(569); module.exports = function hasValue(val) { // is-number checks for NaN and other edge cases @@ -69582,10 +69570,10 @@ module.exports = function hasValue(val) { /***/ }), -/* 601 */ +/* 600 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(572); +var isBuffer = __webpack_require__(571); var toString = Object.prototype.toString; /** @@ -69707,7 +69695,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 602 */ +/* 601 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69720,10 +69708,10 @@ module.exports = function kindOf(val) { -var split = __webpack_require__(565); -var extend = __webpack_require__(561); -var isPlainObject = __webpack_require__(555); -var isObject = __webpack_require__(562); +var split = __webpack_require__(564); +var extend = __webpack_require__(560); +var isPlainObject = __webpack_require__(554); +var isObject = __webpack_require__(561); module.exports = function(obj, prop, val) { if (!isObject(obj)) { @@ -69769,14 +69757,14 @@ function isValidKey(key) { /***/ }), -/* 603 */ +/* 602 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(604); -var forIn = __webpack_require__(605); +var isExtendable = __webpack_require__(603); +var forIn = __webpack_require__(604); function mixinDeep(target, objects) { var len = arguments.length, i = 0; @@ -69840,7 +69828,7 @@ module.exports = mixinDeep; /***/ }), -/* 604 */ +/* 603 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69853,7 +69841,7 @@ module.exports = mixinDeep; -var isPlainObject = __webpack_require__(555); +var isPlainObject = __webpack_require__(554); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -69861,7 +69849,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 605 */ +/* 604 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69884,7 +69872,7 @@ module.exports = function forIn(obj, fn, thisArg) { /***/ }), -/* 606 */ +/* 605 */ /***/ (function(module, exports) { /*! @@ -69911,14 +69899,14 @@ module.exports = pascalcase; /***/ }), -/* 607 */ +/* 606 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var utils = __webpack_require__(608); +var utils = __webpack_require__(607); /** * Expose class utils @@ -70283,7 +70271,7 @@ cu.bubble = function(Parent, events) { /***/ }), -/* 608 */ +/* 607 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70297,10 +70285,10 @@ var utils = {}; * Lazily required module dependencies */ -utils.union = __webpack_require__(591); -utils.define = __webpack_require__(609); -utils.isObj = __webpack_require__(546); -utils.staticExtend = __webpack_require__(616); +utils.union = __webpack_require__(590); +utils.define = __webpack_require__(608); +utils.isObj = __webpack_require__(545); +utils.staticExtend = __webpack_require__(615); /** @@ -70311,7 +70299,7 @@ module.exports = utils; /***/ }), -/* 609 */ +/* 608 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70324,7 +70312,7 @@ module.exports = utils; -var isDescriptor = __webpack_require__(610); +var isDescriptor = __webpack_require__(609); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -70349,7 +70337,7 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 610 */ +/* 609 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70362,9 +70350,9 @@ module.exports = function defineProperty(obj, prop, val) { -var typeOf = __webpack_require__(611); -var isAccessor = __webpack_require__(612); -var isData = __webpack_require__(614); +var typeOf = __webpack_require__(610); +var isAccessor = __webpack_require__(611); +var isData = __webpack_require__(613); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -70378,7 +70366,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 611 */ +/* 610 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -70531,7 +70519,7 @@ function isBuffer(val) { /***/ }), -/* 612 */ +/* 611 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70544,7 +70532,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(613); +var typeOf = __webpack_require__(612); // accessor descriptor properties var accessor = { @@ -70607,10 +70595,10 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 613 */ +/* 612 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(572); +var isBuffer = __webpack_require__(571); var toString = Object.prototype.toString; /** @@ -70729,7 +70717,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 614 */ +/* 613 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70742,7 +70730,7 @@ module.exports = function kindOf(val) { -var typeOf = __webpack_require__(615); +var typeOf = __webpack_require__(614); // data descriptor properties var data = { @@ -70791,10 +70779,10 @@ module.exports = isDataDescriptor; /***/ }), -/* 615 */ +/* 614 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(572); +var isBuffer = __webpack_require__(571); var toString = Object.prototype.toString; /** @@ -70913,7 +70901,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 616 */ +/* 615 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70926,8 +70914,8 @@ module.exports = function kindOf(val) { -var copy = __webpack_require__(617); -var define = __webpack_require__(609); +var copy = __webpack_require__(616); +var define = __webpack_require__(608); var util = __webpack_require__(112); /** @@ -71010,15 +70998,15 @@ module.exports = extend; /***/ }), -/* 617 */ +/* 616 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var typeOf = __webpack_require__(571); -var copyDescriptor = __webpack_require__(618); -var define = __webpack_require__(609); +var typeOf = __webpack_require__(570); +var copyDescriptor = __webpack_require__(617); +var define = __webpack_require__(608); /** * Copy static properties, prototype properties, and descriptors from one object to another. @@ -71191,7 +71179,7 @@ module.exports.has = has; /***/ }), -/* 618 */ +/* 617 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71279,16 +71267,16 @@ function isObject(val) { /***/ }), -/* 619 */ +/* 618 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(620); -var define = __webpack_require__(609); -var debug = __webpack_require__(622)('snapdragon:compiler'); -var utils = __webpack_require__(628); +var use = __webpack_require__(619); +var define = __webpack_require__(608); +var debug = __webpack_require__(621)('snapdragon:compiler'); +var utils = __webpack_require__(627); /** * Create a new `Compiler` with the given `options`. @@ -71442,7 +71430,7 @@ Compiler.prototype = { // source map support if (opts.sourcemap) { - var sourcemaps = __webpack_require__(647); + var sourcemaps = __webpack_require__(646); sourcemaps(this); this.mapVisit(this.ast.nodes); this.applySourceMaps(); @@ -71463,7 +71451,7 @@ module.exports = Compiler; /***/ }), -/* 620 */ +/* 619 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71476,7 +71464,7 @@ module.exports = Compiler; -var utils = __webpack_require__(621); +var utils = __webpack_require__(620); module.exports = function base(app, opts) { if (!utils.isObject(app) && typeof app !== 'function') { @@ -71591,7 +71579,7 @@ module.exports = function base(app, opts) { /***/ }), -/* 621 */ +/* 620 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71605,8 +71593,8 @@ var utils = {}; * Lazily required module dependencies */ -utils.define = __webpack_require__(609); -utils.isObject = __webpack_require__(546); +utils.define = __webpack_require__(608); +utils.isObject = __webpack_require__(545); utils.isString = function(val) { @@ -71621,7 +71609,7 @@ module.exports = utils; /***/ }), -/* 622 */ +/* 621 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -71630,14 +71618,14 @@ module.exports = utils; */ if (typeof process !== 'undefined' && process.type === 'renderer') { - module.exports = __webpack_require__(623); + module.exports = __webpack_require__(622); } else { - module.exports = __webpack_require__(626); + module.exports = __webpack_require__(625); } /***/ }), -/* 623 */ +/* 622 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -71646,7 +71634,7 @@ if (typeof process !== 'undefined' && process.type === 'renderer') { * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(624); +exports = module.exports = __webpack_require__(623); exports.log = log; exports.formatArgs = formatArgs; exports.save = save; @@ -71828,7 +71816,7 @@ function localstorage() { /***/ }), -/* 624 */ +/* 623 */ /***/ (function(module, exports, __webpack_require__) { @@ -71844,7 +71832,7 @@ exports.coerce = coerce; exports.disable = disable; exports.enable = enable; exports.enabled = enabled; -exports.humanize = __webpack_require__(625); +exports.humanize = __webpack_require__(624); /** * The currently active debug mode names, and names to skip. @@ -72036,7 +72024,7 @@ function coerce(val) { /***/ }), -/* 625 */ +/* 624 */ /***/ (function(module, exports) { /** @@ -72194,7 +72182,7 @@ function plural(ms, n, name) { /***/ }), -/* 626 */ +/* 625 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -72210,7 +72198,7 @@ var util = __webpack_require__(112); * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(624); +exports = module.exports = __webpack_require__(623); exports.init = init; exports.log = log; exports.formatArgs = formatArgs; @@ -72389,7 +72377,7 @@ function createWritableStdioStream (fd) { case 'PIPE': case 'TCP': - var net = __webpack_require__(627); + var net = __webpack_require__(626); stream = new net.Socket({ fd: fd, readable: false, @@ -72448,13 +72436,13 @@ exports.enable(load()); /***/ }), -/* 627 */ +/* 626 */ /***/ (function(module, exports) { module.exports = require("net"); /***/ }), -/* 628 */ +/* 627 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -72464,9 +72452,9 @@ module.exports = require("net"); * Module dependencies */ -exports.extend = __webpack_require__(561); -exports.SourceMap = __webpack_require__(629); -exports.sourceMapResolve = __webpack_require__(640); +exports.extend = __webpack_require__(560); +exports.SourceMap = __webpack_require__(628); +exports.sourceMapResolve = __webpack_require__(639); /** * Convert backslash in the given string to forward slashes @@ -72509,7 +72497,7 @@ exports.last = function(arr, n) { /***/ }), -/* 629 */ +/* 628 */ /***/ (function(module, exports, __webpack_require__) { /* @@ -72517,13 +72505,13 @@ exports.last = function(arr, n) { * Licensed under the New BSD license. See LICENSE.txt or: * http://opensource.org/licenses/BSD-3-Clause */ -exports.SourceMapGenerator = __webpack_require__(630).SourceMapGenerator; -exports.SourceMapConsumer = __webpack_require__(636).SourceMapConsumer; -exports.SourceNode = __webpack_require__(639).SourceNode; +exports.SourceMapGenerator = __webpack_require__(629).SourceMapGenerator; +exports.SourceMapConsumer = __webpack_require__(635).SourceMapConsumer; +exports.SourceNode = __webpack_require__(638).SourceNode; /***/ }), -/* 630 */ +/* 629 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72533,10 +72521,10 @@ exports.SourceNode = __webpack_require__(639).SourceNode; * http://opensource.org/licenses/BSD-3-Clause */ -var base64VLQ = __webpack_require__(631); -var util = __webpack_require__(633); -var ArraySet = __webpack_require__(634).ArraySet; -var MappingList = __webpack_require__(635).MappingList; +var base64VLQ = __webpack_require__(630); +var util = __webpack_require__(632); +var ArraySet = __webpack_require__(633).ArraySet; +var MappingList = __webpack_require__(634).MappingList; /** * An instance of the SourceMapGenerator represents a source map which is @@ -72945,7 +72933,7 @@ exports.SourceMapGenerator = SourceMapGenerator; /***/ }), -/* 631 */ +/* 630 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72985,7 +72973,7 @@ exports.SourceMapGenerator = SourceMapGenerator; * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -var base64 = __webpack_require__(632); +var base64 = __webpack_require__(631); // A single base 64 digit can contain 6 bits of data. For the base 64 variable // length quantities we use in the source map spec, the first bit is the sign, @@ -73091,7 +73079,7 @@ exports.decode = function base64VLQ_decode(aStr, aIndex, aOutParam) { /***/ }), -/* 632 */ +/* 631 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73164,7 +73152,7 @@ exports.decode = function (charCode) { /***/ }), -/* 633 */ +/* 632 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73587,7 +73575,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate /***/ }), -/* 634 */ +/* 633 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73597,7 +73585,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(633); +var util = __webpack_require__(632); var has = Object.prototype.hasOwnProperty; var hasNativeMap = typeof Map !== "undefined"; @@ -73714,7 +73702,7 @@ exports.ArraySet = ArraySet; /***/ }), -/* 635 */ +/* 634 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73724,7 +73712,7 @@ exports.ArraySet = ArraySet; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(633); +var util = __webpack_require__(632); /** * Determine whether mappingB is after mappingA with respect to generated @@ -73799,7 +73787,7 @@ exports.MappingList = MappingList; /***/ }), -/* 636 */ +/* 635 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73809,11 +73797,11 @@ exports.MappingList = MappingList; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(633); -var binarySearch = __webpack_require__(637); -var ArraySet = __webpack_require__(634).ArraySet; -var base64VLQ = __webpack_require__(631); -var quickSort = __webpack_require__(638).quickSort; +var util = __webpack_require__(632); +var binarySearch = __webpack_require__(636); +var ArraySet = __webpack_require__(633).ArraySet; +var base64VLQ = __webpack_require__(630); +var quickSort = __webpack_require__(637).quickSort; function SourceMapConsumer(aSourceMap) { var sourceMap = aSourceMap; @@ -74887,7 +74875,7 @@ exports.IndexedSourceMapConsumer = IndexedSourceMapConsumer; /***/ }), -/* 637 */ +/* 636 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -75004,7 +74992,7 @@ exports.search = function search(aNeedle, aHaystack, aCompare, aBias) { /***/ }), -/* 638 */ +/* 637 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -75124,7 +75112,7 @@ exports.quickSort = function (ary, comparator) { /***/ }), -/* 639 */ +/* 638 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -75134,8 +75122,8 @@ exports.quickSort = function (ary, comparator) { * http://opensource.org/licenses/BSD-3-Clause */ -var SourceMapGenerator = __webpack_require__(630).SourceMapGenerator; -var util = __webpack_require__(633); +var SourceMapGenerator = __webpack_require__(629).SourceMapGenerator; +var util = __webpack_require__(632); // Matches a Windows-style `\r\n` newline or a `\n` newline used by all other // operating systems these days (capturing the result). @@ -75543,17 +75531,17 @@ exports.SourceNode = SourceNode; /***/ }), -/* 640 */ +/* 639 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014, 2015, 2016, 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var sourceMappingURL = __webpack_require__(641) -var resolveUrl = __webpack_require__(642) -var decodeUriComponent = __webpack_require__(643) -var urix = __webpack_require__(645) -var atob = __webpack_require__(646) +var sourceMappingURL = __webpack_require__(640) +var resolveUrl = __webpack_require__(641) +var decodeUriComponent = __webpack_require__(642) +var urix = __webpack_require__(644) +var atob = __webpack_require__(645) @@ -75851,7 +75839,7 @@ module.exports = { /***/ }), -/* 641 */ +/* 640 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_RESULT__;// Copyright 2014 Simon Lydell @@ -75914,7 +75902,7 @@ void (function(root, factory) { /***/ }), -/* 642 */ +/* 641 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -75932,13 +75920,13 @@ module.exports = resolveUrl /***/ }), -/* 643 */ +/* 642 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var decodeUriComponent = __webpack_require__(644) +var decodeUriComponent = __webpack_require__(643) function customDecodeUriComponent(string) { // `decodeUriComponent` turns `+` into ` `, but that's not wanted. @@ -75949,7 +75937,7 @@ module.exports = customDecodeUriComponent /***/ }), -/* 644 */ +/* 643 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76050,7 +76038,7 @@ module.exports = function (encodedURI) { /***/ }), -/* 645 */ +/* 644 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -76073,7 +76061,7 @@ module.exports = urix /***/ }), -/* 646 */ +/* 645 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76087,7 +76075,7 @@ module.exports = atob.atob = atob; /***/ }), -/* 647 */ +/* 646 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76095,8 +76083,8 @@ module.exports = atob.atob = atob; var fs = __webpack_require__(134); var path = __webpack_require__(4); -var define = __webpack_require__(609); -var utils = __webpack_require__(628); +var define = __webpack_require__(608); +var utils = __webpack_require__(627); /** * Expose `mixin()`. @@ -76239,19 +76227,19 @@ exports.comment = function(node) { /***/ }), -/* 648 */ +/* 647 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(620); +var use = __webpack_require__(619); var util = __webpack_require__(112); -var Cache = __webpack_require__(649); -var define = __webpack_require__(609); -var debug = __webpack_require__(622)('snapdragon:parser'); -var Position = __webpack_require__(650); -var utils = __webpack_require__(628); +var Cache = __webpack_require__(648); +var define = __webpack_require__(608); +var debug = __webpack_require__(621)('snapdragon:parser'); +var Position = __webpack_require__(649); +var utils = __webpack_require__(627); /** * Create a new `Parser` with the given `input` and `options`. @@ -76779,7 +76767,7 @@ module.exports = Parser; /***/ }), -/* 649 */ +/* 648 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76886,13 +76874,13 @@ MapCache.prototype.del = function mapDelete(key) { /***/ }), -/* 650 */ +/* 649 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var define = __webpack_require__(609); +var define = __webpack_require__(608); /** * Store position for a node @@ -76907,14 +76895,14 @@ module.exports = function Position(start, parser) { /***/ }), -/* 651 */ +/* 650 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(652); -var assignSymbols = __webpack_require__(556); +var isExtendable = __webpack_require__(651); +var assignSymbols = __webpack_require__(555); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -76974,7 +76962,7 @@ function isEnum(obj, key) { /***/ }), -/* 652 */ +/* 651 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76987,7 +76975,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(555); +var isPlainObject = __webpack_require__(554); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -76995,14 +76983,14 @@ module.exports = function isExtendable(val) { /***/ }), -/* 653 */ +/* 652 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var nanomatch = __webpack_require__(654); -var extglob = __webpack_require__(669); +var nanomatch = __webpack_require__(653); +var extglob = __webpack_require__(668); module.exports = function(snapdragon) { var compilers = snapdragon.compiler.compilers; @@ -77079,7 +77067,7 @@ function escapeExtglobs(compiler) { /***/ }), -/* 654 */ +/* 653 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77090,17 +77078,17 @@ function escapeExtglobs(compiler) { */ var util = __webpack_require__(112); -var toRegex = __webpack_require__(538); -var extend = __webpack_require__(655); +var toRegex = __webpack_require__(537); +var extend = __webpack_require__(654); /** * Local dependencies */ -var compilers = __webpack_require__(657); -var parsers = __webpack_require__(658); -var cache = __webpack_require__(661); -var utils = __webpack_require__(663); +var compilers = __webpack_require__(656); +var parsers = __webpack_require__(657); +var cache = __webpack_require__(660); +var utils = __webpack_require__(662); var MAX_LENGTH = 1024 * 64; /** @@ -77924,14 +77912,14 @@ module.exports = nanomatch; /***/ }), -/* 655 */ +/* 654 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(656); -var assignSymbols = __webpack_require__(556); +var isExtendable = __webpack_require__(655); +var assignSymbols = __webpack_require__(555); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -77991,7 +77979,7 @@ function isEnum(obj, key) { /***/ }), -/* 656 */ +/* 655 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78004,7 +77992,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(555); +var isPlainObject = __webpack_require__(554); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -78012,7 +78000,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 657 */ +/* 656 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78358,15 +78346,15 @@ module.exports = function(nanomatch, options) { /***/ }), -/* 658 */ +/* 657 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var regexNot = __webpack_require__(557); -var toRegex = __webpack_require__(538); -var isOdd = __webpack_require__(659); +var regexNot = __webpack_require__(556); +var toRegex = __webpack_require__(537); +var isOdd = __webpack_require__(658); /** * Characters to use in negation regex (we want to "not" match @@ -78752,7 +78740,7 @@ module.exports.not = NOT_REGEX; /***/ }), -/* 659 */ +/* 658 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78765,7 +78753,7 @@ module.exports.not = NOT_REGEX; -var isNumber = __webpack_require__(660); +var isNumber = __webpack_require__(659); module.exports = function isOdd(i) { if (!isNumber(i)) { @@ -78779,7 +78767,7 @@ module.exports = function isOdd(i) { /***/ }), -/* 660 */ +/* 659 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78807,14 +78795,14 @@ module.exports = function isNumber(num) { /***/ }), -/* 661 */ +/* 660 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(662))(); +module.exports = new (__webpack_require__(661))(); /***/ }), -/* 662 */ +/* 661 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78827,7 +78815,7 @@ module.exports = new (__webpack_require__(662))(); -var MapCache = __webpack_require__(649); +var MapCache = __webpack_require__(648); /** * Create a new `FragmentCache` with an optional object to use for `caches`. @@ -78949,7 +78937,7 @@ exports = module.exports = FragmentCache; /***/ }), -/* 663 */ +/* 662 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78962,14 +78950,14 @@ var path = __webpack_require__(4); * Module dependencies */ -var isWindows = __webpack_require__(664)(); -var Snapdragon = __webpack_require__(581); -utils.define = __webpack_require__(665); -utils.diff = __webpack_require__(666); -utils.extend = __webpack_require__(655); -utils.pick = __webpack_require__(667); -utils.typeOf = __webpack_require__(668); -utils.unique = __webpack_require__(560); +var isWindows = __webpack_require__(663)(); +var Snapdragon = __webpack_require__(580); +utils.define = __webpack_require__(664); +utils.diff = __webpack_require__(665); +utils.extend = __webpack_require__(654); +utils.pick = __webpack_require__(666); +utils.typeOf = __webpack_require__(667); +utils.unique = __webpack_require__(559); /** * Returns true if the given value is effectively an empty string @@ -79335,7 +79323,7 @@ utils.unixify = function(options) { /***/ }), -/* 664 */ +/* 663 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;/*! @@ -79363,7 +79351,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ /***/ }), -/* 665 */ +/* 664 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79376,8 +79364,8 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ -var isobject = __webpack_require__(546); -var isDescriptor = __webpack_require__(547); +var isobject = __webpack_require__(545); +var isDescriptor = __webpack_require__(546); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -79408,7 +79396,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 666 */ +/* 665 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79462,7 +79450,7 @@ function diffArray(one, two) { /***/ }), -/* 667 */ +/* 666 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79475,7 +79463,7 @@ function diffArray(one, two) { -var isObject = __webpack_require__(546); +var isObject = __webpack_require__(545); module.exports = function pick(obj, keys) { if (!isObject(obj) && typeof obj !== 'function') { @@ -79504,7 +79492,7 @@ module.exports = function pick(obj, keys) { /***/ }), -/* 668 */ +/* 667 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -79639,7 +79627,7 @@ function isBuffer(val) { /***/ }), -/* 669 */ +/* 668 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79649,18 +79637,18 @@ function isBuffer(val) { * Module dependencies */ -var extend = __webpack_require__(561); -var unique = __webpack_require__(560); -var toRegex = __webpack_require__(538); +var extend = __webpack_require__(560); +var unique = __webpack_require__(559); +var toRegex = __webpack_require__(537); /** * Local dependencies */ -var compilers = __webpack_require__(670); -var parsers = __webpack_require__(676); -var Extglob = __webpack_require__(679); -var utils = __webpack_require__(678); +var compilers = __webpack_require__(669); +var parsers = __webpack_require__(675); +var Extglob = __webpack_require__(678); +var utils = __webpack_require__(677); var MAX_LENGTH = 1024 * 64; /** @@ -79977,13 +79965,13 @@ module.exports = extglob; /***/ }), -/* 670 */ +/* 669 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(671); +var brackets = __webpack_require__(670); /** * Extglob compilers @@ -80153,7 +80141,7 @@ module.exports = function(extglob) { /***/ }), -/* 671 */ +/* 670 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80163,17 +80151,17 @@ module.exports = function(extglob) { * Local dependencies */ -var compilers = __webpack_require__(672); -var parsers = __webpack_require__(674); +var compilers = __webpack_require__(671); +var parsers = __webpack_require__(673); /** * Module dependencies */ -var debug = __webpack_require__(622)('expand-brackets'); -var extend = __webpack_require__(561); -var Snapdragon = __webpack_require__(581); -var toRegex = __webpack_require__(538); +var debug = __webpack_require__(621)('expand-brackets'); +var extend = __webpack_require__(560); +var Snapdragon = __webpack_require__(580); +var toRegex = __webpack_require__(537); /** * Parses the given POSIX character class `pattern` and returns a @@ -80371,13 +80359,13 @@ module.exports = brackets; /***/ }), -/* 672 */ +/* 671 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var posix = __webpack_require__(673); +var posix = __webpack_require__(672); module.exports = function(brackets) { brackets.compiler @@ -80465,7 +80453,7 @@ module.exports = function(brackets) { /***/ }), -/* 673 */ +/* 672 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80494,14 +80482,14 @@ module.exports = { /***/ }), -/* 674 */ +/* 673 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(675); -var define = __webpack_require__(609); +var utils = __webpack_require__(674); +var define = __webpack_require__(608); /** * Text regex @@ -80720,14 +80708,14 @@ module.exports.TEXT_REGEX = TEXT_REGEX; /***/ }), -/* 675 */ +/* 674 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var toRegex = __webpack_require__(538); -var regexNot = __webpack_require__(557); +var toRegex = __webpack_require__(537); +var regexNot = __webpack_require__(556); var cached; /** @@ -80761,15 +80749,15 @@ exports.createRegex = function(pattern, include) { /***/ }), -/* 676 */ +/* 675 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(671); -var define = __webpack_require__(677); -var utils = __webpack_require__(678); +var brackets = __webpack_require__(670); +var define = __webpack_require__(676); +var utils = __webpack_require__(677); /** * Characters to use in text regex (we want to "not" match @@ -80924,7 +80912,7 @@ module.exports = parsers; /***/ }), -/* 677 */ +/* 676 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80937,7 +80925,7 @@ module.exports = parsers; -var isDescriptor = __webpack_require__(547); +var isDescriptor = __webpack_require__(546); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -80962,14 +80950,14 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 678 */ +/* 677 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var regex = __webpack_require__(557); -var Cache = __webpack_require__(662); +var regex = __webpack_require__(556); +var Cache = __webpack_require__(661); /** * Utils @@ -81038,7 +81026,7 @@ utils.createRegex = function(str) { /***/ }), -/* 679 */ +/* 678 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81048,16 +81036,16 @@ utils.createRegex = function(str) { * Module dependencies */ -var Snapdragon = __webpack_require__(581); -var define = __webpack_require__(677); -var extend = __webpack_require__(561); +var Snapdragon = __webpack_require__(580); +var define = __webpack_require__(676); +var extend = __webpack_require__(560); /** * Local dependencies */ -var compilers = __webpack_require__(670); -var parsers = __webpack_require__(676); +var compilers = __webpack_require__(669); +var parsers = __webpack_require__(675); /** * Customize Snapdragon parser and renderer @@ -81123,16 +81111,16 @@ module.exports = Extglob; /***/ }), -/* 680 */ +/* 679 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extglob = __webpack_require__(669); -var nanomatch = __webpack_require__(654); -var regexNot = __webpack_require__(557); -var toRegex = __webpack_require__(538); +var extglob = __webpack_require__(668); +var nanomatch = __webpack_require__(653); +var regexNot = __webpack_require__(556); +var toRegex = __webpack_require__(537); var not; /** @@ -81213,14 +81201,14 @@ function textRegex(pattern) { /***/ }), -/* 681 */ +/* 680 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(662))(); +module.exports = new (__webpack_require__(661))(); /***/ }), -/* 682 */ +/* 681 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81233,13 +81221,13 @@ var path = __webpack_require__(4); * Module dependencies */ -var Snapdragon = __webpack_require__(581); -utils.define = __webpack_require__(683); -utils.diff = __webpack_require__(666); -utils.extend = __webpack_require__(651); -utils.pick = __webpack_require__(667); -utils.typeOf = __webpack_require__(684); -utils.unique = __webpack_require__(560); +var Snapdragon = __webpack_require__(580); +utils.define = __webpack_require__(682); +utils.diff = __webpack_require__(665); +utils.extend = __webpack_require__(650); +utils.pick = __webpack_require__(666); +utils.typeOf = __webpack_require__(683); +utils.unique = __webpack_require__(559); /** * Returns true if the platform is windows, or `path.sep` is `\\`. @@ -81536,7 +81524,7 @@ utils.unixify = function(options) { /***/ }), -/* 683 */ +/* 682 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81549,8 +81537,8 @@ utils.unixify = function(options) { -var isobject = __webpack_require__(546); -var isDescriptor = __webpack_require__(547); +var isobject = __webpack_require__(545); +var isDescriptor = __webpack_require__(546); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -81581,7 +81569,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 684 */ +/* 683 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -81716,7 +81704,7 @@ function isBuffer(val) { /***/ }), -/* 685 */ +/* 684 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81735,9 +81723,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(686); -var reader_1 = __webpack_require__(699); -var fs_stream_1 = __webpack_require__(703); +var readdir = __webpack_require__(685); +var reader_1 = __webpack_require__(698); +var fs_stream_1 = __webpack_require__(702); var ReaderAsync = /** @class */ (function (_super) { __extends(ReaderAsync, _super); function ReaderAsync() { @@ -81798,15 +81786,15 @@ exports.default = ReaderAsync; /***/ }), -/* 686 */ +/* 685 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const readdirSync = __webpack_require__(687); -const readdirAsync = __webpack_require__(695); -const readdirStream = __webpack_require__(698); +const readdirSync = __webpack_require__(686); +const readdirAsync = __webpack_require__(694); +const readdirStream = __webpack_require__(697); module.exports = exports = readdirAsyncPath; exports.readdir = exports.readdirAsync = exports.async = readdirAsyncPath; @@ -81890,7 +81878,7 @@ function readdirStreamStat (dir, options) { /***/ }), -/* 687 */ +/* 686 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81898,11 +81886,11 @@ function readdirStreamStat (dir, options) { module.exports = readdirSync; -const DirectoryReader = __webpack_require__(688); +const DirectoryReader = __webpack_require__(687); let syncFacade = { - fs: __webpack_require__(693), - forEach: __webpack_require__(694), + fs: __webpack_require__(692), + forEach: __webpack_require__(693), sync: true }; @@ -81931,7 +81919,7 @@ function readdirSync (dir, options, internalOptions) { /***/ }), -/* 688 */ +/* 687 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81940,9 +81928,9 @@ function readdirSync (dir, options, internalOptions) { const Readable = __webpack_require__(138).Readable; const EventEmitter = __webpack_require__(156).EventEmitter; const path = __webpack_require__(4); -const normalizeOptions = __webpack_require__(689); -const stat = __webpack_require__(691); -const call = __webpack_require__(692); +const normalizeOptions = __webpack_require__(688); +const stat = __webpack_require__(690); +const call = __webpack_require__(691); /** * Asynchronously reads the contents of a directory and streams the results @@ -82318,14 +82306,14 @@ module.exports = DirectoryReader; /***/ }), -/* 689 */ +/* 688 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); -const globToRegExp = __webpack_require__(690); +const globToRegExp = __webpack_require__(689); module.exports = normalizeOptions; @@ -82502,7 +82490,7 @@ function normalizeOptions (options, internalOptions) { /***/ }), -/* 690 */ +/* 689 */ /***/ (function(module, exports) { module.exports = function (glob, opts) { @@ -82639,13 +82627,13 @@ module.exports = function (glob, opts) { /***/ }), -/* 691 */ +/* 690 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const call = __webpack_require__(692); +const call = __webpack_require__(691); module.exports = stat; @@ -82720,7 +82708,7 @@ function symlinkStat (fs, path, lstats, callback) { /***/ }), -/* 692 */ +/* 691 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82781,14 +82769,14 @@ function callOnce (fn) { /***/ }), -/* 693 */ +/* 692 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const call = __webpack_require__(692); +const call = __webpack_require__(691); /** * A facade around {@link fs.readdirSync} that allows it to be called @@ -82852,7 +82840,7 @@ exports.lstat = function (path, callback) { /***/ }), -/* 694 */ +/* 693 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82881,7 +82869,7 @@ function syncForEach (array, iterator, done) { /***/ }), -/* 695 */ +/* 694 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82889,12 +82877,12 @@ function syncForEach (array, iterator, done) { module.exports = readdirAsync; -const maybe = __webpack_require__(696); -const DirectoryReader = __webpack_require__(688); +const maybe = __webpack_require__(695); +const DirectoryReader = __webpack_require__(687); let asyncFacade = { fs: __webpack_require__(134), - forEach: __webpack_require__(697), + forEach: __webpack_require__(696), async: true }; @@ -82936,7 +82924,7 @@ function readdirAsync (dir, options, callback, internalOptions) { /***/ }), -/* 696 */ +/* 695 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82963,7 +82951,7 @@ module.exports = function maybe (cb, promise) { /***/ }), -/* 697 */ +/* 696 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82999,7 +82987,7 @@ function asyncForEach (array, iterator, done) { /***/ }), -/* 698 */ +/* 697 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83007,11 +82995,11 @@ function asyncForEach (array, iterator, done) { module.exports = readdirStream; -const DirectoryReader = __webpack_require__(688); +const DirectoryReader = __webpack_require__(687); let streamFacade = { fs: __webpack_require__(134), - forEach: __webpack_require__(697), + forEach: __webpack_require__(696), async: true }; @@ -83031,16 +83019,16 @@ function readdirStream (dir, options, internalOptions) { /***/ }), -/* 699 */ +/* 698 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(4); -var deep_1 = __webpack_require__(700); -var entry_1 = __webpack_require__(702); -var pathUtil = __webpack_require__(701); +var deep_1 = __webpack_require__(699); +var entry_1 = __webpack_require__(701); +var pathUtil = __webpack_require__(700); var Reader = /** @class */ (function () { function Reader(options) { this.options = options; @@ -83106,14 +83094,14 @@ exports.default = Reader; /***/ }), -/* 700 */ +/* 699 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(701); -var patternUtils = __webpack_require__(532); +var pathUtils = __webpack_require__(700); +var patternUtils = __webpack_require__(531); var DeepFilter = /** @class */ (function () { function DeepFilter(options, micromatchOptions) { this.options = options; @@ -83196,7 +83184,7 @@ exports.default = DeepFilter; /***/ }), -/* 701 */ +/* 700 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83227,14 +83215,14 @@ exports.makeAbsolute = makeAbsolute; /***/ }), -/* 702 */ +/* 701 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(701); -var patternUtils = __webpack_require__(532); +var pathUtils = __webpack_require__(700); +var patternUtils = __webpack_require__(531); var EntryFilter = /** @class */ (function () { function EntryFilter(options, micromatchOptions) { this.options = options; @@ -83319,7 +83307,7 @@ exports.default = EntryFilter; /***/ }), -/* 703 */ +/* 702 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83339,8 +83327,8 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(138); -var fsStat = __webpack_require__(704); -var fs_1 = __webpack_require__(708); +var fsStat = __webpack_require__(703); +var fs_1 = __webpack_require__(707); var FileSystemStream = /** @class */ (function (_super) { __extends(FileSystemStream, _super); function FileSystemStream() { @@ -83390,14 +83378,14 @@ exports.default = FileSystemStream; /***/ }), -/* 704 */ +/* 703 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const optionsManager = __webpack_require__(705); -const statProvider = __webpack_require__(707); +const optionsManager = __webpack_require__(704); +const statProvider = __webpack_require__(706); /** * Asynchronous API. */ @@ -83428,13 +83416,13 @@ exports.statSync = statSync; /***/ }), -/* 705 */ +/* 704 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsAdapter = __webpack_require__(706); +const fsAdapter = __webpack_require__(705); function prepare(opts) { const options = Object.assign({ fs: fsAdapter.getFileSystemAdapter(opts ? opts.fs : undefined), @@ -83447,7 +83435,7 @@ exports.prepare = prepare; /***/ }), -/* 706 */ +/* 705 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83470,7 +83458,7 @@ exports.getFileSystemAdapter = getFileSystemAdapter; /***/ }), -/* 707 */ +/* 706 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83522,7 +83510,7 @@ exports.isFollowedSymlink = isFollowedSymlink; /***/ }), -/* 708 */ +/* 707 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83553,7 +83541,7 @@ exports.default = FileSystem; /***/ }), -/* 709 */ +/* 708 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83573,9 +83561,9 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(138); -var readdir = __webpack_require__(686); -var reader_1 = __webpack_require__(699); -var fs_stream_1 = __webpack_require__(703); +var readdir = __webpack_require__(685); +var reader_1 = __webpack_require__(698); +var fs_stream_1 = __webpack_require__(702); var TransformStream = /** @class */ (function (_super) { __extends(TransformStream, _super); function TransformStream(reader) { @@ -83643,7 +83631,7 @@ exports.default = ReaderStream; /***/ }), -/* 710 */ +/* 709 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83662,9 +83650,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(686); -var reader_1 = __webpack_require__(699); -var fs_sync_1 = __webpack_require__(711); +var readdir = __webpack_require__(685); +var reader_1 = __webpack_require__(698); +var fs_sync_1 = __webpack_require__(710); var ReaderSync = /** @class */ (function (_super) { __extends(ReaderSync, _super); function ReaderSync() { @@ -83724,7 +83712,7 @@ exports.default = ReaderSync; /***/ }), -/* 711 */ +/* 710 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83743,8 +83731,8 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var fsStat = __webpack_require__(704); -var fs_1 = __webpack_require__(708); +var fsStat = __webpack_require__(703); +var fs_1 = __webpack_require__(707); var FileSystemSync = /** @class */ (function (_super) { __extends(FileSystemSync, _super); function FileSystemSync() { @@ -83790,7 +83778,7 @@ exports.default = FileSystemSync; /***/ }), -/* 712 */ +/* 711 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83806,7 +83794,7 @@ exports.flatten = flatten; /***/ }), -/* 713 */ +/* 712 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83827,13 +83815,13 @@ exports.merge = merge; /***/ }), -/* 714 */ +/* 713 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); -const pathType = __webpack_require__(715); +const pathType = __webpack_require__(714); const getExtensions = extensions => extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]; @@ -83899,13 +83887,13 @@ module.exports.sync = (input, opts) => { /***/ }), -/* 715 */ +/* 714 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const pify = __webpack_require__(716); +const pify = __webpack_require__(715); function type(fn, fn2, fp) { if (typeof fp !== 'string') { @@ -83948,7 +83936,7 @@ exports.symlinkSync = typeSync.bind(null, 'lstatSync', 'isSymbolicLink'); /***/ }), -/* 716 */ +/* 715 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84039,17 +84027,17 @@ module.exports = (obj, opts) => { /***/ }), -/* 717 */ +/* 716 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); const path = __webpack_require__(4); -const fastGlob = __webpack_require__(528); -const gitIgnore = __webpack_require__(718); -const pify = __webpack_require__(719); -const slash = __webpack_require__(720); +const fastGlob = __webpack_require__(527); +const gitIgnore = __webpack_require__(717); +const pify = __webpack_require__(718); +const slash = __webpack_require__(719); const DEFAULT_IGNORE = [ '**/node_modules/**', @@ -84147,7 +84135,7 @@ module.exports.sync = options => { /***/ }), -/* 718 */ +/* 717 */ /***/ (function(module, exports) { // A simple implementation of make-array @@ -84616,7 +84604,7 @@ module.exports = options => new IgnoreBase(options) /***/ }), -/* 719 */ +/* 718 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84691,7 +84679,7 @@ module.exports = (input, options) => { /***/ }), -/* 720 */ +/* 719 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84709,7 +84697,7 @@ module.exports = input => { /***/ }), -/* 721 */ +/* 720 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84722,7 +84710,7 @@ module.exports = input => { -var isGlob = __webpack_require__(722); +var isGlob = __webpack_require__(721); module.exports = function hasGlob(val) { if (val == null) return false; @@ -84742,7 +84730,7 @@ module.exports = function hasGlob(val) { /***/ }), -/* 722 */ +/* 721 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -84773,17 +84761,17 @@ module.exports = function isGlob(str) { /***/ }), -/* 723 */ +/* 722 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); const {constants: fsConstants} = __webpack_require__(134); -const pEvent = __webpack_require__(724); -const CpFileError = __webpack_require__(727); -const fs = __webpack_require__(729); -const ProgressEmitter = __webpack_require__(732); +const pEvent = __webpack_require__(723); +const CpFileError = __webpack_require__(726); +const fs = __webpack_require__(728); +const ProgressEmitter = __webpack_require__(731); const cpFileAsync = async (source, destination, options, progressEmitter) => { let readError; @@ -84897,12 +84885,12 @@ module.exports.sync = (source, destination, options) => { /***/ }), -/* 724 */ +/* 723 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pTimeout = __webpack_require__(725); +const pTimeout = __webpack_require__(724); const symbolAsyncIterator = Symbol.asyncIterator || '@@asyncIterator'; @@ -85193,12 +85181,12 @@ module.exports.iterator = (emitter, event, options) => { /***/ }), -/* 725 */ +/* 724 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pFinally = __webpack_require__(726); +const pFinally = __webpack_require__(725); class TimeoutError extends Error { constructor(message) { @@ -85244,7 +85232,7 @@ module.exports.TimeoutError = TimeoutError; /***/ }), -/* 726 */ +/* 725 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85266,12 +85254,12 @@ module.exports = (promise, onFinally) => { /***/ }), -/* 727 */ +/* 726 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(728); +const NestedError = __webpack_require__(727); class CpFileError extends NestedError { constructor(message, nested) { @@ -85285,7 +85273,7 @@ module.exports = CpFileError; /***/ }), -/* 728 */ +/* 727 */ /***/ (function(module, exports, __webpack_require__) { var inherits = __webpack_require__(112).inherits; @@ -85341,16 +85329,16 @@ module.exports = NestedError; /***/ }), -/* 729 */ +/* 728 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const {promisify} = __webpack_require__(112); const fs = __webpack_require__(133); -const makeDir = __webpack_require__(730); -const pEvent = __webpack_require__(724); -const CpFileError = __webpack_require__(727); +const makeDir = __webpack_require__(729); +const pEvent = __webpack_require__(723); +const CpFileError = __webpack_require__(726); const stat = promisify(fs.stat); const lstat = promisify(fs.lstat); @@ -85447,7 +85435,7 @@ exports.copyFileSync = (source, destination, flags) => { /***/ }), -/* 730 */ +/* 729 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85455,7 +85443,7 @@ exports.copyFileSync = (source, destination, flags) => { const fs = __webpack_require__(134); const path = __webpack_require__(4); const {promisify} = __webpack_require__(112); -const semver = __webpack_require__(731); +const semver = __webpack_require__(730); const useNativeRecursiveOption = semver.satisfies(process.version, '>=10.12.0'); @@ -85610,7 +85598,7 @@ module.exports.sync = (input, options) => { /***/ }), -/* 731 */ +/* 730 */ /***/ (function(module, exports) { exports = module.exports = SemVer @@ -87212,7 +87200,7 @@ function coerce (version, options) { /***/ }), -/* 732 */ +/* 731 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87253,7 +87241,7 @@ module.exports = ProgressEmitter; /***/ }), -/* 733 */ +/* 732 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87299,12 +87287,12 @@ exports.default = module.exports; /***/ }), -/* 734 */ +/* 733 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pMap = __webpack_require__(735); +const pMap = __webpack_require__(734); const pFilter = async (iterable, filterer, options) => { const values = await pMap( @@ -87321,7 +87309,7 @@ module.exports.default = pFilter; /***/ }), -/* 735 */ +/* 734 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87400,12 +87388,12 @@ module.exports.default = pMap; /***/ }), -/* 736 */ +/* 735 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(728); +const NestedError = __webpack_require__(727); class CpyError extends NestedError { constructor(message, nested) { @@ -87419,14 +87407,14 @@ module.exports = CpyError; /***/ }), -/* 737 */ +/* 736 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const arrayUnion = __webpack_require__(526); +const arrayUnion = __webpack_require__(737); const glob = __webpack_require__(147); -const fastGlob = __webpack_require__(528); +const fastGlob = __webpack_require__(527); const dirGlob = __webpack_require__(738); const gitignore = __webpack_require__(742); @@ -87553,6 +87541,19 @@ module.exports.hasMagic = (patterns, opts) => [] module.exports.gitignore = gitignore; +/***/ }), +/* 737 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + +var arrayUniq = __webpack_require__(526); + +module.exports = function () { + return arrayUniq([].concat.apply([], arguments)); +}; + + /***/ }), /* 738 */ /***/ (function(module, exports, __webpack_require__) { @@ -87771,7 +87772,7 @@ module.exports = (obj, opts) => { const fs = __webpack_require__(134); const path = __webpack_require__(4); -const fastGlob = __webpack_require__(528); +const fastGlob = __webpack_require__(527); const gitIgnore = __webpack_require__(743); const pify = __webpack_require__(741); const slash = __webpack_require__(744); @@ -88324,13 +88325,13 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "buildNonBazelProductionProjects", function() { return buildNonBazelProductionProjects; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "getProductionProjects", function() { return getProductionProjects; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "buildProject", function() { return buildProject; }); -/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(521); +/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(520); /* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(cpy__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(143); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(del__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(518); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(517); /* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(131); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(246); /* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(251); diff --git a/scripts/build_api_docs.js b/scripts/build_api_docs.js new file mode 100644 index 0000000000000..3e26a2d87895c --- /dev/null +++ b/scripts/build_api_docs.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +require('../src/setup_node_env'); +require('@kbn/docs-utils').runBuildApiDocsCli(); diff --git a/scripts/release_notes.js b/scripts/release_notes.js index f22c00f4643b0..7408ce322677c 100644 --- a/scripts/release_notes.js +++ b/scripts/release_notes.js @@ -7,4 +7,4 @@ */ require('../src/setup_node_env/no_transpilation'); -require('@kbn/release-notes').runReleaseNotesCli(); +require('@kbn/docs-utils').runReleaseNotesCli(); diff --git a/src/core/kibana.json b/src/core/kibana.json new file mode 100644 index 0000000000000..49f838dbc4ee5 --- /dev/null +++ b/src/core/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "core", + "summary": "The core plugin has core functionality", + "version": "kibana", + "serviceFolders": ["http", "saved_objects", "chrome", "application"] + } + \ No newline at end of file diff --git a/src/core/server/elasticsearch/default_headers.test.ts b/src/core/server/elasticsearch/default_headers.test.ts new file mode 100644 index 0000000000000..58e6e222a3f2b --- /dev/null +++ b/src/core/server/elasticsearch/default_headers.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getReservedHeaders, PRODUCT_ORIGIN_HEADER } from './default_headers'; + +describe('getReservedHeaders', () => { + it('returns the list of reserved headers contained in a list', () => { + expect(getReservedHeaders(['foo', 'bar', PRODUCT_ORIGIN_HEADER])).toEqual([ + PRODUCT_ORIGIN_HEADER, + ]); + }); + + it('ignores the case when identifying headers', () => { + expect(getReservedHeaders(['foo', 'bar', PRODUCT_ORIGIN_HEADER.toUpperCase()])).toEqual([ + PRODUCT_ORIGIN_HEADER.toUpperCase(), + ]); + }); +}); diff --git a/src/core/server/elasticsearch/default_headers.ts b/src/core/server/elasticsearch/default_headers.ts index 737d4772c5c0e..eef04754cd958 100644 --- a/src/core/server/elasticsearch/default_headers.ts +++ b/src/core/server/elasticsearch/default_headers.ts @@ -8,9 +8,22 @@ import { deepFreeze } from '@kbn/std'; +export const PRODUCT_ORIGIN_HEADER = 'x-elastic-product-origin'; + +export const RESERVED_HEADERS = deepFreeze([PRODUCT_ORIGIN_HEADER]); + export const DEFAULT_HEADERS = deepFreeze({ // Elasticsearch uses this to identify when a request is coming from Kibana, to allow Kibana to - // access system indices using the standard ES APIs without logging a warning. After migrating to - // use the new system index APIs, this header can be removed. - 'x-elastic-product-origin': 'kibana', + // access system indices using the standard ES APIs. + [PRODUCT_ORIGIN_HEADER]: 'kibana', }); + +export const getReservedHeaders = (headerNames: string[]): string[] => { + const reservedHeaders = []; + for (const headerName of headerNames) { + if (RESERVED_HEADERS.includes(headerName.toLowerCase())) { + reservedHeaders.push(headerName); + } + } + return reservedHeaders; +}; diff --git a/src/core/server/elasticsearch/elasticsearch_config.test.ts b/src/core/server/elasticsearch/elasticsearch_config.test.ts index ae71932bdd3a9..d3f9693bab229 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.test.ts @@ -108,6 +108,35 @@ test('#requestHeadersWhitelist accepts both string and array of strings', () => expect(configValue.requestHeadersWhitelist).toEqual(['token', 'X-Forwarded-Proto']); }); +describe('reserved headers', () => { + test('throws if customHeaders contains reserved headers', () => { + expect(() => { + config.schema.validate({ + customHeaders: { foo: 'bar', 'x-elastic-product-origin': 'beats' }, + }); + }).toThrowErrorMatchingInlineSnapshot( + `"[customHeaders]: cannot use reserved headers: [x-elastic-product-origin]"` + ); + }); + + test('throws if requestHeadersWhitelist contains reserved headers', () => { + expect(() => { + config.schema.validate({ requestHeadersWhitelist: ['foo', 'x-elastic-product-origin'] }); + }).toThrowErrorMatchingInlineSnapshot(` + "[requestHeadersWhitelist]: types that failed validation: + - [requestHeadersWhitelist.0]: expected value of type [string] but got [Array] + - [requestHeadersWhitelist.1]: cannot use reserved headers: [x-elastic-product-origin]" + `); + expect(() => { + config.schema.validate({ requestHeadersWhitelist: 'x-elastic-product-origin' }); + }).toThrowErrorMatchingInlineSnapshot(` + "[requestHeadersWhitelist]: types that failed validation: + - [requestHeadersWhitelist.0]: cannot use reserved headers: [x-elastic-product-origin] + - [requestHeadersWhitelist.1]: could not parse array value from json input" + `); + }); +}); + describe('reads files', () => { beforeEach(() => { mockReadFileSync.mockReset(); diff --git a/src/core/server/elasticsearch/elasticsearch_config.ts b/src/core/server/elasticsearch/elasticsearch_config.ts index 300ff4a61a288..879002a6ece51 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.ts @@ -12,6 +12,7 @@ import { readFileSync } from 'fs'; import { ConfigDeprecationProvider } from 'src/core/server'; import { readPkcs12Keystore, readPkcs12Truststore } from '../utils'; import { ServiceConfigDescriptor } from '../internal_types'; +import { getReservedHeaders } from './default_headers'; const hostURISchema = schema.uri({ scheme: ['http', 'https'] }); @@ -52,10 +53,42 @@ export const configSchema = schema.object({ ) ), password: schema.maybe(schema.string()), - requestHeadersWhitelist: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { - defaultValue: ['authorization'], + requestHeadersWhitelist: schema.oneOf( + [ + schema.string({ + // can't use `validate` option on union types, forced to validate each individual subtypes + // see https://github.com/elastic/kibana/issues/64906 + validate: (headersWhitelist) => { + const reservedHeaders = getReservedHeaders([headersWhitelist]); + if (reservedHeaders.length) { + return `cannot use reserved headers: [${reservedHeaders.join(', ')}]`; + } + }, + }), + schema.arrayOf(schema.string(), { + // can't use `validate` option on union types, forced to validate each individual subtypes + // see https://github.com/elastic/kibana/issues/64906 + validate: (headersWhitelist) => { + const reservedHeaders = getReservedHeaders(headersWhitelist); + if (reservedHeaders.length) { + return `cannot use reserved headers: [${reservedHeaders.join(', ')}]`; + } + }, + }), + ], + { + defaultValue: ['authorization'], + } + ), + customHeaders: schema.recordOf(schema.string(), schema.string(), { + defaultValue: {}, + validate: (customHeaders) => { + const reservedHeaders = getReservedHeaders(Object.keys(customHeaders)); + if (reservedHeaders.length) { + return `cannot use reserved headers: [${reservedHeaders.join(', ')}]`; + } + }, }), - customHeaders: schema.recordOf(schema.string(), schema.string(), { defaultValue: {} }), shardTimeout: schema.duration({ defaultValue: '30s' }), requestTimeout: schema.duration({ defaultValue: '30s' }), pingTimeout: schema.duration({ defaultValue: schema.siblingRef('requestTimeout') }), diff --git a/src/core/server/http/integration_tests/logging.test.ts b/src/core/server/http/integration_tests/logging.test.ts index fcf2cd2ba3372..62cb699bc49f6 100644 --- a/src/core/server/http/integration_tests/logging.test.ts +++ b/src/core/server/http/integration_tests/logging.test.ts @@ -251,7 +251,7 @@ describe('request logging', () => { expect(JSON.parse(meta).http.response.headers.bar).toBe('world'); }); - it('filters sensitive request headers', async () => { + it('filters sensitive request headers by default', async () => { const { http } = await root.setup(); http.createRouter('/').post( @@ -283,7 +283,139 @@ describe('request logging', () => { expect(JSON.parse(meta).http.request.headers.authorization).toBe('[REDACTED]'); }); - it('filters sensitive response headers', async () => { + it('filters sensitive request headers when RewriteAppender is configured', async () => { + root = kbnTestServer.createRoot({ + logging: { + silent: true, + appenders: { + 'test-console': { + type: 'console', + layout: { + type: 'pattern', + pattern: '%level|%logger|%message|%meta', + }, + }, + rewrite: { + type: 'rewrite', + appenders: ['test-console'], + policy: { + type: 'meta', + mode: 'update', + properties: [ + { path: 'http.request.headers.authorization', value: '[REDACTED]' }, + ], + }, + }, + }, + loggers: [ + { + name: 'http.server.response', + appenders: ['rewrite'], + level: 'debug', + }, + ], + }, + plugins: { + initialize: false, + }, + }); + const { http } = await root.setup(); + + http.createRouter('/').post( + { + path: '/ping', + validate: { + body: schema.object({ message: schema.string() }), + }, + options: { + authRequired: 'optional', + body: { + accepts: ['application/json'], + }, + timeout: { payload: 100 }, + }, + }, + (context, req, res) => res.ok({ body: { message: req.body.message } }) + ); + await root.start(); + + await kbnTestServer.request + .post(root, '/ping') + .set('content-type', 'application/json') + .set('authorization', 'abc') + .send({ message: 'hi' }) + .expect(200); + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + const [, , , meta] = mockConsoleLog.mock.calls[0][0].split('|'); + expect(JSON.parse(meta).http.request.headers.authorization).toBe('[REDACTED]'); + }); + + it('filters sensitive response headers by defaut', async () => { + const { http } = await root.setup(); + + http.createRouter('/').post( + { + path: '/ping', + validate: { + body: schema.object({ message: schema.string() }), + }, + options: { + authRequired: 'optional', + body: { + accepts: ['application/json'], + }, + timeout: { payload: 100 }, + }, + }, + (context, req, res) => + res.ok({ headers: { 'set-cookie': ['123'] }, body: { message: req.body.message } }) + ); + await root.start(); + + await kbnTestServer.request + .post(root, '/ping') + .set('Content-Type', 'application/json') + .send({ message: 'hi' }) + .expect(200); + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + const [, , , meta] = mockConsoleLog.mock.calls[0][0].split('|'); + expect(JSON.parse(meta).http.response.headers['set-cookie']).toBe('[REDACTED]'); + }); + + it('filters sensitive response headers when RewriteAppender is configured', async () => { + root = kbnTestServer.createRoot({ + logging: { + silent: true, + appenders: { + 'test-console': { + type: 'console', + layout: { + type: 'pattern', + pattern: '%level|%logger|%message|%meta', + }, + }, + rewrite: { + type: 'rewrite', + appenders: ['test-console'], + policy: { + type: 'meta', + mode: 'update', + properties: [{ path: 'http.response.headers.set-cookie', value: '[REDACTED]' }], + }, + }, + }, + loggers: [ + { + name: 'http.server.response', + appenders: ['rewrite'], + level: 'debug', + }, + ], + }, + plugins: { + initialize: false, + }, + }); const { http } = await root.setup(); http.createRouter('/').post( diff --git a/src/core/server/http/logging/get_response_log.test.ts b/src/core/server/http/logging/get_response_log.test.ts index 46c4f1d95e3be..64241ff44fc6b 100644 --- a/src/core/server/http/logging/get_response_log.test.ts +++ b/src/core/server/http/logging/get_response_log.test.ts @@ -171,6 +171,53 @@ describe('getEcsResponseLog', () => { }); test('does not mutate original headers', () => { + const reqHeaders = { a: 'foo', b: ['hello', 'world'] }; + const resHeaders = { headers: { c: 'bar' } }; + const req = createMockHapiRequest({ + headers: reqHeaders, + response: { headers: resHeaders }, + }); + + const responseLog = getEcsResponseLog(req, logger); + expect(reqHeaders).toMatchInlineSnapshot(` + Object { + "a": "foo", + "b": Array [ + "hello", + "world", + ], + } + `); + expect(resHeaders).toMatchInlineSnapshot(` + Object { + "headers": Object { + "c": "bar", + }, + } + `); + + responseLog.http.request.headers.a = 'testA'; + responseLog.http.request.headers.b[1] = 'testB'; + responseLog.http.request.headers.c = 'testC'; + expect(reqHeaders).toMatchInlineSnapshot(` + Object { + "a": "foo", + "b": Array [ + "hello", + "world", + ], + } + `); + expect(resHeaders).toMatchInlineSnapshot(` + Object { + "headers": Object { + "c": "bar", + }, + } + `); + }); + + test('does not mutate original headers when redacting sensitive data', () => { const reqHeaders = { authorization: 'a', cookie: 'b', 'user-agent': 'hi' }; const resHeaders = { headers: { 'content-length': 123, 'set-cookie': 'c' } }; const req = createMockHapiRequest({ diff --git a/src/core/server/http/logging/get_response_log.ts b/src/core/server/http/logging/get_response_log.ts index f75acde93bf40..57c02e05bebff 100644 --- a/src/core/server/http/logging/get_response_log.ts +++ b/src/core/server/http/logging/get_response_log.ts @@ -18,14 +18,22 @@ const ECS_VERSION = '1.7.0'; const FORBIDDEN_HEADERS = ['authorization', 'cookie', 'set-cookie']; const REDACTED_HEADER_TEXT = '[REDACTED]'; +type HapiHeaders = Record; + // We are excluding sensitive headers by default, until we have a log filtering mechanism. -function redactSensitiveHeaders( - headers?: Record -): Record { - const result = {} as Record; +function redactSensitiveHeaders(key: string, value: string | string[]): string | string[] { + return FORBIDDEN_HEADERS.includes(key) ? REDACTED_HEADER_TEXT : value; +} + +// Shallow clone the headers so they are not mutated if filtered by a RewriteAppender. +function cloneAndFilterHeaders(headers?: HapiHeaders) { + const result = {} as HapiHeaders; if (headers) { for (const key of Object.keys(headers)) { - result[key] = FORBIDDEN_HEADERS.includes(key) ? REDACTED_HEADER_TEXT : headers[key]; + result[key] = redactSensitiveHeaders( + key, + Array.isArray(headers[key]) ? [...headers[key]] : headers[key] + ); } } return result; @@ -45,7 +53,11 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta { // eslint-disable-next-line @typescript-eslint/naming-convention const status_code = isBoom(response) ? response.output.statusCode : response.statusCode; - const responseHeaders = isBoom(response) ? response.output.headers : response.headers; + + const requestHeaders = cloneAndFilterHeaders(request.headers); + const responseHeaders = cloneAndFilterHeaders( + isBoom(response) ? (response.output.headers as HapiHeaders) : response.headers + ); // borrowed from the hapi/good implementation const responseTime = (request.info.completed || request.info.responded) - request.info.received; @@ -66,7 +78,7 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta { mime_type: request.mime, referrer: request.info.referrer, // @ts-expect-error Headers are not yet part of ECS: https://github.com/elastic/ecs/issues/232. - headers: redactSensitiveHeaders(request.headers), + headers: requestHeaders, }, response: { body: { @@ -74,7 +86,7 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta { }, status_code, // @ts-expect-error Headers are not yet part of ECS: https://github.com/elastic/ecs/issues/232. - headers: redactSensitiveHeaders(responseHeaders), + headers: responseHeaders, // responseTime is a custom non-ECS field responseTime: !isNaN(responseTime) ? responseTime : undefined, }, diff --git a/src/core/server/logging/README.mdx b/src/core/server/logging/README.mdx index 8c093d0231585..1575e67d7b8ee 100644 --- a/src/core/server/logging/README.mdx +++ b/src/core/server/logging/README.mdx @@ -278,6 +278,124 @@ The maximum number of files to keep. Once this number is reached, oldest files w The default value is `7` +### Rewrite Appender + +*This appender is currently considered experimental and is not intended +for public consumption. The API is subject to change at any time.* + +Similar to log4j's `RewriteAppender`, this appender serves as a sort of middleware, +modifying the provided log events before passing them along to another +appender. + +```yaml +logging: + appenders: + my-rewrite-appender: + type: rewrite + appenders: [console, file] # name of "destination" appender(s) + policy: + # ... +``` + +The most common use case for the `RewriteAppender` is when you want to +filter or censor sensitive data that may be contained in a log entry. +In fact, with a default configuration, Kibana will automatically redact +any `authorization`, `cookie`, or `set-cookie` headers when logging http +requests & responses. + +To configure additional rewrite rules, you'll need to specify a `RewritePolicy`. + +#### Rewrite Policies + +Rewrite policies exist to indicate which parts of a log record can be +modified within the rewrite appender. + +**Meta** + +The `meta` rewrite policy can read and modify any data contained in the +`LogMeta` before passing it along to a destination appender. + +Meta policies must specify one of three modes, which indicate which action +to perform on the configured properties: +- `update` updates an existing property at the provided `path`. +- `remove` removes an existing property at the provided `path`. + +The `properties` are listed as a `path` and `value` pair, where `path` is +the dot-delimited path to the target property in the `LogMeta` object, and +`value` is the value to add or update in that target property. When using +the `remove` mode, a `value` is not necessary. + +Here's an example of how you would replace any `cookie` header values with `[REDACTED]`: + +```yaml +logging: + appenders: + my-rewrite-appender: + type: rewrite + appenders: [console] + policy: + type: meta # indicates that we want to rewrite the LogMeta + mode: update # will update an existing property only + properties: + - path: "http.request.headers.cookie" # path to property + value: "[REDACTED]" # value to replace at path +``` + +Rewrite appenders can even be passed to other rewrite appenders to apply +multiple filter policies/modes, as long as it doesn't create a circular +reference. Each rewrite appender is applied sequentially (one after the other). +```yaml +logging: + appenders: + remove-request-headers: + type: rewrite + appenders: [censor-response-headers] # redirect to the next rewrite appender + policy: + type: meta + mode: remove + properties: + - path: "http.request.headers" # remove all request headers + censor-response-headers: + type: rewrite + appenders: [console] # output to console + policy: + type: meta + mode: update + properties: + - path: "http.response.headers.set-cookie" + value: "[REDACTED]" +``` + +#### Complete Example +```yaml +logging: + appenders: + console: + type: console + layout: + type: pattern + highlight: true + pattern: "[%date][%level][%logger] %message %meta" + file: + type: file + fileName: ./kibana.log + layout: + type: json + censor: + type: rewrite + appenders: [console, file] + policy: + type: meta + mode: update + properties: + - path: "http.request.headers.cookie" + value: "[REDACTED]" + loggers: + - name: http.server.response + appenders: [censor] # pass these logs to our rewrite appender + level: debug +``` + ## Configuration As any configuration in the platform, logging configuration is validated against the predefined schema and if there are diff --git a/src/core/server/logging/appenders/appenders.ts b/src/core/server/logging/appenders/appenders.ts index a41a6a2f68fa1..88df355bd5ebe 100644 --- a/src/core/server/logging/appenders/appenders.ts +++ b/src/core/server/logging/appenders/appenders.ts @@ -17,6 +17,7 @@ import { import { Layouts } from '../layouts/layouts'; import { ConsoleAppender, ConsoleAppenderConfig } from './console/console_appender'; import { FileAppender, FileAppenderConfig } from './file/file_appender'; +import { RewriteAppender, RewriteAppenderConfig } from './rewrite/rewrite_appender'; import { RollingFileAppender, RollingFileAppenderConfig, @@ -32,6 +33,7 @@ export const appendersSchema = schema.oneOf([ ConsoleAppender.configSchema, FileAppender.configSchema, LegacyAppender.configSchema, + RewriteAppender.configSchema, RollingFileAppender.configSchema, ]); @@ -40,6 +42,7 @@ export type AppenderConfigType = | ConsoleAppenderConfig | FileAppenderConfig | LegacyAppenderConfig + | RewriteAppenderConfig | RollingFileAppenderConfig; /** @internal */ @@ -57,6 +60,8 @@ export class Appenders { return new ConsoleAppender(Layouts.create(config.layout)); case 'file': return new FileAppender(Layouts.create(config.layout), config.fileName); + case 'rewrite': + return new RewriteAppender(config); case 'rolling-file': return new RollingFileAppender(config); case 'legacy-appender': diff --git a/src/core/server/logging/appenders/rewrite/mocks.ts b/src/core/server/logging/appenders/rewrite/mocks.ts new file mode 100644 index 0000000000000..a19756e25bf8e --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/mocks.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { RewritePolicy } from './policies/policy'; + +const createPolicyMock = () => { + const mock: jest.Mocked = { + rewrite: jest.fn((x) => x), + }; + return mock; +}; + +export const rewriteAppenderMocks = { + createPolicy: createPolicyMock, +}; diff --git a/src/core/server/logging/appenders/rewrite/policies/index.ts b/src/core/server/logging/appenders/rewrite/policies/index.ts new file mode 100644 index 0000000000000..ae3be1e4de916 --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/policies/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { assertNever } from '@kbn/std'; +import { RewritePolicy } from './policy'; +import { MetaRewritePolicy, MetaRewritePolicyConfig, metaRewritePolicyConfigSchema } from './meta'; + +export { RewritePolicy }; + +/** + * Available rewrite policies which specify what part of a {@link LogRecord} + * can be modified. + */ +export type RewritePolicyConfig = MetaRewritePolicyConfig; + +export const rewritePolicyConfigSchema = metaRewritePolicyConfigSchema; + +export const createRewritePolicy = (config: RewritePolicyConfig): RewritePolicy => { + switch (config.type) { + case 'meta': + return new MetaRewritePolicy(config); + default: + return assertNever(config.type); + } +}; diff --git a/src/core/server/logging/appenders/rewrite/policies/meta/index.ts b/src/core/server/logging/appenders/rewrite/policies/meta/index.ts new file mode 100644 index 0000000000000..afdfd6fb709d3 --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/policies/meta/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { + MetaRewritePolicy, + MetaRewritePolicyConfig, + metaRewritePolicyConfigSchema, +} from './meta_policy'; diff --git a/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts b/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts new file mode 100644 index 0000000000000..52b88331a75be --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { LogRecord, LogLevel, LogMeta } from '@kbn/logging'; +import { MetaRewritePolicy, MetaRewritePolicyConfig } from './meta_policy'; + +describe('MetaRewritePolicy', () => { + const createPolicy = ( + mode: MetaRewritePolicyConfig['mode'], + properties: MetaRewritePolicyConfig['properties'] + ) => new MetaRewritePolicy({ type: 'meta', mode, properties }); + + const createLogRecord = (meta: LogMeta = {}): LogRecord => ({ + timestamp: new Date(Date.UTC(2012, 1, 1, 14, 30, 22, 11)), + level: LogLevel.Info, + context: 'context', + message: 'just a log', + pid: 42, + meta, + }); + + describe('mode: update', () => { + it('updates existing properties in LogMeta', () => { + const log = createLogRecord({ a: 'before' }); + const policy = createPolicy('update', [{ path: 'a', value: 'after' }]); + expect(policy.rewrite(log).meta!.a).toBe('after'); + }); + + it('updates nested properties in LogMeta', () => { + const log = createLogRecord({ a: 'before a', b: { c: 'before b.c' }, d: [0, 1] }); + const policy = createPolicy('update', [ + { path: 'a', value: 'after a' }, + { path: 'b.c', value: 'after b.c' }, + { path: 'd[1]', value: 2 }, + ]); + expect(policy.rewrite(log).meta).toMatchInlineSnapshot(` + Object { + "a": "after a", + "b": Object { + "c": "after b.c", + }, + "d": Array [ + 0, + 2, + ], + } + `); + }); + + it('handles string, number, boolean, null', () => { + const policy = createPolicy('update', [ + { path: 'a', value: false }, + { path: 'b', value: null }, + { path: 'c', value: 123 }, + { path: 'd', value: 'hi' }, + ]); + const log = createLogRecord({ + a: 'a', + b: 'b', + c: 'c', + d: 'd', + }); + expect(policy.rewrite(log).meta).toMatchInlineSnapshot(` + Object { + "a": false, + "b": null, + "c": 123, + "d": "hi", + } + `); + }); + + it(`does not add properties which don't exist yet`, () => { + const policy = createPolicy('update', [ + { path: 'a.b', value: 'foo' }, + { path: 'a.c', value: 'bar' }, + ]); + const log = createLogRecord({ a: { b: 'existing meta' } }); + const { meta } = policy.rewrite(log); + expect(meta!.a.b).toBe('foo'); + expect(meta!.a.c).toBeUndefined(); + }); + + it('does not touch anything outside of LogMeta', () => { + const policy = createPolicy('update', [{ path: 'a', value: 'bar' }]); + const message = Symbol(); + expect( + policy.rewrite(({ message, meta: { a: 'foo' } } as unknown) as LogRecord).message + ).toBe(message); + expect(policy.rewrite(({ message, meta: { a: 'foo' } } as unknown) as LogRecord)) + .toMatchInlineSnapshot(` + Object { + "message": Symbol(), + "meta": Object { + "a": "bar", + }, + } + `); + }); + }); + + describe('mode: remove', () => { + it('removes existing properties in LogMeta', () => { + const log = createLogRecord({ a: 'goodbye' }); + const policy = createPolicy('remove', [{ path: 'a' }]); + expect(policy.rewrite(log).meta!.a).toBeUndefined(); + }); + + it('removes nested properties in LogMeta', () => { + const log = createLogRecord({ a: 'a', b: { c: 'b.c' }, d: [0, 1] }); + const policy = createPolicy('remove', [{ path: 'b.c' }, { path: 'd[1]' }]); + expect(policy.rewrite(log).meta).toMatchInlineSnapshot(` + Object { + "a": "a", + "b": Object {}, + "d": Array [ + 0, + undefined, + ], + } + `); + }); + + it('has no effect if property does not exist', () => { + const log = createLogRecord({ a: 'a' }); + const policy = createPolicy('remove', [{ path: 'b' }]); + expect(policy.rewrite(log).meta).toMatchInlineSnapshot(` + Object { + "a": "a", + } + `); + }); + + it('does not touch anything outside of LogMeta', () => { + const policy = createPolicy('remove', [{ path: 'message' }]); + const message = Symbol(); + expect( + policy.rewrite(({ message, meta: { message: 'foo' } } as unknown) as LogRecord).message + ).toBe(message); + expect(policy.rewrite(({ message, meta: { message: 'foo' } } as unknown) as LogRecord)) + .toMatchInlineSnapshot(` + Object { + "message": Symbol(), + "meta": Object {}, + } + `); + }); + }); +}); diff --git a/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.ts b/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.ts new file mode 100644 index 0000000000000..2215b3489539f --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; +import { LogRecord } from '@kbn/logging'; +import { set } from '@elastic/safer-lodash-set'; +import { has, unset } from 'lodash'; +import { assertNever } from '@kbn/std'; +import { RewritePolicy } from '../policy'; + +type MetaRewritePolicyConfigProperties = Array<{ + path: string; + value?: string | number | boolean | null; +}>; + +export interface MetaRewritePolicyConfig { + type: 'meta'; + + /** + * The 'mode' specifies what action to perform on the specified properties. + * - 'update' updates an existing property at the provided 'path'. + * - 'remove' removes an existing property at the provided 'path'. + */ + mode: 'remove' | 'update'; + + /** + * The properties to modify. + * + * @remarks + * Each provided 'path' is relative to the record's {@link LogMeta}. + * For the 'remove' mode, no 'value' is provided. + */ + properties: MetaRewritePolicyConfigProperties; +} + +export const metaRewritePolicyConfigSchema = schema.object({ + type: schema.literal('meta'), + mode: schema.oneOf([schema.literal('update'), schema.literal('remove')], { + defaultValue: 'update', + }), + properties: schema.arrayOf( + schema.object({ + path: schema.string(), + value: schema.maybe( + schema.nullable(schema.oneOf([schema.string(), schema.number(), schema.boolean()])) + ), + }) + ), +}); + +/** + * A rewrite policy which can add, remove, or update properties + * from a record's {@link LogMeta}. + */ +export class MetaRewritePolicy implements RewritePolicy { + constructor(private readonly config: MetaRewritePolicyConfig) {} + + rewrite(record: LogRecord): LogRecord { + switch (this.config.mode) { + case 'update': + return this.update(record); + case 'remove': + return this.remove(record); + default: + return assertNever(this.config.mode); + } + } + + private update(record: LogRecord) { + for (const { path, value } of this.config.properties) { + if (!has(record, `meta.${path}`)) { + continue; // don't add properties which don't already exist + } + set(record, `meta.${path}`, value); + } + return record; + } + + private remove(record: LogRecord) { + for (const { path } of this.config.properties) { + unset(record, `meta.${path}`); + } + return record; + } +} diff --git a/src/core/server/logging/appenders/rewrite/policies/policy.ts b/src/core/server/logging/appenders/rewrite/policies/policy.ts new file mode 100644 index 0000000000000..f8aef887965fd --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/policies/policy.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { LogRecord } from '@kbn/logging'; + +/** + * Rewrites a {@link LogRecord} based on the policy's configuration. + **/ +export interface RewritePolicy { + rewrite(record: LogRecord): LogRecord; +} diff --git a/src/core/server/logging/appenders/rewrite/rewrite_appender.test.mocks.ts b/src/core/server/logging/appenders/rewrite/rewrite_appender.test.mocks.ts new file mode 100644 index 0000000000000..9d29a68305792 --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/rewrite_appender.test.mocks.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; + +export const createRewritePolicyMock = jest.fn(); +jest.doMock('./policies', () => ({ + rewritePolicyConfigSchema: schema.any(), + createRewritePolicy: createRewritePolicyMock, +})); + +export const resetAllMocks = () => { + createRewritePolicyMock.mockReset(); +}; diff --git a/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts b/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts new file mode 100644 index 0000000000000..72a54b5012ce5 --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { MockedKeys } from '@kbn/utility-types/jest'; +import { createRewritePolicyMock, resetAllMocks } from './rewrite_appender.test.mocks'; +import { rewriteAppenderMocks } from './mocks'; +import { LogLevel, LogRecord, LogMeta, DisposableAppender } from '@kbn/logging'; +import { RewriteAppender, RewriteAppenderConfig } from './rewrite_appender'; + +// Helper to ensure tuple is typed [A, B] instead of Array +const toTuple = (a: A, b: B): [A, B] => [a, b]; + +const createAppenderMock = (name: string) => { + const appenderMock: MockedKeys = { + append: jest.fn(), + dispose: jest.fn(), + }; + + return toTuple(name, appenderMock); +}; + +const createConfig = (appenderNames: string[]): RewriteAppenderConfig => ({ + type: 'rewrite', + appenders: appenderNames, + policy: { + type: 'meta', + mode: 'update', + properties: [{ path: 'foo', value: 'bar' }], + }, +}); + +const createLogRecord = (meta: LogMeta = {}): LogRecord => ({ + timestamp: new Date(), + level: LogLevel.Info, + context: 'context', + message: 'just a log', + pid: 42, + meta, +}); + +describe('RewriteAppender', () => { + let policy: ReturnType; + + beforeEach(() => { + policy = rewriteAppenderMocks.createPolicy(); + createRewritePolicyMock.mockReturnValue(policy); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + resetAllMocks(); + }); + + it('creates a rewrite policy with the provided config', () => { + const config = createConfig([]); + new RewriteAppender(config); + expect(createRewritePolicyMock).toHaveBeenCalledTimes(1); + expect(createRewritePolicyMock).toHaveBeenCalledWith(config.policy); + }); + + describe('#addAppender', () => { + it('updates the map of available appenders', () => { + const config = createConfig(['mock1']); + const appender = new RewriteAppender(config); + appender.addAppender(...createAppenderMock('mock1')); + expect(() => { + appender.append(createLogRecord()); + }).not.toThrowError(); + }); + }); + + describe('#append', () => { + it('calls the configured appenders with the provided LogRecord', () => { + const config = createConfig(['mock1', 'mock2']); + const appenderMocks = [createAppenderMock('mock1'), createAppenderMock('mock2')]; + + const appender = new RewriteAppender(config); + appenderMocks.forEach((mock) => appender.addAppender(...mock)); + + const log1 = createLogRecord({ a: 'b' }); + const log2 = createLogRecord({ c: 'd' }); + + appender.append(log1); + + expect(appenderMocks[0][1].append).toHaveBeenCalledTimes(1); + expect(appenderMocks[1][1].append).toHaveBeenCalledTimes(1); + expect(appenderMocks[0][1].append).toHaveBeenCalledWith(log1); + expect(appenderMocks[1][1].append).toHaveBeenCalledWith(log1); + + appender.append(log2); + + expect(appenderMocks[0][1].append).toHaveBeenCalledTimes(2); + expect(appenderMocks[1][1].append).toHaveBeenCalledTimes(2); + expect(appenderMocks[0][1].append).toHaveBeenCalledWith(log2); + expect(appenderMocks[1][1].append).toHaveBeenCalledWith(log2); + }); + + it('calls `rewrite` on the configured policy', () => { + const config = createConfig(['mock1']); + + const appender = new RewriteAppender(config); + appender.addAppender(...createAppenderMock('mock1')); + + const log1 = createLogRecord({ a: 'b' }); + const log2 = createLogRecord({ c: 'd' }); + + appender.append(log1); + + expect(policy.rewrite).toHaveBeenCalledTimes(1); + expect(policy.rewrite.mock.calls).toEqual([[log1]]); + + appender.append(log2); + + expect(policy.rewrite).toHaveBeenCalledTimes(2); + expect(policy.rewrite.mock.calls).toEqual([[log1], [log2]]); + }); + + it('throws if an appender key cannot be found', () => { + const config = createConfig(['oops']); + const appender = new RewriteAppender(config); + + expect(() => { + appender.append(createLogRecord()); + }).toThrowErrorMatchingInlineSnapshot( + `"Rewrite Appender could not find appender key \\"oops\\". Be sure \`appender.addAppender()\` was called before \`appender.append()\`."` + ); + }); + }); +}); diff --git a/src/core/server/logging/appenders/rewrite/rewrite_appender.ts b/src/core/server/logging/appenders/rewrite/rewrite_appender.ts new file mode 100644 index 0000000000000..e54d8ba40ebfc --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/rewrite_appender.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; +import { LogRecord, Appender, DisposableAppender } from '@kbn/logging'; +import { + createRewritePolicy, + rewritePolicyConfigSchema, + RewritePolicy, + RewritePolicyConfig, +} from './policies'; + +export interface RewriteAppenderConfig { + type: 'rewrite'; + /** + * The {@link Appender | appender(s)} to pass the log event to after + * implementing the specified rewrite policy. + */ + appenders: string[]; + /** + * The {@link RewritePolicy | policy} to use to manipulate the provided data. + */ + policy: RewritePolicyConfig; +} + +/** + * Appender that can modify the `LogRecord` instances it receives before passing + * them along to another {@link Appender}. + * @internal + */ +export class RewriteAppender implements DisposableAppender { + public static configSchema = schema.object({ + type: schema.literal('rewrite'), + appenders: schema.arrayOf(schema.string(), { defaultValue: [] }), + policy: rewritePolicyConfigSchema, + }); + + private appenders: Map = new Map(); + private readonly policy: RewritePolicy; + + constructor(private readonly config: RewriteAppenderConfig) { + this.policy = createRewritePolicy(config.policy); + } + + /** + * List of appenders that are dependencies of this appender. + * + * `addAppender` will throw an error when called with an appender + * reference that isn't in this list. + */ + public get appenderRefs() { + return this.config.appenders; + } + + /** + * Appenders can be "attached" to this one so that the RewriteAppender + * is able to act as a sort of middleware by calling `append` on other appenders. + * + * As appenders cannot be attached to each other until they are created, + * the `addAppender` method is used to pass in a configured appender. + */ + public addAppender(appenderRef: string, appender: Appender) { + if (!this.appenderRefs.includes(appenderRef)) { + throw new Error( + `addAppender was called with an appender key that is missing from the appenderRefs: "${appenderRef}".` + ); + } + + this.appenders.set(appenderRef, appender); + } + + /** + * Modifies the `record` and passes it to the specified appender. + */ + public append(record: LogRecord) { + const rewrittenRecord = this.policy.rewrite(record); + for (const appenderRef of this.appenderRefs) { + const appender = this.appenders.get(appenderRef); + if (!appender) { + throw new Error( + `Rewrite Appender could not find appender key "${appenderRef}". ` + + 'Be sure `appender.addAppender()` was called before `appender.append()`.' + ); + } + appender.append(rewrittenRecord); + } + } + + /** + * Disposes `RewriteAppender`. + */ + public dispose() { + this.appenders.clear(); + } +} diff --git a/src/core/server/logging/logging_config.test.ts b/src/core/server/logging/logging_config.test.ts index 2cb5831a8fb4c..83f3c139e371a 100644 --- a/src/core/server/logging/logging_config.test.ts +++ b/src/core/server/logging/logging_config.test.ts @@ -78,7 +78,6 @@ test('correctly fills in custom `appenders` config.', () => { type: 'console', layout: { type: 'pattern', highlight: true }, }); - expect(configValue.appenders.get('console')).toEqual({ type: 'console', layout: { type: 'pattern' }, diff --git a/src/core/server/logging/logging_system.test.ts b/src/core/server/logging/logging_system.test.ts index f68d6c6a97fbc..8a6fe71bc6222 100644 --- a/src/core/server/logging/logging_system.test.ts +++ b/src/core/server/logging/logging_system.test.ts @@ -134,6 +134,76 @@ test('uses `root` logger if context name is not specified.', async () => { expect(mockConsoleLog.mock.calls).toMatchSnapshot(); }); +test('attaches appenders to appenders that declare refs', async () => { + await system.upgrade( + config.schema.validate({ + appenders: { + console: { + type: 'console', + layout: { type: 'pattern', pattern: '[%logger] %message %meta' }, + }, + file: { + type: 'file', + layout: { type: 'pattern', pattern: '[%logger] %message %meta' }, + fileName: 'path', + }, + rewrite: { + type: 'rewrite', + appenders: ['console', 'file'], + policy: { type: 'meta', mode: 'remove', properties: [{ path: 'b' }] }, + }, + }, + loggers: [{ name: 'tests', level: 'warn', appenders: ['rewrite'] }], + }) + ); + + const testLogger = system.get('tests'); + testLogger.warn('This message goes to a test context.', { a: 'hi', b: 'remove me' }); + + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + expect(mockConsoleLog.mock.calls[0][0]).toMatchInlineSnapshot( + `"[tests] This message goes to a test context. {\\"a\\":\\"hi\\"}"` + ); + + expect(mockStreamWrite).toHaveBeenCalledTimes(1); + expect(mockStreamWrite.mock.calls[0][0]).toMatchInlineSnapshot(` + "[tests] This message goes to a test context. {\\"a\\":\\"hi\\"} + " + `); +}); + +test('throws if a circular appender reference is detected', async () => { + expect(async () => { + await system.upgrade( + config.schema.validate({ + appenders: { + console: { type: 'console', layout: { type: 'pattern' } }, + a: { + type: 'rewrite', + appenders: ['b'], + policy: { type: 'meta', mode: 'remove', properties: [{ path: 'b' }] }, + }, + b: { + type: 'rewrite', + appenders: ['c'], + policy: { type: 'meta', mode: 'remove', properties: [{ path: 'b' }] }, + }, + c: { + type: 'rewrite', + appenders: ['console', 'a'], + policy: { type: 'meta', mode: 'remove', properties: [{ path: 'b' }] }, + }, + }, + loggers: [{ name: 'tests', level: 'warn', appenders: ['a'] }], + }) + ); + }).rejects.toThrowErrorMatchingInlineSnapshot( + `"Circular appender reference detected: [b -> c -> a -> b]"` + ); + + expect(mockConsoleLog).toHaveBeenCalledTimes(0); +}); + test('`stop()` disposes all appenders.', async () => { await system.upgrade( config.schema.validate({ diff --git a/src/core/server/logging/logging_system.ts b/src/core/server/logging/logging_system.ts index 9ae434aff41d3..d7c34b48c4101 100644 --- a/src/core/server/logging/logging_system.ts +++ b/src/core/server/logging/logging_system.ts @@ -146,6 +146,26 @@ export class LoggingSystem implements LoggerFactory { return this.getLoggerConfigByContext(config, LoggingConfig.getParentLoggerContext(context)); } + /** + * Retrieves an appender by the provided key, after first checking that no circular + * dependencies exist between appender refs. + */ + private getAppenderByRef(appenderRef: string) { + const checkCircularRefs = (key: string, stack: string[]) => { + if (stack.includes(key)) { + throw new Error(`Circular appender reference detected: [${stack.join(' -> ')} -> ${key}]`); + } + stack.push(key); + const appender = this.appenders.get(key); + if (appender?.appenderRefs) { + appender.appenderRefs.forEach((ref) => checkCircularRefs(ref, [...stack])); + } + return appender; + }; + + return checkCircularRefs(appenderRef, []); + } + private async applyBaseConfig(newBaseConfig: LoggingConfig) { const computedConfig = [...this.contextConfigs.values()].reduce( (baseConfig, contextConfig) => baseConfig.extend(contextConfig), @@ -167,6 +187,23 @@ export class LoggingSystem implements LoggerFactory { this.appenders.set(appenderKey, Appenders.create(appenderConfig)); } + // Once all appenders have been created, check for any that have explicitly + // declared `appenderRefs` dependencies, and look up those dependencies to + // attach to the appender. This enables appenders to act as a sort of + // middleware and call `append` on each other if needed. + for (const [key, appender] of this.appenders) { + if (!appender.addAppender || !appender.appenderRefs) { + continue; + } + for (const ref of appender.appenderRefs) { + const foundAppender = this.getAppenderByRef(ref); + if (!foundAppender) { + throw new Error(`Appender "${key}" config contains unknown appender key "${ref}".`); + } + appender.addAppender(ref, foundAppender); + } + } + for (const [loggerKey, loggerAdapter] of this.loggers) { loggerAdapter.updateLogger(this.createLogger(loggerKey, computedConfig)); } diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.ts index eae0e73e86c46..7ac629534ba08 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.ts @@ -47,6 +47,7 @@ const KNOWN_MANIFEST_FIELDS = (() => { server: true, extraPublicDirs: true, requiredBundles: true, + serviceFolders: true, }; return new Set(Object.keys(manifestFields)); diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index 45db98201b758..a6086bd6f17e8 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -169,6 +169,12 @@ export interface PluginManifest { * @deprecated */ readonly extraPublicDirs?: string[]; + + /** + * Only used for the automatically generated API documentation. Specifying service + * folders will cause your plugin API reference to be broken up into sub sections. + */ + readonly serviceFolders?: readonly string[]; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 2177da84b2b53..72d66bc04f08e 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -180,10 +180,11 @@ export interface AppCategory { // Warning: (ae-forgotten-export) The symbol "ConsoleAppenderConfig" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "FileAppenderConfig" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "LegacyAppenderConfig" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RewriteAppenderConfig" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RollingFileAppenderConfig" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export type AppenderConfigType = ConsoleAppenderConfig | FileAppenderConfig | LegacyAppenderConfig | RollingFileAppenderConfig; +export type AppenderConfigType = ConsoleAppenderConfig | FileAppenderConfig | LegacyAppenderConfig | RewriteAppenderConfig | RollingFileAppenderConfig; // @public @deprecated (undocumented) export interface AssistanceAPIResponse { @@ -1882,6 +1883,7 @@ export interface PluginManifest { readonly requiredBundles: readonly string[]; readonly requiredPlugins: readonly PluginName[]; readonly server: boolean; + readonly serviceFolders?: readonly string[]; readonly ui: boolean; readonly version: string; } @@ -3197,9 +3199,9 @@ export const validBodyOutput: readonly ["data", "stream"]; // Warnings were encountered during analysis: // // src/core/server/http/router/response.ts:297:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:280:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:280:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:283:3 - (ae-forgotten-export) The symbol "SavedObjectsConfigType" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:388:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "create" +// src/core/server/plugins/types.ts:286:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:286:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:289:3 - (ae-forgotten-export) The symbol "SavedObjectsConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:394:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "create" ``` diff --git a/src/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index c4559029e5607..f9c1e67c0540d 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -21,6 +21,10 @@ cp "src/dev/ci_setup/.bazelrc-ci" "$HOME/.bazelrc"; echo "# Appended by src/dev/ci_setup/setup.sh" >> "$HOME/.bazelrc" echo "build --remote_header=x-buildbuddy-api-key=$KIBANA_BUILDBUDDY_CI_API_KEY" >> "$HOME/.bazelrc" +if [[ "$BUILD_TS_REFS_CACHE_ENABLE" != "true" ]]; then + export BUILD_TS_REFS_CACHE_ENABLE=false +fi + ### ### install dependencies ### diff --git a/src/dev/code_coverage/ingest_coverage/ingest.js b/src/dev/code_coverage/ingest_coverage/ingest.js index 819d9f128d62d..0d1108ef4acf4 100644 --- a/src/dev/code_coverage/ingest_coverage/ingest.js +++ b/src/dev/code_coverage/ingest_coverage/ingest.js @@ -51,7 +51,7 @@ function dontSendButLog(log) { async function send(logF, idx, redactedEsHostUrl, client, requestBody) { try { await client.index(requestBody); - logF(requestBody); + // logF(requestBody); // A simple way to speed things up, just log less output. } catch (e) { const { body } = requestBody; const parsed = parse(body); diff --git a/src/dev/typescript/build_ts_refs_cli.ts b/src/dev/typescript/build_ts_refs_cli.ts index fc8911a251773..a073e58623278 100644 --- a/src/dev/typescript/build_ts_refs_cli.ts +++ b/src/dev/typescript/build_ts_refs_cli.ts @@ -23,7 +23,7 @@ export async function runBuildRefsCli() { async ({ log, flags }) => { const outDirs = getOutputsDeep(REF_CONFIG_PATHS); - const cacheEnabled = process.env.BUILD_TS_REFS_CACHE_ENABLE === 'true' || !!flags.cache; + const cacheEnabled = process.env.BUILD_TS_REFS_CACHE_ENABLE !== 'false' && !!flags.cache; const doCapture = process.env.BUILD_TS_REFS_CACHE_CAPTURE === 'true'; const doClean = !!flags.clean || doCapture; const doInitCache = cacheEnabled && !doClean; @@ -62,6 +62,9 @@ export async function runBuildRefsCli() { description: 'Build TypeScript projects', flags: { boolean: ['clean', 'cache'], + default: { + cache: true, + }, }, log: { defaultLevel: 'debug', diff --git a/src/dev/typescript/ref_output_cache/ref_output_cache.ts b/src/dev/typescript/ref_output_cache/ref_output_cache.ts index 342470ce0c6e3..ca69236a706d2 100644 --- a/src/dev/typescript/ref_output_cache/ref_output_cache.ts +++ b/src/dev/typescript/ref_output_cache/ref_output_cache.ts @@ -76,28 +76,28 @@ export class RefOutputCache { * written to the directory. */ async initCaches() { - const archive = - this.archives.get(this.mergeBase) ?? - (await this.archives.getFirstAvailable([ - this.mergeBase, - ...(await this.repo.getRecentShasFrom(this.mergeBase, 5)), - ])); - - if (!archive) { - return; - } - const outdatedOutDirs = ( await concurrentMap(100, this.outDirs, async (outDir) => ({ path: outDir, - outdated: !(await matchMergeBase(outDir, archive.sha)), + outdated: !(await matchMergeBase(outDir, this.mergeBase)), })) ) .filter((o) => o.outdated) .map((o) => o.path); if (!outdatedOutDirs.length) { - this.log.debug('all outDirs have the most recent cache'); + this.log.debug('all outDirs have a recent cache'); + return; + } + + const archive = + this.archives.get(this.mergeBase) ?? + (await this.archives.getFirstAvailable([ + this.mergeBase, + ...(await this.repo.getRecentShasFrom(this.mergeBase, 5)), + ])); + + if (!archive) { return; } @@ -132,7 +132,7 @@ export class RefOutputCache { this.log.debug(`[${relative}] clearing outDir and replacing with cache`); await del(outDir); await unzip(Path.resolve(tmpDir, cacheName), outDir); - await Fs.writeFile(Path.resolve(outDir, OUTDIR_MERGE_BASE_FILENAME), archive.sha); + await Fs.writeFile(Path.resolve(outDir, OUTDIR_MERGE_BASE_FILENAME), this.mergeBase); }); } diff --git a/src/plugins/data/kibana.json b/src/plugins/data/kibana.json index a09ab12f0c6f0..452b081d6387f 100644 --- a/src/plugins/data/kibana.json +++ b/src/plugins/data/kibana.json @@ -10,6 +10,7 @@ "share", "inspector" ], + "serviceFolders": ["search", "index_patterns", "query", "autocomplete", "ui", "field_formats"], "optionalPlugins": ["usageCollection"], "extraPublicDirs": ["common"], "requiredBundles": [ diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 05e5c8577ebe3..884873f4535cc 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1531,6 +1531,7 @@ export type IndexPatternSelectProps = Required, 'isLo indexPatternId: string; fieldTypes?: string[]; onNoIndexPatterns?: () => void; + maxIndexPatterns?: number; }; // Warning: (ae-missing-release-tag) "IndexPatternSpec" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx b/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx index 96e7a6d83d2d2..aa36323d11bcc 100644 --- a/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx +++ b/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx @@ -25,6 +25,7 @@ export type IndexPatternSelectProps = Required< indexPatternId: string; fieldTypes?: string[]; onNoIndexPatterns?: () => void; + maxIndexPatterns?: number; }; export type IndexPatternSelectInternalProps = IndexPatternSelectProps & { @@ -41,6 +42,10 @@ interface IndexPatternSelectState { // Needed for React.lazy // eslint-disable-next-line import/no-default-export export default class IndexPatternSelect extends Component { + static defaultProps: { + maxIndexPatterns: 1000; + }; + private isMounted: boolean = false; state: IndexPatternSelectState; @@ -103,7 +108,10 @@ export default class IndexPatternSelect extends Component { const { fieldTypes, onNoIndexPatterns, indexPatternService } = this.props; - const indexPatterns = await indexPatternService.find(`${searchValue}*`, 100); + const indexPatterns = await indexPatternService.find( + `${searchValue}*`, + this.props.maxIndexPatterns + ); // We need this check to handle the case where search results come back in a different // order than they were sent out. Only load results for the most recent search. diff --git a/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts b/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts index 5517011f05718..8c7e48f173031 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts @@ -59,6 +59,7 @@ export function isFieldFiltered( const scriptedOrMissing = !filterState.missing || field.type === '_source' || + field.type === 'unknown_selected' || field.scripted || fieldCounts[field.name] > 0; const needle = filterState.name ? filterState.name.toLowerCase() : ''; diff --git a/src/plugins/discover/public/application/components/sidebar/lib/group_fields.test.ts b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.test.ts index 9792e98ba84c7..68099fb0c8e2a 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/group_fields.test.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.test.ts @@ -217,6 +217,20 @@ describe('group_fields', function () { ]); }); + it('should filter fields by a given name', function () { + const fieldFilterState = { ...getDefaultFieldFilter(), ...{ name: 'curr' } }; + + const actual1 = groupFields( + fields as IndexPatternField[], + ['customer_birth_date', 'currency', 'unknown'], + 5, + fieldCounts, + fieldFilterState, + false + ); + expect(actual1.selected.map((field) => field.name)).toEqual(['currency']); + }); + it('excludes unmapped fields if showUnmappedFields set to false', function () { const fieldFilterState = getDefaultFieldFilter(); const fieldsWithUnmappedField = [...fields]; diff --git a/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx index eefb96b78aac6..dc6cbcedc8086 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx +++ b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx @@ -71,10 +71,18 @@ export function groupFields( } } } - // add columns, that are not part of the index pattern, to be removeable + // add selected columns, that are not part of the index pattern, to be removeable for (const column of columns) { - if (!result.selected.find((field) => field.name === column)) { - result.selected.push({ name: column, displayName: column } as IndexPatternField); + const tmpField = { + name: column, + displayName: column, + type: 'unknown_selected', + } as IndexPatternField; + if ( + !result.selected.find((field) => field.name === column) && + isFieldFiltered(tmpField, fieldFilterState, fieldCounts) + ) { + result.selected.push(tmpField); } } result.selected.sort((a, b) => { diff --git a/src/plugins/discover/public/kibana_services.ts b/src/plugins/discover/public/kibana_services.ts index ba5ca5efad8c2..27bcc00234939 100644 --- a/src/plugins/discover/public/kibana_services.ts +++ b/src/plugins/discover/public/kibana_services.ts @@ -59,10 +59,17 @@ export const [getUrlTracker, setUrlTracker] = createGetterSetter<{ export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter( 'DocViewsRegistry' ); + /** * Makes sure discover and context are using one instance of history. */ -export const getHistory = _.once(() => createHashHistory()); +export const getHistory = _.once(() => { + const history = createHashHistory(); + history.listen(() => { + // keep at least one listener so that `history.location` always in sync + }); + return history; +}); /** * Discover currently uses two `history` instances: one from Kibana Platform and diff --git a/src/plugins/expressions/common/expression_functions/specs/index.ts b/src/plugins/expressions/common/expression_functions/specs/index.ts index 938fce026027c..9408b3a433712 100644 --- a/src/plugins/expressions/common/expression_functions/specs/index.ts +++ b/src/plugins/expressions/common/expression_functions/specs/index.ts @@ -15,6 +15,8 @@ import { theme } from './theme'; import { cumulativeSum } from './cumulative_sum'; import { derivative } from './derivative'; import { movingAverage } from './moving_average'; +import { mapColumn } from './map_column'; +import { math } from './math'; export const functionSpecs: AnyExpressionFunctionDefinition[] = [ clog, @@ -25,6 +27,8 @@ export const functionSpecs: AnyExpressionFunctionDefinition[] = [ cumulativeSum, derivative, movingAverage, + mapColumn, + math, ]; export * from './clog'; @@ -35,3 +39,5 @@ export * from './theme'; export * from './cumulative_sum'; export * from './derivative'; export * from './moving_average'; +export { mapColumn, MapColumnArguments } from './map_column'; +export { math, MathArguments, MathInput } from './math'; diff --git a/src/plugins/expressions/common/expression_functions/specs/map_column.ts b/src/plugins/expressions/common/expression_functions/specs/map_column.ts new file mode 100644 index 0000000000000..e2605e5ddf38d --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/map_column.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../types'; +import { Datatable, getType } from '../../expression_types'; + +export interface MapColumnArguments { + id?: string | null; + name: string; + expression?: (datatable: Datatable) => Promise; + copyMetaFrom?: string | null; +} + +export const mapColumn: ExpressionFunctionDefinition< + 'mapColumn', + Datatable, + MapColumnArguments, + Promise +> = { + name: 'mapColumn', + aliases: ['mc'], // midnight commander. So many times I've launched midnight commander instead of moving a file. + type: 'datatable', + inputTypes: ['datatable'], + help: i18n.translate('expressions.functions.mapColumnHelpText', { + defaultMessage: + 'Adds a column calculated as the result of other columns. ' + + 'Changes are made only when you provide arguments.' + + 'See also {alterColumnFn} and {staticColumnFn}.', + values: { + alterColumnFn: '`alterColumn`', + staticColumnFn: '`staticColumn`', + }, + }), + args: { + id: { + types: ['string', 'null'], + help: i18n.translate('expressions.functions.mapColumn.args.idHelpText', { + defaultMessage: + 'An optional id of the resulting column. When `null` the name/column argument is used as id.', + }), + required: false, + default: null, + }, + name: { + types: ['string'], + aliases: ['_', 'column'], + help: i18n.translate('expressions.functions.mapColumn.args.nameHelpText', { + defaultMessage: 'The name of the resulting column.', + }), + required: true, + }, + expression: { + types: ['boolean', 'number', 'string', 'null'], + resolve: false, + aliases: ['exp', 'fn', 'function'], + help: i18n.translate('expressions.functions.mapColumn.args.expressionHelpText', { + defaultMessage: + 'An expression that is executed on every row, provided with a single-row {DATATABLE} context and returning the cell value.', + values: { + DATATABLE: '`datatable`', + }, + }), + required: true, + }, + copyMetaFrom: { + types: ['string', 'null'], + help: i18n.translate('expressions.functions.mapColumn.args.copyMetaFromHelpText', { + defaultMessage: + "If set, the meta object from the specified column id is copied over to the specified target column. If the column doesn't exist it silently fails.", + }), + required: false, + default: null, + }, + }, + fn: (input, args) => { + const expression = args.expression || (() => Promise.resolve(null)); + const columnId = args.id != null ? args.id : args.name; + + const columns = [...input.columns]; + const rowPromises = input.rows.map((row) => { + return expression({ + type: 'datatable', + columns, + rows: [row], + }).then((val) => ({ + ...row, + [columnId]: val, + })); + }); + + return Promise.all(rowPromises).then((rows) => { + const existingColumnIndex = columns.findIndex(({ name }) => name === args.name); + const type = rows.length ? getType(rows[0][columnId]) : 'null'; + const newColumn = { + id: columnId, + name: args.name, + meta: { type }, + }; + if (args.copyMetaFrom) { + const metaSourceFrom = columns.find(({ id }) => id === args.copyMetaFrom); + newColumn.meta = { ...newColumn.meta, ...(metaSourceFrom?.meta || {}) }; + } + + if (existingColumnIndex === -1) { + columns.push(newColumn); + } else { + columns[existingColumnIndex] = newColumn; + } + + return { + type: 'datatable', + columns, + rows, + } as Datatable; + }); + }, +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/math.ts b/src/plugins/expressions/common/expression_functions/specs/math.ts new file mode 100644 index 0000000000000..a70c032769b57 --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/math.ts @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { map, zipObject } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { evaluate } from '@kbn/tinymath'; +import { ExpressionFunctionDefinition } from '../types'; +import { Datatable, isDatatable } from '../../expression_types'; + +export type MathArguments = { + expression: string; + onError?: 'null' | 'zero' | 'false' | 'throw'; +}; + +export type MathInput = number | Datatable; + +const TINYMATH = '`TinyMath`'; +const TINYMATH_URL = + 'https://www.elastic.co/guide/en/kibana/current/canvas-tinymath-functions.html'; + +const isString = (val: any): boolean => typeof val === 'string'; + +function pivotObjectArray< + RowType extends { [key: string]: any }, + ReturnColumns extends string | number | symbol = keyof RowType +>(rows: RowType[], columns?: string[]): Record { + const columnNames = columns || Object.keys(rows[0]); + if (!columnNames.every(isString)) { + throw new Error('Columns should be an array of strings'); + } + + const columnValues = map(columnNames, (name) => map(rows, name)); + return zipObject(columnNames, columnValues); +} + +export const errors = { + emptyExpression: () => + new Error( + i18n.translate('expressions.functions.math.emptyExpressionErrorMessage', { + defaultMessage: 'Empty expression', + }) + ), + tooManyResults: () => + new Error( + i18n.translate('expressions.functions.math.tooManyResultsErrorMessage', { + defaultMessage: + 'Expressions must return a single number. Try wrapping your expression in {mean} or {sum}', + values: { + mean: 'mean()', + sum: 'sum()', + }, + }) + ), + executionFailed: () => + new Error( + i18n.translate('expressions.functions.math.executionFailedErrorMessage', { + defaultMessage: 'Failed to execute math expression. Check your column names', + }) + ), + emptyDatatable: () => + new Error( + i18n.translate('expressions.functions.math.emptyDatatableErrorMessage', { + defaultMessage: 'Empty datatable', + }) + ), +}; + +const fallbackValue = { + null: null, + zero: 0, + false: false, +} as const; + +export const math: ExpressionFunctionDefinition< + 'math', + MathInput, + MathArguments, + boolean | number | null +> = { + name: 'math', + type: undefined, + inputTypes: ['number', 'datatable'], + help: i18n.translate('expressions.functions.mathHelpText', { + defaultMessage: + 'Interprets a {TINYMATH} math expression using a {TYPE_NUMBER} or {DATATABLE} as {CONTEXT}. ' + + 'The {DATATABLE} columns are available by their column name. ' + + 'If the {CONTEXT} is a number it is available as {value}.', + values: { + TINYMATH, + CONTEXT: '_context_', + DATATABLE: '`datatable`', + value: '`value`', + TYPE_NUMBER: '`number`', + }, + }), + args: { + expression: { + aliases: ['_'], + types: ['string'], + help: i18n.translate('expressions.functions.math.args.expressionHelpText', { + defaultMessage: 'An evaluated {TINYMATH} expression. See {TINYMATH_URL}.', + values: { + TINYMATH, + TINYMATH_URL, + }, + }), + }, + onError: { + types: ['string'], + options: ['throw', 'false', 'zero', 'null'], + help: i18n.translate('expressions.functions.math.args.onErrorHelpText', { + defaultMessage: + "In case the {TINYMATH} evaluation fails or returns NaN, the return value is specified by onError. When `'throw'`, it will throw an exception, terminating expression execution (default).", + values: { + TINYMATH, + }, + }), + }, + }, + fn: (input, args) => { + const { expression, onError } = args; + const onErrorValue = onError ?? 'throw'; + + if (!expression || expression.trim() === '') { + throw errors.emptyExpression(); + } + + const mathContext = isDatatable(input) + ? pivotObjectArray( + input.rows, + input.columns.map((col) => col.name) + ) + : { value: input }; + + try { + const result = evaluate(expression, mathContext); + if (Array.isArray(result)) { + if (result.length === 1) { + return result[0]; + } + throw errors.tooManyResults(); + } + if (isNaN(result)) { + // make TS happy + if (onErrorValue !== 'throw' && onErrorValue in fallbackValue) { + return fallbackValue[onErrorValue]; + } + throw errors.executionFailed(); + } + return result; + } catch (e) { + if (onErrorValue !== 'throw' && onErrorValue in fallbackValue) { + return fallbackValue[onErrorValue]; + } + if (isDatatable(input) && input.rows.length === 0) { + throw errors.emptyDatatable(); + } else { + throw e; + } + } + }, +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/map_column.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/map_column.test.ts new file mode 100644 index 0000000000000..6b0dce4ff9a2a --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/tests/map_column.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Datatable } from '../../../expression_types'; +import { mapColumn, MapColumnArguments } from '../map_column'; +import { emptyTable, functionWrapper, testTable } from './utils'; + +const pricePlusTwo = (datatable: Datatable) => Promise.resolve(datatable.rows[0].price + 2); + +describe('mapColumn', () => { + const fn = functionWrapper(mapColumn); + const runFn = (input: Datatable, args: MapColumnArguments) => + fn(input, args) as Promise; + + it('returns a datatable with a new column with the values from mapping a function over each row in a datatable', () => { + return runFn(testTable, { + id: 'pricePlusTwo', + name: 'pricePlusTwo', + expression: pricePlusTwo, + }).then((result) => { + const arbitraryRowIndex = 2; + + expect(result.type).toBe('datatable'); + expect(result.columns).toEqual([ + ...testTable.columns, + { id: 'pricePlusTwo', name: 'pricePlusTwo', meta: { type: 'number' } }, + ]); + expect(result.columns[result.columns.length - 1]).toHaveProperty('name', 'pricePlusTwo'); + expect(result.rows[arbitraryRowIndex]).toHaveProperty('pricePlusTwo'); + }); + }); + + it('overwrites existing column with the new column if an existing column name is provided', () => { + return runFn(testTable, { name: 'name', expression: pricePlusTwo }).then((result) => { + const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); + const arbitraryRowIndex = 4; + + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(testTable.columns.length); + expect(result.columns[nameColumnIndex]).toHaveProperty('name', 'name'); + expect(result.columns[nameColumnIndex].meta).toHaveProperty('type', 'number'); + expect(result.rows[arbitraryRowIndex]).toHaveProperty('name', 202); + }); + }); + + it('adds a column to empty tables', () => { + return runFn(emptyTable, { name: 'name', expression: pricePlusTwo }).then((result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'name'); + expect(result.columns[0].meta).toHaveProperty('type', 'null'); + }); + }); + + it('should assign specific id, different from name, when id arg is passed for new columns', () => { + return runFn(emptyTable, { name: 'name', id: 'myid', expression: pricePlusTwo }).then( + (result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'name'); + expect(result.columns[0]).toHaveProperty('id', 'myid'); + expect(result.columns[0].meta).toHaveProperty('type', 'null'); + } + ); + }); + + it('should assign specific id, different from name, when id arg is passed for copied column', () => { + return runFn(testTable, { name: 'name', id: 'myid', expression: pricePlusTwo }).then( + (result) => { + const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); + expect(result.type).toBe('datatable'); + expect(result.columns[nameColumnIndex]).toEqual({ + id: 'myid', + name: 'name', + meta: { type: 'number' }, + }); + } + ); + }); + + it('should copy over the meta information from the specified column', () => { + return runFn( + { + ...testTable, + columns: [ + ...testTable.columns, + // add a new entry + { + id: 'myId', + name: 'myName', + meta: { type: 'date', params: { id: 'number', params: { digits: 2 } } }, + }, + ], + rows: testTable.rows.map((row) => ({ ...row, myId: Date.now() })), + }, + { name: 'name', copyMetaFrom: 'myId', expression: pricePlusTwo } + ).then((result) => { + const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); + expect(result.type).toBe('datatable'); + expect(result.columns[nameColumnIndex]).toEqual({ + id: 'name', + name: 'name', + meta: { type: 'date', params: { id: 'number', params: { digits: 2 } } }, + }); + }); + }); + + it('should be resilient if the references column for meta information does not exists', () => { + return runFn(emptyTable, { name: 'name', copyMetaFrom: 'time', expression: pricePlusTwo }).then( + (result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'name'); + expect(result.columns[0]).toHaveProperty('id', 'name'); + expect(result.columns[0].meta).toHaveProperty('type', 'null'); + } + ); + }); + + it('should correctly infer the type fromt he first row if the references column for meta information does not exists', () => { + return runFn( + { ...emptyTable, rows: [...emptyTable.rows, { value: 5 }] }, + { name: 'value', copyMetaFrom: 'time', expression: pricePlusTwo } + ).then((result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'value'); + expect(result.columns[0]).toHaveProperty('id', 'value'); + expect(result.columns[0].meta).toHaveProperty('type', 'number'); + }); + }); + + describe('expression', () => { + it('maps null values to the new column', () => { + return runFn(testTable, { name: 'empty' }).then((result) => { + const emptyColumnIndex = result.columns.findIndex(({ name }) => name === 'empty'); + const arbitraryRowIndex = 8; + + expect(result.columns[emptyColumnIndex]).toHaveProperty('name', 'empty'); + expect(result.columns[emptyColumnIndex].meta).toHaveProperty('type', 'null'); + expect(result.rows[arbitraryRowIndex]).toHaveProperty('empty', null); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.test.js b/src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts similarity index 63% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.test.js rename to src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts index f5b8123ab8568..7541852cdbdaf 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.test.js +++ b/src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts @@ -1,19 +1,16 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; -import { getFunctionErrors } from '../../../i18n'; -import { emptyTable, testTable } from './__fixtures__/test_tables'; -import { math } from './math'; - -const errors = getFunctionErrors().math; +import { errors, math } from '../math'; +import { emptyTable, functionWrapper, testTable } from './utils'; describe('math', () => { - const fn = functionWrapper(math); + const fn = functionWrapper(math); it('evaluates math expressions without reference to context', () => { expect(fn(null, { expression: '10.5345' })).toBe(10.5345); @@ -48,6 +45,19 @@ describe('math', () => { expect(fn(testTable, { expression: 'count(name)' })).toBe(9); }); }); + + describe('onError', () => { + it('should return the desired fallback value, for invalid expressions', () => { + expect(fn(testTable, { expression: 'mean(name)', onError: 'zero' })).toBe(0); + expect(fn(testTable, { expression: 'mean(name)', onError: 'null' })).toBe(null); + expect(fn(testTable, { expression: 'mean(name)', onError: 'false' })).toBe(false); + }); + it('should return the desired fallback value, for division by zero', () => { + expect(fn(testTable, { expression: '1/0', onError: 'zero' })).toBe(0); + expect(fn(testTable, { expression: '1/0', onError: 'null' })).toBe(null); + expect(fn(testTable, { expression: '1/0', onError: 'false' })).toBe(false); + }); + }); }); describe('invalid expressions', () => { @@ -88,5 +98,23 @@ describe('math', () => { new RegExp(errors.emptyDatatable().message) ); }); + + it('should not throw when requesting fallback values for invalid expression', () => { + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'zero' })).not.toThrow(); + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'false' })).not.toThrow(); + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'null' })).not.toThrow(); + }); + + it('should throw when declared in the onError argument', () => { + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'throw' })).toThrow( + new RegExp(errors.executionFailed().message) + ); + }); + + it('should throw when dividing by zero', () => { + expect(() => fn(testTable, { expression: '1/0', onError: 'throw' })).toThrow( + new RegExp('Cannot divide by 0') + ); + }); }); }); diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts b/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts index 9006de9067616..7369570cf2c4b 100644 --- a/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts +++ b/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts @@ -9,16 +9,219 @@ import { mapValues } from 'lodash'; import { AnyExpressionFunctionDefinition } from '../../types'; import { ExecutionContext } from '../../../execution/types'; +import { Datatable } from '../../../expression_types'; /** * Takes a function spec and passes in default args, * overriding with any provided args. */ -export const functionWrapper = (spec: AnyExpressionFunctionDefinition) => { +export const functionWrapper = ( + spec: AnyExpressionFunctionDefinition +) => { const defaultArgs = mapValues(spec.args, (argSpec) => argSpec.default); return ( - context: object | null, + context: ContextType, args: Record = {}, handlers: ExecutionContext = {} as ExecutionContext ) => spec.fn(context, { ...defaultArgs, ...args }, handlers); }; + +const emptyTable: Datatable = { + type: 'datatable', + columns: [], + rows: [], +}; + +const testTable: Datatable = { + type: 'datatable', + columns: [ + { + id: 'name', + name: 'name', + meta: { type: 'string' }, + }, + { + id: 'time', + name: 'time', + meta: { type: 'date' }, + }, + { + id: 'price', + name: 'price', + meta: { type: 'number' }, + }, + { + id: 'quantity', + name: 'quantity', + meta: { type: 'number' }, + }, + { + id: 'in_stock', + name: 'in_stock', + meta: { type: 'boolean' }, + }, + ], + rows: [ + { + name: 'product1', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 605, + quantity: 100, + in_stock: true, + }, + { + name: 'product1', + time: 1517929200950, // 06 Feb 2018 15:00:00 GMT + price: 583, + quantity: 200, + in_stock: true, + }, + { + name: 'product1', + time: 1518015600950, // 07 Feb 2018 15:00:00 GMT + price: 420, + quantity: 300, + in_stock: true, + }, + { + name: 'product2', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 216, + quantity: 350, + in_stock: false, + }, + { + name: 'product2', + time: 1517929200950, // 06 Feb 2018 15:00:00 GMT + price: 200, + quantity: 256, + in_stock: false, + }, + { + name: 'product2', + time: 1518015600950, // 07 Feb 2018 15:00:00 GMT + price: 190, + quantity: 231, + in_stock: false, + }, + { + name: 'product3', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 67, + quantity: 240, + in_stock: true, + }, + { + name: 'product4', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 311, + quantity: 447, + in_stock: false, + }, + { + name: 'product5', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 288, + quantity: 384, + in_stock: true, + }, + ], +}; + +const stringTable: Datatable = { + type: 'datatable', + columns: [ + { + id: 'name', + name: 'name', + meta: { type: 'string' }, + }, + { + id: 'time', + name: 'time', + meta: { type: 'string' }, + }, + { + id: 'price', + name: 'price', + meta: { type: 'string' }, + }, + { + id: 'quantity', + name: 'quantity', + meta: { type: 'string' }, + }, + { + id: 'in_stock', + name: 'in_stock', + meta: { type: 'string' }, + }, + ], + rows: [ + { + name: 'product1', + time: '2018-02-05T15:00:00.950Z', + price: '605', + quantity: '100', + in_stock: 'true', + }, + { + name: 'product1', + time: '2018-02-06T15:00:00.950Z', + price: '583', + quantity: '200', + in_stock: 'true', + }, + { + name: 'product1', + time: '2018-02-07T15:00:00.950Z', + price: '420', + quantity: '300', + in_stock: 'true', + }, + { + name: 'product2', + time: '2018-02-05T15:00:00.950Z', + price: '216', + quantity: '350', + in_stock: 'false', + }, + { + name: 'product2', + time: '2018-02-06T15:00:00.950Z', + price: '200', + quantity: '256', + in_stock: 'false', + }, + { + name: 'product2', + time: '2018-02-07T15:00:00.950Z', + price: '190', + quantity: '231', + in_stock: 'false', + }, + { + name: 'product3', + time: '2018-02-05T15:00:00.950Z', + price: '67', + quantity: '240', + in_stock: 'true', + }, + { + name: 'product4', + time: '2018-02-05T15:00:00.950Z', + price: '311', + quantity: '447', + in_stock: 'false', + }, + { + name: 'product5', + time: '2018-02-05T15:00:00.950Z', + price: '288', + quantity: '384', + in_stock: 'true', + }, + ], +}; + +export { emptyTable, testTable, stringTable }; diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index a7a5b8626914a..349e024f31c31 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -9,7 +9,9 @@ import { i18n } from '@kbn/i18n'; import { SavedObjectMetaData, OnSaveProps } from 'src/plugins/saved_objects/public'; import { first } from 'rxjs/operators'; +import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; import { SavedObjectAttributes } from '../../../../core/public'; +import { extractSearchSourceReferences } from '../../../data/public'; import { EmbeddableFactoryDefinition, EmbeddableOutput, @@ -236,4 +238,42 @@ export class VisualizeEmbeddableFactory } ); } + + public extract(_state: EmbeddableStateWithType) { + const state = (_state as unknown) as VisualizeInput; + const references = []; + + if (state.savedVis?.data.searchSource) { + const [, searchSourceReferences] = extractSearchSourceReferences( + state.savedVis.data.searchSource + ); + + references.push(...searchSourceReferences); + } + + if (state.savedVis?.data.savedSearchId) { + references.push({ + name: 'search_0', + type: 'search', + id: String(state.savedVis.data.savedSearchId), + }); + } + + if (state.savedVis?.params.controls) { + const controls = state.savedVis.params.controls; + controls.forEach((control: Record, i: number) => { + if (!control.indexPattern) { + return; + } + control.indexPatternRefName = `control_${i}_index_pattern`; + references.push({ + name: control.indexPatternRefName, + type: 'index-pattern', + id: control.indexPattern, + }); + }); + } + + return { state: _state, references }; + } } diff --git a/test/functional/apps/context/_discover_navigation.js b/test/functional/apps/context/_discover_navigation.js index 403f85273fcf1..6152659e47f27 100644 --- a/test/functional/apps/context/_discover_navigation.js +++ b/test/functional/apps/context/_discover_navigation.js @@ -18,7 +18,8 @@ export default function ({ getService, getPageObjects }) { const retry = getService('retry'); const docTable = getService('docTable'); const filterBar = getService('filterBar'); - const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'context']); + const testSubjects = getService('testSubjects'); describe('context link in discover', () => { before(async () => { @@ -69,5 +70,29 @@ export default function ({ getService, getPageObjects }) { } expect(disabledFilterCounter).to.be(TEST_FILTER_COLUMN_NAMES.length); }); + + // bugfix: https://github.com/elastic/kibana/issues/92099 + it('should navigate to the first document and then back to discover', async () => { + await PageObjects.context.waitUntilContextLoadingHasFinished(); + + // navigate to the doc view + await docTable.clickRowToggle({ rowIndex: 0 }); + + // click the open action + await retry.try(async () => { + const rowActions = await docTable.getRowActions({ rowIndex: 0 }); + if (!rowActions.length) { + throw new Error('row actions empty, trying again'); + } + await rowActions[1].click(); + }); + + const hasDocHit = await testSubjects.exists('doc-hit'); + expect(hasDocHit).to.be(true); + + await testSubjects.click('breadcrumb first'); + await PageObjects.discover.waitForDiscoverAppOnScreen(); + await PageObjects.discover.waitForDocTableLoadingComplete(); + }); }); } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 048bc3468f149..5c4d1d55cff04 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -34,8 +34,6 @@ import { joinRows } from './join_rows'; import { lt } from './lt'; import { lte } from './lte'; import { mapCenter } from './map_center'; -import { mapColumn } from './mapColumn'; -import { math } from './math'; import { metric } from './metric'; import { neq } from './neq'; import { ply } from './ply'; @@ -89,8 +87,6 @@ export const functions = [ lte, joinRows, mapCenter, - mapColumn, - math, metric, neq, ply, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js deleted file mode 100644 index d511c7774122d..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { functionWrapper } from '../../../test_helpers/function_wrapper'; -import { testTable, emptyTable } from './__fixtures__/test_tables'; -import { mapColumn } from './mapColumn'; - -const pricePlusTwo = (datatable) => Promise.resolve(datatable.rows[0].price + 2); - -describe('mapColumn', () => { - const fn = functionWrapper(mapColumn); - - it('returns a datatable with a new column with the values from mapping a function over each row in a datatable', () => { - return fn(testTable, { - id: 'pricePlusTwo', - name: 'pricePlusTwo', - expression: pricePlusTwo, - }).then((result) => { - const arbitraryRowIndex = 2; - - expect(result.type).toBe('datatable'); - expect(result.columns).toEqual([ - ...testTable.columns, - { id: 'pricePlusTwo', name: 'pricePlusTwo', meta: { type: 'number' } }, - ]); - expect(result.columns[result.columns.length - 1]).toHaveProperty('name', 'pricePlusTwo'); - expect(result.rows[arbitraryRowIndex]).toHaveProperty('pricePlusTwo'); - }); - }); - - it('overwrites existing column with the new column if an existing column name is provided', () => { - return fn(testTable, { name: 'name', expression: pricePlusTwo }).then((result) => { - const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); - const arbitraryRowIndex = 4; - - expect(result.type).toBe('datatable'); - expect(result.columns).toHaveLength(testTable.columns.length); - expect(result.columns[nameColumnIndex]).toHaveProperty('name', 'name'); - expect(result.columns[nameColumnIndex].meta).toHaveProperty('type', 'number'); - expect(result.rows[arbitraryRowIndex]).toHaveProperty('name', 202); - }); - }); - - it('adds a column to empty tables', () => { - return fn(emptyTable, { name: 'name', expression: pricePlusTwo }).then((result) => { - expect(result.type).toBe('datatable'); - expect(result.columns).toHaveLength(1); - expect(result.columns[0]).toHaveProperty('name', 'name'); - expect(result.columns[0].meta).toHaveProperty('type', 'null'); - }); - }); - - describe('expression', () => { - it('maps null values to the new column', () => { - return fn(testTable, { name: 'empty' }).then((result) => { - const emptyColumnIndex = result.columns.findIndex(({ name }) => name === 'empty'); - const arbitraryRowIndex = 8; - - expect(result.columns[emptyColumnIndex]).toHaveProperty('name', 'empty'); - expect(result.columns[emptyColumnIndex].meta).toHaveProperty('type', 'null'); - expect(result.rows[arbitraryRowIndex]).toHaveProperty('empty', null); - }); - }); - }); -}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts deleted file mode 100644 index 63cc0d6cbc687..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Datatable, ExpressionFunctionDefinition, getType } from '../../../types'; -import { getFunctionHelp } from '../../../i18n'; - -interface Arguments { - name: string; - expression: (datatable: Datatable) => Promise; -} - -export function mapColumn(): ExpressionFunctionDefinition< - 'mapColumn', - Datatable, - Arguments, - Promise -> { - const { help, args: argHelp } = getFunctionHelp().mapColumn; - - return { - name: 'mapColumn', - aliases: ['mc'], // midnight commander. So many times I've launched midnight commander instead of moving a file. - type: 'datatable', - inputTypes: ['datatable'], - help, - args: { - name: { - types: ['string'], - aliases: ['_', 'column'], - help: argHelp.name, - required: true, - }, - expression: { - types: ['boolean', 'number', 'string', 'null'], - resolve: false, - aliases: ['exp', 'fn', 'function'], - help: argHelp.expression, - required: true, - }, - }, - fn: (input, args) => { - const expression = args.expression || (() => Promise.resolve(null)); - - const columns = [...input.columns]; - const rowPromises = input.rows.map((row) => { - return expression({ - type: 'datatable', - columns, - rows: [row], - }).then((val) => ({ - ...row, - [args.name]: val, - })); - }); - - return Promise.all(rowPromises).then((rows) => { - const existingColumnIndex = columns.findIndex(({ name }) => name === args.name); - const type = rows.length ? getType(rows[0][args.name]) : 'null'; - const newColumn = { id: args.name, name: args.name, meta: { type } }; - - if (existingColumnIndex === -1) { - columns.push(newColumn); - } else { - columns[existingColumnIndex] = newColumn; - } - - return { - type: 'datatable', - columns, - rows, - } as Datatable; - }); - }, - }; -} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts deleted file mode 100644 index af70fa729b7da..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { evaluate } from '@kbn/tinymath'; -import { pivotObjectArray } from '../../../common/lib/pivot_object_array'; -import { Datatable, isDatatable, ExpressionFunctionDefinition } from '../../../types'; -import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; - -interface Arguments { - expression: string; -} - -type Input = number | Datatable; - -export function math(): ExpressionFunctionDefinition<'math', Input, Arguments, number> { - const { help, args: argHelp } = getFunctionHelp().math; - const errors = getFunctionErrors().math; - - return { - name: 'math', - type: 'number', - inputTypes: ['number', 'datatable'], - help, - args: { - expression: { - aliases: ['_'], - types: ['string'], - help: argHelp.expression, - }, - }, - fn: (input, args) => { - const { expression } = args; - - if (!expression || expression.trim() === '') { - throw errors.emptyExpression(); - } - - const mathContext = isDatatable(input) - ? pivotObjectArray( - input.rows, - input.columns.map((col) => col.name) - ) - : { value: input }; - - try { - const result = evaluate(expression, mathContext); - if (Array.isArray(result)) { - if (result.length === 1) { - return result[0]; - } - throw errors.tooManyResults(); - } - if (isNaN(result)) { - throw errors.executionFailed(); - } - return result; - } catch (e) { - if (isDatatable(input) && input.rows.length === 0) { - throw errors.emptyDatatable(); - } else { - throw e; - } - } - }, - }; -} diff --git a/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts b/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts deleted file mode 100644 index f8d0311d08961..0000000000000 --- a/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { mapColumn } from '../../../canvas_plugin_src/functions/common/mapColumn'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; -import { CANVAS, DATATABLE } from '../../constants'; - -export const help: FunctionHelp> = { - help: i18n.translate('xpack.canvas.functions.mapColumnHelpText', { - defaultMessage: - 'Adds a column calculated as the result of other columns. ' + - 'Changes are made only when you provide arguments.' + - 'See also {alterColumnFn} and {staticColumnFn}.', - values: { - alterColumnFn: '`alterColumn`', - staticColumnFn: '`staticColumn`', - }, - }), - args: { - name: i18n.translate('xpack.canvas.functions.mapColumn.args.nameHelpText', { - defaultMessage: 'The name of the resulting column.', - }), - expression: i18n.translate('xpack.canvas.functions.mapColumn.args.expressionHelpText', { - defaultMessage: - 'A {CANVAS} expression that is passed to each row as a single row {DATATABLE}.', - values: { - CANVAS, - DATATABLE, - }, - }), - }, -}; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/math.ts b/x-pack/plugins/canvas/i18n/functions/dict/math.ts deleted file mode 100644 index 110136872e1ff..0000000000000 --- a/x-pack/plugins/canvas/i18n/functions/dict/math.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { math } from '../../../canvas_plugin_src/functions/common/math'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; -import { DATATABLE, CONTEXT, TINYMATH, TINYMATH_URL, TYPE_NUMBER } from '../../constants'; - -export const help: FunctionHelp> = { - help: i18n.translate('xpack.canvas.functions.mathHelpText', { - defaultMessage: - 'Interprets a {TINYMATH} math expression using a {TYPE_NUMBER} or {DATATABLE} as {CONTEXT}. ' + - 'The {DATATABLE} columns are available by their column name. ' + - 'If the {CONTEXT} is a number it is available as {value}.', - values: { - TINYMATH, - CONTEXT, - DATATABLE, - value: '`value`', - TYPE_NUMBER, - }, - }), - args: { - expression: i18n.translate('xpack.canvas.functions.math.args.expressionHelpText', { - defaultMessage: 'An evaluated {TINYMATH} expression. See {TINYMATH_URL}.', - values: { - TINYMATH, - TINYMATH_URL, - }, - }), - }, -}; - -export const errors = { - emptyExpression: () => - new Error( - i18n.translate('xpack.canvas.functions.math.emptyExpressionErrorMessage', { - defaultMessage: 'Empty expression', - }) - ), - tooManyResults: () => - new Error( - i18n.translate('xpack.canvas.functions.math.tooManyResultsErrorMessage', { - defaultMessage: - 'Expressions must return a single number. Try wrapping your expression in {mean} or {sum}', - values: { - mean: 'mean()', - sum: 'sum()', - }, - }) - ), - executionFailed: () => - new Error( - i18n.translate('xpack.canvas.functions.math.executionFailedErrorMessage', { - defaultMessage: 'Failed to execute math expression. Check your column names', - }) - ), - emptyDatatable: () => - new Error( - i18n.translate('xpack.canvas.functions.math.emptyDatatableErrorMessage', { - defaultMessage: 'Empty datatable', - }) - ), -}; diff --git a/x-pack/plugins/canvas/i18n/functions/function_errors.ts b/x-pack/plugins/canvas/i18n/functions/function_errors.ts index ac86eb4c4d0e9..4a85018c1b4ac 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_errors.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_errors.ts @@ -16,7 +16,6 @@ import { errors as demodata } from './dict/demodata'; import { errors as getCell } from './dict/get_cell'; import { errors as image } from './dict/image'; import { errors as joinRows } from './dict/join_rows'; -import { errors as math } from './dict/math'; import { errors as ply } from './dict/ply'; import { errors as pointseries } from './dict/pointseries'; import { errors as progress } from './dict/progress'; @@ -36,7 +35,6 @@ export const getFunctionErrors = () => ({ getCell, image, joinRows, - math, ply, pointseries, progress, diff --git a/x-pack/plugins/canvas/i18n/functions/function_help.ts b/x-pack/plugins/canvas/i18n/functions/function_help.ts index 245732e53cc89..512ebc4ff8c93 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_help.ts @@ -46,9 +46,7 @@ import { help as location } from './dict/location'; import { help as lt } from './dict/lt'; import { help as lte } from './dict/lte'; import { help as mapCenter } from './dict/map_center'; -import { help as mapColumn } from './dict/map_column'; import { help as markdown } from './dict/markdown'; -import { help as math } from './dict/math'; import { help as metric } from './dict/metric'; import { help as neq } from './dict/neq'; import { help as pie } from './dict/pie'; @@ -209,9 +207,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ lt, lte, mapCenter, - mapColumn, markdown, - math, metric, neq, pie, diff --git a/x-pack/plugins/case/server/client/cases/mock.ts b/x-pack/plugins/case/server/client/cases/mock.ts index 809c4ad1ea1bd..490519187f49e 100644 --- a/x-pack/plugins/case/server/client/cases/mock.ts +++ b/x-pack/plugins/case/server/client/cases/mock.ts @@ -11,6 +11,7 @@ import { ConnectorMappingsAttributes, CaseUserActionsResponse, AssociationType, + CommentResponseAlertsType, } from '../../../common/api'; import { BasicParams } from './types'; @@ -76,6 +77,20 @@ export const commentAlert: CommentResponse = { version: 'WzEsMV0=', }; +export const commentAlertMultipleIds: CommentResponseAlertsType = { + ...commentAlert, + id: 'mock-comment-2', + alertId: ['alert-id-1', 'alert-id-2'], + index: 'alert-index-1', + type: CommentType.alert as const, +}; + +export const commentGeneratedAlert: CommentResponseAlertsType = { + ...commentAlertMultipleIds, + id: 'mock-comment-3', + type: CommentType.generatedAlert as const, +}; + export const defaultPipes = ['informationCreated']; export const basicParams: BasicParams = { description: 'a description', diff --git a/x-pack/plugins/case/server/client/cases/types.ts b/x-pack/plugins/case/server/client/cases/types.ts index f1d56e7132bd1..2dd2caf9fe73a 100644 --- a/x-pack/plugins/case/server/client/cases/types.ts +++ b/x-pack/plugins/case/server/client/cases/types.ts @@ -72,7 +72,7 @@ export interface TransformFieldsArgs { export interface ExternalServiceComment { comment: string; - commentId: string; + commentId?: string; } export interface MapIncident { diff --git a/x-pack/plugins/case/server/client/cases/utils.test.ts b/x-pack/plugins/case/server/client/cases/utils.test.ts index 361d0fb561afd..44e7a682aa7ed 100644 --- a/x-pack/plugins/case/server/client/cases/utils.test.ts +++ b/x-pack/plugins/case/server/client/cases/utils.test.ts @@ -17,6 +17,8 @@ import { basicParams, userActions, commentAlert, + commentAlertMultipleIds, + commentGeneratedAlert, } from './mock'; import { @@ -48,7 +50,7 @@ describe('utils', () => { { actionType: 'overwrite', key: 'short_description', - pipes: ['informationCreated'], + pipes: [], value: 'a title', }, { @@ -71,7 +73,7 @@ describe('utils', () => { { actionType: 'overwrite', key: 'short_description', - pipes: ['myTestPipe'], + pipes: [], value: 'a title', }, { @@ -98,7 +100,7 @@ describe('utils', () => { }); expect(res).toEqual({ - short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + short_description: 'a title', description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)', }); }); @@ -122,13 +124,13 @@ describe('utils', () => { }, fields, currentIncident: { - short_description: 'first title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + short_description: 'first title', description: 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User)', }, }); expect(res).toEqual({ - short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by Another User)', + short_description: 'a title', description: 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User) \r\na description (updated at 2020-03-15T08:34:53.450Z by Another User)', }); @@ -168,7 +170,7 @@ describe('utils', () => { }); expect(res).toEqual({ - short_description: 'a title (created at 2020-03-13T08:34:53.450Z by elastic)', + short_description: 'a title', description: 'a description (created at 2020-03-13T08:34:53.450Z by elastic)', }); }); @@ -190,7 +192,7 @@ describe('utils', () => { }); expect(res).toEqual({ - short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by anotherUser)', + short_description: 'a title', description: 'a description (updated at 2020-03-15T08:34:53.450Z by anotherUser)', }); }); @@ -448,8 +450,7 @@ describe('utils', () => { labels: ['defacement'], issueType: null, parent: null, - short_description: - 'Super Bad Security Issue (created at 2019-11-25T21:54:48.952Z by elastic)', + short_description: 'Super Bad Security Issue', description: 'This is a brand new case of a bad meanie defacing data (created at 2019-11-25T21:54:48.952Z by elastic)', externalId: null, @@ -504,7 +505,7 @@ describe('utils', () => { expect(res.comments).toEqual([]); }); - it('it creates comments of type alert correctly', async () => { + it('it adds the total alert comments correctly', async () => { const res = await createIncident({ actionsClient: actionsMock, theCase: { @@ -512,7 +513,9 @@ describe('utils', () => { comments: [ { ...commentObj, id: 'comment-user-1' }, { ...commentAlert, id: 'comment-alert-1' }, - { ...commentAlert, id: 'comment-alert-2' }, + { + ...commentAlertMultipleIds, + }, ], }, // Remove second push @@ -536,14 +539,36 @@ describe('utils', () => { commentId: 'comment-user-1', }, { - comment: - 'Alert with ids alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', - commentId: 'comment-alert-1', + comment: 'Elastic Security Alerts attached to the case: 3', }, + ]); + }); + + it('it removes alerts correctly', async () => { + const res = await createIncident({ + actionsClient: actionsMock, + theCase: { + ...theCase, + comments: [ + { ...commentObj, id: 'comment-user-1' }, + commentAlertMultipleIds, + commentGeneratedAlert, + ], + }, + userActions, + connector, + mappings, + alerts: [], + }); + + expect(res.comments).toEqual([ { comment: - 'Alert with ids alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', - commentId: 'comment-alert-2', + 'Wow, good luck catching that bad meanie! (added at 2019-11-25T21:55:00.177Z by elastic)', + commentId: 'comment-user-1', + }, + { + comment: 'Elastic Security Alerts attached to the case: 4', }, ]); }); @@ -578,8 +603,7 @@ describe('utils', () => { description: 'fun description \r\nThis is a brand new case of a bad meanie defacing data (updated at 2019-11-25T21:54:48.952Z by elastic)', externalId: 'external-id', - short_description: - 'Super Bad Security Issue (updated at 2019-11-25T21:54:48.952Z by elastic)', + short_description: 'Super Bad Security Issue', }, comments: [], }); diff --git a/x-pack/plugins/case/server/client/cases/utils.ts b/x-pack/plugins/case/server/client/cases/utils.ts index fda4142bf77c7..a5013d9b93982 100644 --- a/x-pack/plugins/case/server/client/cases/utils.ts +++ b/x-pack/plugins/case/server/client/cases/utils.ts @@ -40,6 +40,15 @@ import { } from './types'; import { getAlertIds } from '../../routes/api/utils'; +interface CreateIncidentArgs { + actionsClient: ActionsClient; + theCase: CaseResponse; + userActions: CaseUserActionsResponse; + connector: ActionConnector; + mappings: ConnectorMappingsAttributes[]; + alerts: CaseClientGetAlertsResponse; +} + export const getLatestPushInfo = ( connectorId: string, userActions: CaseUserActionsResponse @@ -75,14 +84,13 @@ const getCommentContent = (comment: CommentResponse): string => { return ''; }; -interface CreateIncidentArgs { - actionsClient: ActionsClient; - theCase: CaseResponse; - userActions: CaseUserActionsResponse; - connector: ActionConnector; - mappings: ConnectorMappingsAttributes[]; - alerts: CaseClientGetAlertsResponse; -} +const countAlerts = (comments: CaseResponse['comments']): number => + comments?.reduce((total, comment) => { + if (comment.type === CommentType.alert || comment.type === CommentType.generatedAlert) { + return total + (Array.isArray(comment.alertId) ? comment.alertId.length : 1); + } + return total; + }, 0) ?? 0; export const createIncident = async ({ actionsClient, @@ -152,22 +160,34 @@ export const createIncident = async ({ userActions .slice(latestPushInfo?.index ?? 0) .filter( - (action, index) => - Array.isArray(action.action_field) && action.action_field[0] === 'comment' + (action) => Array.isArray(action.action_field) && action.action_field[0] === 'comment' ) .map((action) => action.comment_id) ); - const commentsToBeUpdated = caseComments?.filter((comment) => - commentsIdsToBeUpdated.has(comment.id) + + const commentsToBeUpdated = caseComments?.filter( + (comment) => + // We push only user's comments + comment.type === CommentType.user && commentsIdsToBeUpdated.has(comment.id) ); + const totalAlerts = countAlerts(caseComments); + let comments: ExternalServiceComment[] = []; + if (commentsToBeUpdated && Array.isArray(commentsToBeUpdated) && commentsToBeUpdated.length > 0) { const commentsMapping = mappings.find((m) => m.source === 'comments'); if (commentsMapping?.action_type !== 'nothing') { comments = transformComments(commentsToBeUpdated, ['informationAdded']); } } + + if (totalAlerts > 0) { + comments.push({ + comment: `Elastic Security Alerts attached to the case: ${totalAlerts}`, + }); + } + return { incident, comments }; }; @@ -247,7 +267,13 @@ export const prepareFieldsForTransformation = ({ key: mapping.target, value: params[mapping.source] ?? '', actionType: mapping.action_type, - pipes: mapping.action_type === 'append' ? [...defaultPipes, 'append'] : defaultPipes, + pipes: + // Do not transform titles + mapping.source !== 'title' + ? mapping.action_type === 'append' + ? [...defaultPipes, 'append'] + : defaultPipes + : [], }, ] : acc, diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts index bf398d1ffcf40..c8501130493ba 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts @@ -170,7 +170,7 @@ describe('Push case', () => { parent: null, priority: 'High', labels: ['LOLBins'], - summary: 'Another bad one (created at 2019-11-25T22:32:17.947Z by elastic)', + summary: 'Another bad one', description: 'Oh no, a bad meanie going LOLBins all over the place! (created at 2019-11-25T22:32:17.947Z by elastic)', externalId: null, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/boost_item.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item.tsx similarity index 73% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/boost_item.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item.tsx index e5a76bc586b80..641628c32659c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/boost_item.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item.tsx @@ -9,26 +9,29 @@ import React, { useMemo } from 'react'; import { EuiFlexItem, EuiAccordion, EuiFlexGroup, EuiHideFor } from '@elastic/eui'; -import { BoostIcon } from '../../../boost_icon'; -import { BOOST_TYPE_TO_DISPLAY_MAP } from '../../../constants'; -import { Boost } from '../../../types'; -import { ValueBadge } from '../../value_badge'; +import { BoostIcon } from '../boost_icon'; +import { BOOST_TYPE_TO_DISPLAY_MAP } from '../constants'; +import { Boost } from '../types'; +import { ValueBadge } from '../value_badge'; +import { BoostItemContent } from './boost_item_content'; import { getBoostSummary } from './get_boost_summary'; interface Props { boost: Boost; id: string; + index: number; + name: string; } -export const BoostItem: React.FC = ({ id, boost }) => { +export const BoostItem: React.FC = ({ id, boost, index, name }) => { const summary = useMemo(() => getBoostSummary(boost), [boost]); return ( @@ -48,6 +51,8 @@ export const BoostItem: React.FC = ({ id, boost }) => { } paddingSize="s" - /> + > + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/boost_item_content.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/boost_item_content.test.tsx new file mode 100644 index 0000000000000..3296155fdce5d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/boost_item_content.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockActions } from '../../../../../__mocks__/kea.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButton, EuiRange } from '@elastic/eui'; + +import { BoostType } from '../../types'; + +import { BoostItemContent } from './boost_item_content'; +import { FunctionalBoostForm } from './functional_boost_form'; +import { ProximityBoostForm } from './proximity_boost_form'; +import { ValueBoostForm } from './value_boost_form'; + +describe('BoostItemContent', () => { + const actions = { + updateBoostFactor: jest.fn(), + deleteBoost: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(actions); + }); + + it('renders a value boost form if the provided boost is "value" boost', () => { + const boost = { + factor: 2, + type: 'value' as BoostType, + }; + + const wrapper = shallow(); + + expect(wrapper.find(ValueBoostForm).exists()).toBe(true); + expect(wrapper.find(FunctionalBoostForm).exists()).toBe(false); + expect(wrapper.find(ProximityBoostForm).exists()).toBe(false); + }); + + it('renders a functional boost form if the provided boost is "functional" boost', () => { + const boost = { + factor: 10, + type: 'functional' as BoostType, + }; + + const wrapper = shallow(); + + expect(wrapper.find(ValueBoostForm).exists()).toBe(false); + expect(wrapper.find(FunctionalBoostForm).exists()).toBe(true); + expect(wrapper.find(ProximityBoostForm).exists()).toBe(false); + }); + + it('renders a proximity boost form if the provided boost is "proximity" boost', () => { + const boost = { + factor: 8, + type: 'proximity' as BoostType, + }; + + const wrapper = shallow(); + + expect(wrapper.find(ValueBoostForm).exists()).toBe(false); + expect(wrapper.find(FunctionalBoostForm).exists()).toBe(false); + expect(wrapper.find(ProximityBoostForm).exists()).toBe(true); + }); + + it("renders an impact slider that can be used to update the boost's 'factor'", () => { + const boost = { + factor: 8, + type: 'proximity' as BoostType, + }; + + const wrapper = shallow(); + const impactSlider = wrapper.find(EuiRange); + expect(impactSlider.prop('value')).toBe(8); + + impactSlider.simulate('change', { target: { value: '2' } }); + + expect(actions.updateBoostFactor).toHaveBeenCalledWith('foo', 3, 2); + }); + + it("will delete the current boost if the 'Delete Boost' button is clicked", () => { + const boost = { + factor: 8, + type: 'proximity' as BoostType, + }; + + const wrapper = shallow(); + wrapper.find(EuiButton).simulate('click'); + + expect(actions.deleteBoost).toHaveBeenCalledWith('foo', 3); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/boost_item_content.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/boost_item_content.tsx new file mode 100644 index 0000000000000..7a19564543c81 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/boost_item_content.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions } from 'kea'; + +import { EuiButton, EuiFormRow, EuiPanel, EuiRange, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { RelevanceTuningLogic } from '../..'; +import { Boost, BoostType } from '../../types'; + +import { FunctionalBoostForm } from './functional_boost_form'; +import { ProximityBoostForm } from './proximity_boost_form'; +import { ValueBoostForm } from './value_boost_form'; + +interface Props { + boost: Boost; + index: number; + name: string; +} + +export const BoostItemContent: React.FC = ({ boost, index, name }) => { + const { deleteBoost, updateBoostFactor } = useActions(RelevanceTuningLogic); + const { type } = boost; + + const getBoostForm = () => { + switch (type) { + case BoostType.Value: + return ; + case BoostType.Functional: + return ; + case BoostType.Proximity: + return ; + } + }; + + return ( + + {getBoostForm()} + + + + updateBoostFactor( + name, + index, + parseFloat((e as React.ChangeEvent).target.value) + ) + } + showInput + compressed + fullWidth + /> + + deleteBoost(name, index)}> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.deleteBoostButtonLabel', + { + defaultMessage: 'Delete Boost', + } + )} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/functional_boost_form.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/functional_boost_form.test.tsx new file mode 100644 index 0000000000000..11a224a71d7f8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/functional_boost_form.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockActions } from '../../../../../__mocks__/kea.mock'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiSelect } from '@elastic/eui'; + +import { Boost, BoostOperation, BoostType, FunctionalBoostFunction } from '../../types'; + +import { FunctionalBoostForm } from './functional_boost_form'; + +describe('FunctionalBoostForm', () => { + const boost: Boost = { + factor: 2, + type: 'functional' as BoostType, + function: 'logarithmic' as FunctionalBoostFunction, + operation: 'multiply' as BoostOperation, + }; + + const actions = { + updateBoostSelectOption: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(actions); + }); + + const functionSelect = (wrapper: ShallowWrapper) => wrapper.find(EuiSelect).at(0); + const operationSelect = (wrapper: ShallowWrapper) => wrapper.find(EuiSelect).at(1); + + it('renders select boxes with values from the provided boost selected', () => { + const wrapper = shallow(); + expect(functionSelect(wrapper).prop('value')).toEqual('logarithmic'); + expect(operationSelect(wrapper).prop('value')).toEqual('multiply'); + }); + + it('will update state when a user makes a selection', () => { + const wrapper = shallow(); + + functionSelect(wrapper).simulate('change', { + target: { + value: 'exponential', + }, + }); + expect(actions.updateBoostSelectOption).toHaveBeenCalledWith( + 'foo', + 3, + 'function', + 'exponential' + ); + + operationSelect(wrapper).simulate('change', { + target: { + value: 'add', + }, + }); + expect(actions.updateBoostSelectOption).toHaveBeenCalledWith('foo', 3, 'operation', 'add'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/functional_boost_form.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/functional_boost_form.tsx new file mode 100644 index 0000000000000..d677fe5cbc069 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/functional_boost_form.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions } from 'kea'; + +import { EuiFormRow, EuiSelect } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { RelevanceTuningLogic } from '../..'; +import { + BOOST_OPERATION_DISPLAY_MAP, + FUNCTIONAL_BOOST_FUNCTION_DISPLAY_MAP, +} from '../../constants'; +import { + Boost, + BoostFunction, + BoostOperation, + BoostType, + FunctionalBoostFunction, +} from '../../types'; + +interface Props { + boost: Boost; + index: number; + name: string; +} + +const functionOptions = Object.values(FunctionalBoostFunction).map((boostFunction) => ({ + value: boostFunction, + text: FUNCTIONAL_BOOST_FUNCTION_DISPLAY_MAP[boostFunction as FunctionalBoostFunction], +})); + +const operationOptions = Object.values(BoostOperation).map((boostOperation) => ({ + value: boostOperation, + text: BOOST_OPERATION_DISPLAY_MAP[boostOperation as BoostOperation], +})); + +export const FunctionalBoostForm: React.FC = ({ boost, index, name }) => { + const { updateBoostSelectOption } = useActions(RelevanceTuningLogic); + return ( + <> + + + updateBoostSelectOption(name, index, 'function', e.target.value as BoostFunction) + } + fullWidth + /> + + + + updateBoostSelectOption(name, index, 'operation', e.target.value as BoostOperation) + } + fullWidth + /> + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/index.ts new file mode 100644 index 0000000000000..1a13c486ca523 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { BoostItemContent } from './boost_item_content'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/proximity_boost_form.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/proximity_boost_form.test.tsx new file mode 100644 index 0000000000000..6abbcc3d98862 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/proximity_boost_form.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockActions } from '../../../../../__mocks__/kea.mock'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiFieldText, EuiSelect } from '@elastic/eui'; + +import { Boost, BoostType, ProximityBoostFunction } from '../../types'; + +import { ProximityBoostForm } from './proximity_boost_form'; + +describe('ProximityBoostForm', () => { + const boost: Boost = { + factor: 2, + type: 'proximity' as BoostType, + function: 'linear' as ProximityBoostFunction, + center: '2', + }; + + const actions = { + updateBoostSelectOption: jest.fn(), + updateBoostCenter: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(actions); + }); + + const functionSelect = (wrapper: ShallowWrapper) => wrapper.find(EuiSelect); + const centerInput = (wrapper: ShallowWrapper) => wrapper.find(EuiFieldText); + + it('renders input with values from the provided boost', () => { + const wrapper = shallow(); + expect(functionSelect(wrapper).prop('value')).toEqual('linear'); + expect(centerInput(wrapper).prop('defaultValue')).toEqual('2'); + }); + + describe('various boost values', () => { + const renderWithBoostValues = (boostValues: { + center?: Boost['center']; + function?: Boost['function']; + }) => { + return shallow( + + ); + }; + + it('will set the center value as a string if the value is a number', () => { + const wrapper = renderWithBoostValues({ center: 0 }); + expect(centerInput(wrapper).prop('defaultValue')).toEqual('0'); + }); + + it('will set the center value as an empty string if the value is undefined', () => { + const wrapper = renderWithBoostValues({ center: undefined }); + expect(centerInput(wrapper).prop('defaultValue')).toEqual(''); + }); + + it('will set the function to Guaussian if it is not already set', () => { + const wrapper = renderWithBoostValues({ function: undefined }); + expect(functionSelect(wrapper).prop('value')).toEqual('gaussian'); + }); + }); + + it('will update state when a user enters input', () => { + const wrapper = shallow(); + + functionSelect(wrapper).simulate('change', { + target: { + value: 'exponential', + }, + }); + expect(actions.updateBoostSelectOption).toHaveBeenCalledWith( + 'foo', + 3, + 'function', + 'exponential' + ); + + centerInput(wrapper).simulate('change', { + target: { + value: '5', + }, + }); + expect(actions.updateBoostCenter).toHaveBeenCalledWith('foo', 3, '5'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/proximity_boost_form.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/proximity_boost_form.tsx new file mode 100644 index 0000000000000..f01f060bfcee6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/proximity_boost_form.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions } from 'kea'; + +import { EuiFieldText, EuiFormRow, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { RelevanceTuningLogic } from '../..'; +import { PROXIMITY_BOOST_FUNCTION_DISPLAY_MAP } from '../../constants'; +import { Boost, BoostType, ProximityBoostFunction } from '../../types'; + +interface Props { + boost: Boost; + index: number; + name: string; +} + +export const ProximityBoostForm: React.FC = ({ boost, index, name }) => { + const { updateBoostSelectOption, updateBoostCenter } = useActions(RelevanceTuningLogic); + + const currentBoostCenter = boost.center !== undefined ? boost.center.toString() : ''; + const currentBoostFunction = boost.function || ProximityBoostFunction.Gaussian; + + const functionOptions = Object.values(ProximityBoostFunction).map((boostFunction) => ({ + value: boostFunction, + text: PROXIMITY_BOOST_FUNCTION_DISPLAY_MAP[boostFunction as ProximityBoostFunction], + })); + + return ( + <> + + + updateBoostSelectOption( + name, + index, + 'function', + e.target.value as ProximityBoostFunction + ) + } + fullWidth + /> + + + updateBoostCenter(name, index, e.target.value)} + fullWidth + /> + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/value_boost_form.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/value_boost_form.test.tsx new file mode 100644 index 0000000000000..447ca8e178349 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/value_boost_form.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockActions } from '../../../../../__mocks__/kea.mock'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiButton, EuiButtonIcon, EuiFieldText } from '@elastic/eui'; + +import { Boost, BoostType } from '../../types'; + +import { ValueBoostForm } from './value_boost_form'; + +describe('ValueBoostForm', () => { + const boost: Boost = { + factor: 2, + type: 'value' as BoostType, + value: ['bar', '', 'baz'], + }; + + const actions = { + removeBoostValue: jest.fn(), + updateBoostValue: jest.fn(), + addBoostValue: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(actions); + }); + + const valueInput = (wrapper: ShallowWrapper, index: number) => + wrapper.find(EuiFieldText).at(index); + const removeButton = (wrapper: ShallowWrapper, index: number) => + wrapper.find(EuiButtonIcon).at(index); + const addButton = (wrapper: ShallowWrapper) => wrapper.find(EuiButton); + + it('renders a text input for each value from the boost', () => { + const wrapper = shallow(); + expect(valueInput(wrapper, 0).prop('value')).toEqual('bar'); + expect(valueInput(wrapper, 1).prop('value')).toEqual(''); + expect(valueInput(wrapper, 2).prop('value')).toEqual('baz'); + }); + + it('renders a single empty text box if the boost has no value', () => { + const wrapper = shallow( + + ); + expect(valueInput(wrapper, 0).prop('value')).toEqual(''); + }); + + it('updates the corresponding value in state whenever a user changes the value in a text input', () => { + const wrapper = shallow(); + + valueInput(wrapper, 2).simulate('change', { target: { value: 'new value' } }); + + expect(actions.updateBoostValue).toHaveBeenCalledWith('foo', 3, 2, 'new value'); + }); + + it('deletes a boost value when the Remove Value button is clicked', () => { + const wrapper = shallow(); + + removeButton(wrapper, 2).simulate('click'); + + expect(actions.removeBoostValue).toHaveBeenCalledWith('foo', 3, 2); + }); + + it('adds a new boost value when the Add Value is button clicked', () => { + const wrapper = shallow(); + + addButton(wrapper).simulate('click'); + + expect(actions.addBoostValue).toHaveBeenCalledWith('foo', 3); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/value_boost_form.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/value_boost_form.tsx new file mode 100644 index 0000000000000..15d19a9741d0a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/value_boost_form.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions } from 'kea'; + +import { + EuiButton, + EuiButtonIcon, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { RelevanceTuningLogic } from '../..'; +import { Boost } from '../../types'; + +interface Props { + boost: Boost; + index: number; + name: string; +} + +export const ValueBoostForm: React.FC = ({ boost, index, name }) => { + const { updateBoostValue, removeBoostValue, addBoostValue } = useActions(RelevanceTuningLogic); + const values = boost.value || ['']; + + return ( + <> + {values.map((value, valueIndex) => ( + + + updateBoostValue(name, index, valueIndex, e.target.value)} + aria-label={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.value.valueNameAriaLabel', + { + defaultMessage: 'Value name', + } + )} + autoFocus + /> + + + removeBoostValue(name, index, valueIndex)} + aria-label={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.value.removeValueAriaLabel', + { + defaultMessage: 'Remove value', + } + )} + /> + + + ))} + + addBoostValue(name, index)}> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.value.addValueButtonLabel', + { + defaultMessage: 'Add Value', + } + )} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/boosts.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boosts.scss similarity index 94% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/boosts.scss rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boosts.scss index 53b3c233301b0..0e9b2b1035b36 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/boosts.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boosts.scss @@ -3,7 +3,7 @@ min-width: $euiSizeXXL * 4; } - &__itemContent { + &__itemButton { width: 100%; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/boosts.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boosts.test.tsx similarity index 65% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/boosts.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boosts.test.tsx index b313e16c0bda1..75c22d2ae9473 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/boosts.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boosts.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { setMockActions } from '../../../../../../__mocks__/kea.mock'; +import { setMockActions } from '../../../../__mocks__/kea.mock'; import React from 'react'; @@ -13,8 +13,11 @@ import { shallow } from 'enzyme'; import { EuiSuperSelect } from '@elastic/eui'; -import { SchemaTypes } from '../../../../../../shared/types'; +import { SchemaTypes } from '../../../../shared/types'; +import { BoostType } from '../types'; + +import { BoostItem } from './boost_item'; import { Boosts } from './boosts'; describe('Boosts', () => { @@ -68,4 +71,33 @@ describe('Boosts', () => { expect(actions.addBoost).toHaveBeenCalledWith('foo', 'functional'); }); + + it('will render a list of boosts', () => { + const boost1 = { + factor: 2, + type: 'value' as BoostType, + }; + const boost2 = { + factor: 10, + type: 'functional' as BoostType, + }; + const boost3 = { + factor: 8, + type: 'proximity' as BoostType, + }; + + const wrapper = shallow( + + ); + + const boostItems = wrapper.find(BoostItem); + expect(boostItems.at(0).prop('boost')).toEqual(boost1); + expect(boostItems.at(1).prop('boost')).toEqual(boost2); + expect(boostItems.at(2).prop('boost')).toEqual(boost3); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/boosts.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boosts.tsx similarity index 86% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/boosts.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boosts.tsx index 1ad27346d2630..d6d43ea7beab0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/boosts.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boosts.tsx @@ -13,13 +13,13 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle, EuiSuperSelect } from '@ import { i18n } from '@kbn/i18n'; -import { TEXT } from '../../../../../../shared/constants/field_types'; -import { SchemaTypes } from '../../../../../../shared/types'; +import { TEXT } from '../../../../shared/constants/field_types'; +import { SchemaTypes } from '../../../../shared/types'; -import { BoostIcon } from '../../../boost_icon'; -import { FUNCTIONAL_DISPLAY, PROXIMITY_DISPLAY, VALUE_DISPLAY } from '../../../constants'; -import { RelevanceTuningLogic } from '../../../relevance_tuning_logic'; -import { Boost, BoostType } from '../../../types'; +import { BoostIcon } from '../boost_icon'; +import { FUNCTIONAL_DISPLAY, PROXIMITY_DISPLAY, VALUE_DISPLAY } from '../constants'; +import { RelevanceTuningLogic } from '../relevance_tuning_logic'; +import { Boost, BoostType } from '../types'; import { BoostItem } from './boost_item'; @@ -111,7 +111,13 @@ export const Boosts: React.FC = ({ name, type, boosts = [] }) => { {boosts.map((boost, index) => ( - + ))} ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/get_boost_summary.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/get_boost_summary.test.ts similarity index 80% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/get_boost_summary.test.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/get_boost_summary.test.ts index f6852569213a6..4d78fe8f06739 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/get_boost_summary.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/get_boost_summary.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Boost, BoostFunction, BoostType, BoostOperation } from '../../../types'; +import { Boost, BoostFunction, BoostType, BoostOperation, FunctionalBoostFunction } from '../types'; import { getBoostSummary } from './get_boost_summary'; @@ -29,6 +29,15 @@ describe('getBoostSummary', () => { }) ).toEqual(''); }); + + it('filters out empty values', () => { + expect( + getBoostSummary({ + ...boost, + value: [' ', '', 'foo', '', 'bar'], + }) + ).toEqual('foo,bar'); + }); }); describe('when the boost type is "proximity"', () => { @@ -55,18 +64,20 @@ describe('getBoostSummary', () => { describe('when the boost type is "functional"', () => { const boost: Boost = { type: BoostType.Functional, - function: BoostFunction.Gaussian, + function: FunctionalBoostFunction.Logarithmic, operation: BoostOperation.Add, factor: 5, }; it('creates a summary that is name of the function and operation', () => { - expect(getBoostSummary(boost)).toEqual('gaussian add'); + expect(getBoostSummary(boost)).toEqual('logarithmic add'); }); it('prints empty if function or operation is missing', () => { expect(getBoostSummary({ ...boost, function: undefined })).toEqual(BoostOperation.Add); - expect(getBoostSummary({ ...boost, operation: undefined })).toEqual(BoostFunction.Gaussian); + expect(getBoostSummary({ ...boost, operation: undefined })).toEqual( + FunctionalBoostFunction.Logarithmic + ); expect(getBoostSummary({ ...boost, function: undefined, operation: undefined })).toEqual(''); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/get_boost_summary.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/get_boost_summary.ts similarity index 80% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/get_boost_summary.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/get_boost_summary.ts index f3922ebb0fffe..71b1a6136cf65 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/get_boost_summary.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/get_boost_summary.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { Boost, BoostType } from '../../../types'; +import { Boost, BoostType } from '../types'; export const getBoostSummary = (boost: Boost): string => { if (boost.type === BoostType.Value) { - return !boost.value ? '' : boost.value.join(','); + return !boost.value ? '' : boost.value.filter((v) => v.trim() !== '').join(','); } else if (boost.type === BoostType.Proximity) { return boost.function || ''; } else { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/boosts/index.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/constants.ts index 9fdbb8e979b31..8131a6a3a57c6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/constants.ts @@ -7,7 +7,12 @@ import { i18n } from '@kbn/i18n'; -import { BoostType } from './types'; +import { + BoostOperation, + BoostType, + FunctionalBoostFunction, + ProximityBoostFunction, +} from './types'; export const FIELD_FILTER_CUTOFF = 10; @@ -59,6 +64,7 @@ export const VALUE_DISPLAY = i18n.translate( defaultMessage: 'Value', } ); + export const BOOST_TYPE_TO_DISPLAY_MAP = { [BoostType.Proximity]: PROXIMITY_DISPLAY, [BoostType.Functional]: FUNCTIONAL_DISPLAY, @@ -70,3 +76,62 @@ export const BOOST_TYPE_TO_ICON_MAP = { [BoostType.Functional]: 'tokenFunction', [BoostType.Proximity]: 'tokenGeo', }; + +export const ADD_DISPLAY = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.addOperationDropDownOptionLabel', + { + defaultMessage: 'Add', + } +); + +export const MULTIPLY_DISPLAY = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.multiplyOperationDropDownOptionLabel', + { + defaultMessage: 'Multiply', + } +); + +export const BOOST_OPERATION_DISPLAY_MAP = { + [BoostOperation.Add]: ADD_DISPLAY, + [BoostOperation.Multiply]: MULTIPLY_DISPLAY, +}; + +export const LOGARITHMIC_DISPLAY = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.logarithmicBoostFunctionDropDownOptionLabel', + { + defaultMessage: 'Logarithmic', + } +); + +export const GAUSSIAN_DISPLAY = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.gaussianFunctionDropDownOptionLabel', + { + defaultMessage: 'Gaussian', + } +); + +export const EXPONENTIAL_DISPLAY = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.exponentialFunctionDropDownOptionLabel', + { + defaultMessage: 'Exponential', + } +); + +export const LINEAR_DISPLAY = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.linearFunctionDropDownOptionLabel', + { + defaultMessage: 'Linear', + } +); + +export const PROXIMITY_BOOST_FUNCTION_DISPLAY_MAP = { + [ProximityBoostFunction.Gaussian]: GAUSSIAN_DISPLAY, + [ProximityBoostFunction.Exponential]: EXPONENTIAL_DISPLAY, + [ProximityBoostFunction.Linear]: LINEAR_DISPLAY, +}; + +export const FUNCTIONAL_BOOST_FUNCTION_DISPLAY_MAP = { + [FunctionalBoostFunction.Logarithmic]: LOGARITHMIC_DISPLAY, + [FunctionalBoostFunction.Exponential]: EXPONENTIAL_DISPLAY, + [FunctionalBoostFunction.Linear]: LINEAR_DISPLAY, +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.scss index 749fca6f79811..9795564da04d5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.scss @@ -17,4 +17,10 @@ } } } + + .relevanceTuningAccordionItem { + border: none; + border-top: $euiBorderThin; + border-radius: 0; + } } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item.test.tsx index 6043e7ae65b26..674bb91929a76 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item.test.tsx @@ -13,9 +13,9 @@ import { SchemaTypes } from '../../../../shared/types'; import { BoostIcon } from '../boost_icon'; import { Boost, BoostType, SearchField } from '../types'; +import { ValueBadge } from '../value_badge'; import { RelevanceTuningItem } from './relevance_tuning_item'; -import { ValueBadge } from './value_badge'; describe('RelevanceTuningItem', () => { const props = { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item.tsx index 38cec4825cfe7..f7f4c64622fa6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item.tsx @@ -13,8 +13,7 @@ import { SchemaTypes } from '../../../../shared/types'; import { BoostIcon } from '../boost_icon'; import { Boost, SearchField } from '../types'; - -import { ValueBadge } from './value_badge'; +import { ValueBadge } from '../value_badge'; interface Props { name: string; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/relevance_tuning_item_content.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/relevance_tuning_item_content.scss deleted file mode 100644 index 63718a95551fa..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/relevance_tuning_item_content.scss +++ /dev/null @@ -1,6 +0,0 @@ -.relevanceTuningForm { - &__itemContent { - border: none; - border-top: $euiBorderThin; - } -} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/relevance_tuning_item_content.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/relevance_tuning_item_content.tsx index 29ab559485d77..e780a4de07252 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/relevance_tuning_item_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_item_content/relevance_tuning_item_content.tsx @@ -11,14 +11,12 @@ import { EuiPanel } from '@elastic/eui'; import { SchemaTypes } from '../../../../../shared/types'; +import { Boosts } from '../../boosts'; import { Boost, SearchField } from '../../types'; -import { Boosts } from './boosts'; import { TextSearchToggle } from './text_search_toggle'; import { WeightSlider } from './weight_slider'; -import './relevance_tuning_item_content.scss'; - interface Props { name: string; type: SchemaTypes; @@ -29,7 +27,7 @@ interface Props { export const RelevanceTuningItemContent: React.FC = ({ name, type, boosts, field }) => { return ( <> - + {field && } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts index a7ee6f9755fc4..8ce07dc699cbb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts @@ -9,7 +9,7 @@ import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../ import { nextTick } from '@kbn/test/jest'; -import { Boost, BoostFunction, BoostOperation, BoostType } from './types'; +import { Boost, BoostOperation, BoostType, FunctionalBoostFunction } from './types'; import { RelevanceTuningLogic } from './'; @@ -1053,14 +1053,14 @@ describe('RelevanceTuningLogic', () => { 'foo', 1, 'function', - BoostFunction.Exponential + FunctionalBoostFunction.Exponential ); expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( searchSettingsWithBoost({ factor: 1, type: BoostType.Functional, - function: BoostFunction.Exponential, + function: FunctionalBoostFunction.Exponential, }) ); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts index 95bd33aac5b9f..16da5868da681 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts @@ -11,15 +11,23 @@ export enum BoostType { Proximity = 'proximity', } -export enum BoostFunction { +export enum FunctionalBoostFunction { + Logarithmic = 'logarithmic', + Exponential = 'exponential', + Linear = 'linear', +} + +export enum ProximityBoostFunction { Gaussian = 'gaussian', Exponential = 'exponential', Linear = 'linear', } +export type BoostFunction = FunctionalBoostFunction | ProximityBoostFunction; + export enum BoostOperation { Add = 'add', - Multiple = 'multiply', + Multiply = 'multiply', } export interface BaseBoost { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/value_badge.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/value_badge.scss similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/value_badge.scss rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/value_badge.scss diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/value_badge.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/value_badge.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/value_badge.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/value_badge.tsx diff --git a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap index e5594bb0bb769..7aa838021f2a8 100644 --- a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap +++ b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap @@ -1,16 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`DragDrop defined dropType is reflected in the className 1`] = ` -

+ Hello! + `; exports[`DragDrop items that has dropType=undefined get special styling when another item is dragged 1`] = ` diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss index 9e3f1e1c3cf26..961f7ee0ec400 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss @@ -81,13 +81,6 @@ } } -.lnsDragDrop__container { - position: relative; - overflow: visible !important; // sass-lint:disable-line no-important - width: 100%; - height: 100%; -} - .lnsDragDrop__reorderableDrop { position: absolute; width: 100%; diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 618a7accb9b2b..76e44c29eaed5 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -456,7 +456,7 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { isActiveDropTarget && dropType !== 'reorder' && dragging?.ghost ? dragging.ghost : undefined; return ( -
+ <> {React.cloneElement(children, { 'data-test-subj': dataTestSubj || 'lnsDragDrop', className: classNames(children.props.className, classes, className), @@ -471,7 +471,7 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { style: ghost.style, }) : null} -
+ ); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 59b64de369745..1d75e873f9b18 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -187,272 +187,275 @@ export function LayerPanel( ]); return ( -
- - - - - - - {layerDatasource && ( - - { - const newState = - typeof updater === 'function' ? updater(layerDatasourceState) : updater; - // Look for removed columns - const nextPublicAPI = layerDatasource.getPublicAPI({ - state: newState, - layerId, - }); - const nextTable = new Set( - nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) - ); - const removed = datasourcePublicAPI - .getTableSpec() - .map(({ columnId }) => columnId) - .filter((columnId) => !nextTable.has(columnId)); - let nextVisState = props.visualizationState; - removed.forEach((columnId) => { - nextVisState = activeVisualization.removeDimension({ - layerId, - columnId, - prevState: nextVisState, - }); - }); - - props.updateAll(datasourceId, newState, nextVisState); - }, + <> +
+ + + + - )} - - - - - {groups.map((group, groupIndex) => { - const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; - return ( - {group.groupLabel}} - labelType="legend" - key={group.groupId} - isInvalid={isMissing} - error={ - isMissing ? ( -
- {i18n.translate('xpack.lens.editorFrame.requiredDimensionWarningLabel', { - defaultMessage: 'Required dimension', - })} -
- ) : ( - [] - ) - } - > - <> - - {group.accessors.map((accessorConfig, accessorIndex) => { - const { columnId } = accessorConfig; - return ( - -
- { - setActiveDimension({ - isNew: false, - activeGroup: group, - activeId: id, - }); - }} - onRemoveClick={(id: string) => { - trackUiEvent('indexpattern_dimension_removed'); - props.updateAll( - datasourceId, - layerDatasource.removeColumn({ - layerId, - columnId: id, - prevState: layerDatasourceState, - }), - activeVisualization.removeDimension({ - layerId, - columnId: id, - prevState: props.visualizationState, - }) - ); - removeButtonRef(id); - }} - > - - -
-
- ); - })} -
- {group.supportsMoreColumns ? ( - { - setActiveDimension({ - activeGroup: group, - activeId: id, - isNew: true, - }); - }} - onDrop={onDrop} - /> - ) : null} - -
- ); - })} - { - if (layerDatasource.updateStateOnCloseDimension) { - const newState = layerDatasource.updateStateOnCloseDimension({ - state: layerDatasourceState, - layerId, - columnId: activeId!, - }); - if (newState) { - props.updateDatasource(datasourceId, newState); - } - } - setActiveDimension(initialActiveDimensionState); - }} - panel={ - <> - {activeGroup && activeId && ( + {layerDatasource && ( + { - if (shouldReplaceDimension || shouldRemoveDimension) { - props.updateAll( - datasourceId, - newState, - shouldRemoveDimension - ? activeVisualization.removeDimension({ - layerId, - columnId: activeId, - prevState: props.visualizationState, - }) - : activeVisualization.setDimension({ - layerId, - groupId: activeGroup.groupId, - columnId: activeId, - prevState: props.visualizationState, - }) - ); - } else { - props.updateDatasource(datasourceId, newState); - } - setActiveDimension({ - ...activeDimension, - isNew: false, + layerId, + state: layerDatasourceState, + activeData: props.framePublicAPI.activeData, + setState: (updater: unknown) => { + const newState = + typeof updater === 'function' ? updater(layerDatasourceState) : updater; + // Look for removed columns + const nextPublicAPI = layerDatasource.getPublicAPI({ + state: newState, + layerId, + }); + const nextTable = new Set( + nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) + ); + const removed = datasourcePublicAPI + .getTableSpec() + .map(({ columnId }) => columnId) + .filter((columnId) => !nextTable.has(columnId)); + let nextVisState = props.visualizationState; + removed.forEach((columnId) => { + nextVisState = activeVisualization.removeDimension({ + layerId, + columnId, + prevState: nextVisState, + }); }); + + props.updateAll(datasourceId, newState, nextVisState); }, }} /> - )} - {activeGroup && - activeId && - !activeDimension.isNew && - activeVisualization.renderDimensionEditor && - activeGroup?.enableDimensionEditor && ( -
- + )} + + + + + {groups.map((group, groupIndex) => { + const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; + return ( + {group.groupLabel}
} + labelType="legend" + key={group.groupId} + isInvalid={isMissing} + error={ + isMissing ? ( +
+ {i18n.translate('xpack.lens.editorFrame.requiredDimensionWarningLabel', { + defaultMessage: 'Required dimension', + })} +
+ ) : ( + [] + ) + } + > + <> + + {group.accessors.map((accessorConfig, accessorIndex) => { + const { columnId } = accessorConfig; + + return ( + +
+ { + setActiveDimension({ + isNew: false, + activeGroup: group, + activeId: id, + }); + }} + onRemoveClick={(id: string) => { + trackUiEvent('indexpattern_dimension_removed'); + props.updateAll( + datasourceId, + layerDatasource.removeColumn({ + layerId, + columnId: id, + prevState: layerDatasourceState, + }), + activeVisualization.removeDimension({ + layerId, + columnId: id, + prevState: props.visualizationState, + }) + ); + removeButtonRef(id); + }} + > + + +
+
+ ); + })} +
+ {group.supportsMoreColumns ? ( + { + setActiveDimension({ + activeGroup: group, + activeId: id, + isNew: true, + }); }} + onDrop={onDrop} /> - - )} - - } - /> + ) : null} + + + ); + })} - + - - - - - -
-
+ + + + + +
+
+ + { + if (layerDatasource.updateStateOnCloseDimension) { + const newState = layerDatasource.updateStateOnCloseDimension({ + state: layerDatasourceState, + layerId, + columnId: activeId!, + }); + if (newState) { + props.updateDatasource(datasourceId, newState); + } + } + setActiveDimension(initialActiveDimensionState); + }} + panel={ + <> + {activeGroup && activeId && ( + { + if (shouldReplaceDimension || shouldRemoveDimension) { + props.updateAll( + datasourceId, + newState, + shouldRemoveDimension + ? activeVisualization.removeDimension({ + layerId, + columnId: activeId, + prevState: props.visualizationState, + }) + : activeVisualization.setDimension({ + layerId, + groupId: activeGroup.groupId, + columnId: activeId, + prevState: props.visualizationState, + }) + ); + } else { + props.updateDatasource(datasourceId, newState); + } + setActiveDimension({ + ...activeDimension, + isNew: false, + }); + }, + }} + /> + )} + {activeGroup && + activeId && + !activeDimension.isNew && + activeVisualization.renderDimensionEditor && + activeGroup?.enableDimensionEditor && ( +
+ +
+ )} + + } + /> + ); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index d5c0b9ff64807..af5411dd4d3b0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -11,9 +11,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { Ast } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; import { + EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, - EuiIcon, EuiText, EuiButtonEmpty, EuiLink, @@ -389,72 +389,84 @@ export const InnerVisualizationWrapper = ({ if (localState.configurationValidationError?.length) { let showExtraErrors = null; + let showExtraErrorsAction = null; + if (localState.configurationValidationError.length > 1) { if (localState.expandError) { showExtraErrors = localState.configurationValidationError .slice(1) .map(({ longMessage }) => ( - {longMessage} - +

)); } else { - showExtraErrors = ( - - { - setLocalState((prevState: WorkspaceState) => ({ - ...prevState, - expandError: !prevState.expandError, - })); - }} - data-test-subj="configuration-failure-more-errors" - > - {i18n.translate('xpack.lens.editorFrame.configurationFailureMoreErrors', { - defaultMessage: ` +{errors} {errors, plural, one {error} other {errors}}`, - values: { errors: localState.configurationValidationError.length - 1 }, - })} - - + showExtraErrorsAction = ( + { + setLocalState((prevState: WorkspaceState) => ({ + ...prevState, + expandError: !prevState.expandError, + })); + }} + data-test-subj="configuration-failure-more-errors" + > + {i18n.translate('xpack.lens.editorFrame.configurationFailureMoreErrors', { + defaultMessage: ` +{errors} {errors, plural, one {error} other {errors}}`, + values: { errors: localState.configurationValidationError.length - 1 }, + })} + ); } } return ( - + - - - - {localState.configurationValidationError[0].longMessage} + +

+ {localState.configurationValidationError[0].longMessage} +

+ + {showExtraErrors} + + } + iconColor="danger" + iconType="alert" + />
- {showExtraErrors}
); } if (localState.expressionBuildError?.length) { return ( - + - - - - +

+ +

+ +

{localState.expressionBuildError[0].longMessage}

+ + } + iconColor="danger" + iconType="alert" />
- {localState.expressionBuildError[0].longMessage}
); } @@ -474,34 +486,43 @@ export const InnerVisualizationWrapper = ({ const visibleErrorMessage = getOriginalRequestErrorMessage(error) || errorMessage; return ( - + - - - - { + setLocalState((prevState: WorkspaceState) => ({ + ...prevState, + expandError: !prevState.expandError, + })); + }} + > + {i18n.translate('xpack.lens.editorFrame.expandRenderingErrorButton', { + defaultMessage: 'Show details of error', + })} + + ) : null + } + body={ + <> +

+ +

+ + {localState.expandError ? ( +

visibleErrorMessage

+ ) : null} + + } + iconColor="danger" + iconType="alert" />
- {visibleErrorMessage ? ( - - { - setLocalState((prevState: WorkspaceState) => ({ - ...prevState, - expandError: !prevState.expandError, - })); - }} - > - {i18n.translate('xpack.lens.editorFrame.expandRenderingErrorButton', { - defaultMessage: 'Show details of error', - })} - - - {localState.expandError ? visibleErrorMessage : null} - - ) : null}
); }} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss index 3949c7deb53b4..167c17ee6ae9c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss @@ -11,6 +11,7 @@ min-height: $euiSizeXXL * 10; overflow: visible; border: none; + height: 100%; .lnsWorkspacePanelWrapper__pageContentBody { @include euiScrollBar; diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts index 4c40282012d6d..a676b7283671c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts @@ -5,10 +5,11 @@ * 2.0. */ -import { Capabilities, HttpSetup } from 'kibana/public'; +import { Capabilities, HttpSetup, SavedObjectReference } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { RecursiveReadonly } from '@kbn/utility-types'; import { Ast } from '@kbn/interpreter/target/common'; +import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; import { IndexPatternsContract, TimefilterContract, @@ -105,4 +106,15 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { parent ); } + + extract(state: EmbeddableStateWithType) { + let references: SavedObjectReference[] = []; + const typedState = (state as unknown) as LensEmbeddableInput; + + if ('attributes' in typedState && typedState.attributes !== undefined) { + references = typedState.attributes.references; + } + + return { state, references }; + } } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 4e7e07b99904f..bc4bb028696b8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -602,6 +602,55 @@ describe('IndexPattern Data Source', () => { `); }); + it('should put column formatters after calculated columns', async () => { + const queryBaseState: IndexPatternBaseState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['bucket', 'metric', 'calculated'], + columns: { + bucket: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'auto', + }, + }, + metric: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + timeScale: 'h', + }, + calculated: { + label: 'Moving average of bytes', + dataType: 'number', + isBucketed: false, + operationType: 'moving_average', + references: ['metric'], + params: { + window: 5, + }, + }, + }, + }, + }, + }; + + const state = enrichBaseState(queryBaseState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + const formatIndex = ast.chain.findIndex((fn) => fn.function === 'lens_format_column'); + const calculationIndex = ast.chain.findIndex((fn) => fn.function === 'moving_average'); + expect(calculationIndex).toBeLessThan(formatIndex); + }); + it('should rename the output from esaggs when using flat query', () => { const queryBaseState: IndexPatternBaseState = { currentIndexPatternId: '1', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index eff88574161b1..cdac7d154628d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -177,8 +177,8 @@ function getExpressionForLayer( idMap: [JSON.stringify(idMap)], }, }, - ...formatterOverrides, ...expressions, + ...formatterOverrides, ...timeScaleFunctions, ], }; diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index 6f8782c148d45..27e0fa29b1e55 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -8,7 +8,7 @@ import moment from 'moment'; import { OsTypeArray } from './schemas/common'; -import { EntriesArray } from './schemas/types'; +import { EntriesArray, Entry, EntryMatch, EntryNested } from './schemas/types'; import { EndpointEntriesArray } from './schemas/types/endpoint'; export const DATE_NOW = '2020-04-20T15:25:31.830Z'; export const OLD_DATE_RELATIVE_TO_DATE_NOW = '2020-04-19T15:25:31.830Z'; @@ -72,6 +72,34 @@ export const ENDPOINT_ENTRIES: EndpointEntriesArray = [ }, { field: 'some.not.nested.field', operator: 'included', type: 'match', value: 'some value' }, ]; +// ENTRIES_WITH_IDS should only be used to mock out functionality of a collection of transforms +// that are UI specific and useful for UI concerns that are inserted between the +// API and the actual user interface. In some ways these might be viewed as +// technical debt or to compensate for the differences and preferences +// of how ReactJS might prefer data vs. how we want to model data. +export const ENTRIES_WITH_IDS: EntriesArray = [ + { + entries: [ + { + field: 'nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as EntryMatch & { id: string }, + ], + field: 'some.parentField', + id: '123', + type: 'nested', + } as EntryNested & { id: string }, + { + field: 'some.not.nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as Entry & { id: string }, +]; export const ITEM_TYPE = 'simple'; export const OS_TYPES: OsTypeArray = ['windows']; export const TAGS = []; diff --git a/x-pack/plugins/lists/common/shared_imports.ts b/x-pack/plugins/lists/common/shared_imports.ts index 7e96b13c036ec..2483c1f7dd992 100644 --- a/x-pack/plugins/lists/common/shared_imports.ts +++ b/x-pack/plugins/lists/common/shared_imports.ts @@ -12,6 +12,8 @@ export { DefaultStringArray, DefaultVersionNumber, DefaultVersionNumberDecoded, + addIdToItem, + removeIdFromItem, exactCheck, getPaths, foldLeftRight, diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts index ed16a405a5107..7a39bd5651014 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts @@ -7,6 +7,7 @@ import { act, renderHook } from '@testing-library/react-hooks'; +import { ENTRIES_WITH_IDS } from '../../../common/constants.mock'; import { coreMock } from '../../../../../../src/core/public/mocks'; import * as api from '../api'; import { getCreateExceptionListItemSchemaMock } from '../../../common/schemas/request/create_exception_list_item_schema.mock'; @@ -69,8 +70,8 @@ describe('usePersistExceptionItem', () => { }); test('it invokes "updateExceptionListItem" when payload has "id"', async () => { - const addExceptionItem = jest.spyOn(api, 'addExceptionListItem'); - const updateExceptionItem = jest.spyOn(api, 'updateExceptionListItem'); + const addExceptionListItem = jest.spyOn(api, 'addExceptionListItem'); + const updateExceptionListItem = jest.spyOn(api, 'updateExceptionListItem'); await act(async () => { const { result, waitForNextUpdate } = renderHook< PersistHookProps, @@ -78,12 +79,45 @@ describe('usePersistExceptionItem', () => { >(() => usePersistExceptionItem({ http: mockKibanaHttpService, onError })); await waitForNextUpdate(); - result.current[1](getUpdateExceptionListItemSchemaMock()); + // NOTE: Take note here passing in an exception item where it's + // entries have been enriched with ids to ensure that they get stripped + // before the call goes through + result.current[1]({ ...getUpdateExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }); await waitForNextUpdate(); expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); - expect(addExceptionItem).not.toHaveBeenCalled(); - expect(updateExceptionItem).toHaveBeenCalled(); + expect(addExceptionListItem).not.toHaveBeenCalled(); + expect(updateExceptionListItem).toHaveBeenCalledWith({ + http: mockKibanaHttpService, + listItem: getUpdateExceptionListItemSchemaMock(), + signal: new AbortController().signal, + }); + }); + }); + + test('it invokes "addExceptionListItem" when payload does not have "id"', async () => { + const updateExceptionListItem = jest.spyOn(api, 'updateExceptionListItem'); + const addExceptionListItem = jest.spyOn(api, 'addExceptionListItem'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + PersistHookProps, + ReturnPersistExceptionItem + >(() => usePersistExceptionItem({ http: mockKibanaHttpService, onError })); + + await waitForNextUpdate(); + // NOTE: Take note here passing in an exception item where it's + // entries have been enriched with ids to ensure that they get stripped + // before the call goes through + result.current[1]({ ...getCreateExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }); + await waitForNextUpdate(); + + expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); + expect(updateExceptionListItem).not.toHaveBeenCalled(); + expect(addExceptionListItem).toHaveBeenCalledWith({ + http: mockKibanaHttpService, + listItem: getCreateExceptionListItemSchemaMock(), + signal: new AbortController().signal, + }); }); }); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.ts b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.ts index 995c6b8703bf4..6135d14aef6a4 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.ts @@ -7,9 +7,13 @@ import { Dispatch, useEffect, useState } from 'react'; -import { UpdateExceptionListItemSchema } from '../../../common/schemas'; +import { + CreateExceptionListItemSchema, + UpdateExceptionListItemSchema, +} from '../../../common/schemas'; import { addExceptionListItem, updateExceptionListItem } from '../api'; -import { AddExceptionListItem, PersistHookProps } from '../types'; +import { transformNewItemOutput, transformOutput } from '../transforms'; +import { PersistHookProps } from '../types'; interface PersistReturnExceptionItem { isLoading: boolean; @@ -18,7 +22,7 @@ interface PersistReturnExceptionItem { export type ReturnPersistExceptionItem = [ PersistReturnExceptionItem, - Dispatch + Dispatch ]; /** @@ -32,7 +36,9 @@ export const usePersistExceptionItem = ({ http, onError, }: PersistHookProps): ReturnPersistExceptionItem => { - const [exceptionListItem, setExceptionItem] = useState(null); + const [exceptionListItem, setExceptionItem] = useState< + CreateExceptionListItemSchema | UpdateExceptionListItemSchema | null + >(null); const [isSaved, setIsSaved] = useState(false); const [isLoading, setIsLoading] = useState(false); const isUpdateExceptionItem = (item: unknown): item is UpdateExceptionListItemSchema => @@ -47,16 +53,25 @@ export const usePersistExceptionItem = ({ if (exceptionListItem != null) { try { setIsLoading(true); + if (isUpdateExceptionItem(exceptionListItem)) { + // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes + // for context around the temporary `id` + const transformedList = transformOutput(exceptionListItem); + await updateExceptionListItem({ http, - listItem: exceptionListItem, + listItem: transformedList, signal: abortCtrl.signal, }); } else { + // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes + // for context around the temporary `id` + const transformedList = transformNewItemOutput(exceptionListItem); + await addExceptionListItem({ http, - listItem: exceptionListItem, + listItem: transformedList, signal: abortCtrl.signal, }); } diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts index e61e74ca33236..62f959cb386a0 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts @@ -7,6 +7,7 @@ import { act, renderHook } from '@testing-library/react-hooks'; +import { ENTRIES_WITH_IDS } from '../../../common/constants.mock'; import { getUpdateExceptionListItemSchemaMock } from '../../../common/schemas/request/update_exception_list_item_schema.mock'; import { coreMock } from '../../../../../../src/core/public/mocks'; import * as api from '../api'; @@ -24,6 +25,10 @@ import { import { ExceptionsApi, useApi } from './use_api'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + const mockKibanaHttpService = coreMock.createStart().http; describe('useApi', () => { @@ -34,397 +39,428 @@ describe('useApi', () => { jest.clearAllMocks(); }); - test('it invokes "deleteExceptionListItemById" when "deleteExceptionItem" used', async () => { - const payload = getExceptionListItemSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnDeleteExceptionListItemById = jest - .spyOn(api, 'deleteExceptionListItemById') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - const { id, namespace_type: namespaceType } = payload; - - await result.current.deleteExceptionItem({ - id, - namespaceType, - onError: jest.fn(), - onSuccess: onSuccessMock, + describe('deleteExceptionItem', () => { + test('it invokes "deleteExceptionListItemById" when "deleteExceptionItem" used', async () => { + const payload = getExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnDeleteExceptionListItemById = jest + .spyOn(api, 'deleteExceptionListItemById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.deleteExceptionItem({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + + expect(spyOnDeleteExceptionListItemById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalled(); }); - - const expected: ApiCallByIdProps = { - http: mockKibanaHttpService, - id, - namespaceType, - signal: new AbortController().signal, - }; - - expect(spyOnDeleteExceptionListItemById).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); - }); - test('invokes "onError" callback if "deleteExceptionListItemById" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'deleteExceptionListItemById').mockRejectedValue(mockError); + test('invokes "onError" callback if "deleteExceptionListItemById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'deleteExceptionListItemById').mockRejectedValue(mockError); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); - const { id, namespace_type: namespaceType } = getExceptionListItemSchemaMock(); + const { id, namespace_type: namespaceType } = getExceptionListItemSchemaMock(); - await result.current.deleteExceptionItem({ - id, - namespaceType, - onError: onErrorMock, - onSuccess: jest.fn(), - }); + await result.current.deleteExceptionItem({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); - expect(onErrorMock).toHaveBeenCalledWith(mockError); + expect(onErrorMock).toHaveBeenCalledWith(mockError); + }); }); }); - test('it invokes "deleteExceptionListById" when "deleteExceptionList" used', async () => { - const payload = getExceptionListSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnDeleteExceptionListById = jest - .spyOn(api, 'deleteExceptionListById') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - const { id, namespace_type: namespaceType } = payload; - - await result.current.deleteExceptionList({ - id, - namespaceType, - onError: jest.fn(), - onSuccess: onSuccessMock, + describe('deleteExceptionList', () => { + test('it invokes "deleteExceptionListById" when "deleteExceptionList" used', async () => { + const payload = getExceptionListSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnDeleteExceptionListById = jest + .spyOn(api, 'deleteExceptionListById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.deleteExceptionList({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + + expect(spyOnDeleteExceptionListById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalled(); }); - - const expected: ApiCallByIdProps = { - http: mockKibanaHttpService, - id, - namespaceType, - signal: new AbortController().signal, - }; - - expect(spyOnDeleteExceptionListById).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); - }); - test('invokes "onError" callback if "deleteExceptionListById" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'deleteExceptionListById').mockRejectedValue(mockError); + test('invokes "onError" callback if "deleteExceptionListById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'deleteExceptionListById').mockRejectedValue(mockError); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); - const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); + const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); - await result.current.deleteExceptionList({ - id, - namespaceType, - onError: onErrorMock, - onSuccess: jest.fn(), - }); + await result.current.deleteExceptionList({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); - expect(onErrorMock).toHaveBeenCalledWith(mockError); + expect(onErrorMock).toHaveBeenCalledWith(mockError); + }); }); }); - test('it invokes "fetchExceptionListItemById" when "getExceptionItem" used', async () => { - const payload = getExceptionListItemSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnFetchExceptionListItemById = jest - .spyOn(api, 'fetchExceptionListItemById') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - const { id, namespace_type: namespaceType } = payload; - - await result.current.getExceptionItem({ - id, - namespaceType, - onError: jest.fn(), - onSuccess: onSuccessMock, + describe('getExceptionItem', () => { + test('it invokes "fetchExceptionListItemById" when "getExceptionItem" used', async () => { + const payload = getExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListItemById = jest + .spyOn(api, 'fetchExceptionListItemById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.getExceptionItem({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + const expectedExceptionListItem = { + ...getExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; + + expect(spyOnFetchExceptionListItemById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalledWith(expectedExceptionListItem); }); - - const expected: ApiCallByIdProps = { - http: mockKibanaHttpService, - id, - namespaceType, - signal: new AbortController().signal, - }; - - expect(spyOnFetchExceptionListItemById).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); - }); - test('invokes "onError" callback if "fetchExceptionListItemById" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'fetchExceptionListItemById').mockRejectedValue(mockError); + test('invokes "onError" callback if "fetchExceptionListItemById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'fetchExceptionListItemById').mockRejectedValue(mockError); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); - const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); + const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); - await result.current.getExceptionItem({ - id, - namespaceType, - onError: onErrorMock, - onSuccess: jest.fn(), - }); + await result.current.getExceptionItem({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); - expect(onErrorMock).toHaveBeenCalledWith(mockError); + expect(onErrorMock).toHaveBeenCalledWith(mockError); + }); }); }); - test('it invokes "fetchExceptionListById" when "getExceptionList" used', async () => { - const payload = getExceptionListSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnFetchExceptionListById = jest - .spyOn(api, 'fetchExceptionListById') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - const { id, namespace_type: namespaceType } = payload; - - await result.current.getExceptionList({ - id, - namespaceType, - onError: jest.fn(), - onSuccess: onSuccessMock, + describe('getExceptionList', () => { + test('it invokes "fetchExceptionListById" when "getExceptionList" used', async () => { + const payload = getExceptionListSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListById = jest + .spyOn(api, 'fetchExceptionListById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.getExceptionList({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + + expect(spyOnFetchExceptionListById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalled(); }); - - const expected: ApiCallByIdProps = { - http: mockKibanaHttpService, - id, - namespaceType, - signal: new AbortController().signal, - }; - - expect(spyOnFetchExceptionListById).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); - }); - - test('invokes "onError" callback if "fetchExceptionListById" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'fetchExceptionListById').mockRejectedValue(mockError); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); + test('invokes "onError" callback if "fetchExceptionListById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'fetchExceptionListById').mockRejectedValue(mockError); - const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); - await result.current.getExceptionList({ - id, - namespaceType, - onError: onErrorMock, - onSuccess: jest.fn(), - }); + const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); - expect(onErrorMock).toHaveBeenCalledWith(mockError); - }); - }); + await result.current.getExceptionList({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); - test('it invokes "fetchExceptionListsItemsByListIds" when "getExceptionItem" used', async () => { - const output = getFoundExceptionListItemSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnFetchExceptionListsItemsByListIds = jest - .spyOn(api, 'fetchExceptionListsItemsByListIds') - .mockResolvedValue(output); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.getExceptionListsItems({ - filterOptions: [], - lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }], - onError: jest.fn(), - onSuccess: onSuccessMock, - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - showDetectionsListsOnly: false, - showEndpointListsOnly: false, + expect(onErrorMock).toHaveBeenCalledWith(mockError); }); - - const expected: ApiCallByListIdProps = { - filterOptions: [], - http: mockKibanaHttpService, - listIds: ['list_id'], - namespaceTypes: ['single'], - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - signal: new AbortController().signal, - }; - - expect(spyOnFetchExceptionListsItemsByListIds).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); }); - test('it does not invoke "fetchExceptionListsItemsByListIds" if no listIds', async () => { - const output = getFoundExceptionListItemSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnFetchExceptionListsItemsByListIds = jest - .spyOn(api, 'fetchExceptionListsItemsByListIds') - .mockResolvedValue(output); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.getExceptionListsItems({ - filterOptions: [], - lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }], - onError: jest.fn(), - onSuccess: onSuccessMock, - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - showDetectionsListsOnly: false, - showEndpointListsOnly: true, + describe('getExceptionListsItems', () => { + test('it invokes "fetchExceptionListsItemsByListIds" when "getExceptionListsItems" used', async () => { + const output = getFoundExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListsItemsByListIds = jest + .spyOn(api, 'fetchExceptionListsItemsByListIds') + .mockResolvedValue(output); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.getExceptionListsItems({ + filterOptions: [], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], + onError: jest.fn(), + onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 1, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, + }); + + const expected: ApiCallByListIdProps = { + filterOptions: [], + http: mockKibanaHttpService, + listIds: ['list_id'], + namespaceTypes: ['single'], + pagination: { + page: 1, + perPage: 1, + total: 0, + }, + signal: new AbortController().signal, + }; + + expect(spyOnFetchExceptionListsItemsByListIds).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalledWith({ + exceptions: [{ ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }], + pagination: { + page: 1, + perPage: 1, + total: 1, + }, + }); }); + }); - expect(spyOnFetchExceptionListsItemsByListIds).not.toHaveBeenCalled(); - expect(onSuccessMock).toHaveBeenCalledWith({ - exceptions: [], - pagination: { - page: 0, - perPage: 20, - total: 0, - }, + test('it does not invoke "fetchExceptionListsItemsByListIds" if no listIds', async () => { + const output = getFoundExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListsItemsByListIds = jest + .spyOn(api, 'fetchExceptionListsItemsByListIds') + .mockResolvedValue(output); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.getExceptionListsItems({ + filterOptions: [], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], + onError: jest.fn(), + onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: true, + }); + + expect(spyOnFetchExceptionListsItemsByListIds).not.toHaveBeenCalled(); + expect(onSuccessMock).toHaveBeenCalledWith({ + exceptions: [], + pagination: { + page: 0, + perPage: 20, + total: 0, + }, + }); }); }); - }); - test('invokes "onError" callback if "fetchExceptionListsItemsByListIds" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'fetchExceptionListsItemsByListIds').mockRejectedValue(mockError); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.getExceptionListsItems({ - filterOptions: [], - lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }], - onError: onErrorMock, - onSuccess: jest.fn(), - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - showDetectionsListsOnly: false, - showEndpointListsOnly: false, + test('invokes "onError" callback if "fetchExceptionListsItemsByListIds" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'fetchExceptionListsItemsByListIds').mockRejectedValue(mockError); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.getExceptionListsItems({ + filterOptions: [], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], + onError: onErrorMock, + onSuccess: jest.fn(), + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, + }); + + expect(onErrorMock).toHaveBeenCalledWith(mockError); }); - - expect(onErrorMock).toHaveBeenCalledWith(mockError); }); }); - test('it invokes "addExceptionListItem" when "addExceptionListItem" used', async () => { - const payload = getExceptionListItemSchemaMock(); - const itemToCreate = getCreateExceptionListItemSchemaMock(); - const spyOnFetchExceptionListItemById = jest - .spyOn(api, 'addExceptionListItem') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.addExceptionListItem({ - listItem: itemToCreate, + describe('addExceptionListItem', () => { + test('it removes exception item entry ids', async () => { + const payload = getExceptionListItemSchemaMock(); + const itemToCreate = { ...getCreateExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }; + const spyOnFetchExceptionListItemById = jest + .spyOn(api, 'addExceptionListItem') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.addExceptionListItem({ + listItem: itemToCreate, + }); + + const expected: AddExceptionListItemProps = { + http: mockKibanaHttpService, + listItem: getCreateExceptionListItemSchemaMock(), + signal: new AbortController().signal, + }; + + expect(spyOnFetchExceptionListItemById).toHaveBeenCalledWith(expected); }); - - const expected: AddExceptionListItemProps = { - http: mockKibanaHttpService, - listItem: itemToCreate, - signal: new AbortController().signal, - }; - - expect(spyOnFetchExceptionListItemById).toHaveBeenCalledWith(expected); }); }); - test('it invokes "updateExceptionListItem" when "getExceptionItem" used', async () => { - const payload = getExceptionListItemSchemaMock(); - const itemToUpdate = getUpdateExceptionListItemSchemaMock(); - const spyOnUpdateExceptionListItem = jest - .spyOn(api, 'updateExceptionListItem') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.updateExceptionListItem({ - listItem: itemToUpdate, + describe('updateExceptionListItem', () => { + test('it removes exception item entry ids', async () => { + const payload = getExceptionListItemSchemaMock(); + const itemToUpdate = { ...getUpdateExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }; + const spyOnUpdateExceptionListItem = jest + .spyOn(api, 'updateExceptionListItem') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.updateExceptionListItem({ + listItem: itemToUpdate, + }); + + const expected: UpdateExceptionListItemProps = { + http: mockKibanaHttpService, + listItem: getUpdateExceptionListItemSchemaMock(), + signal: new AbortController().signal, + }; + + expect(spyOnUpdateExceptionListItem).toHaveBeenCalledWith(expected); }); - - const expected: UpdateExceptionListItemProps = { - http: mockKibanaHttpService, - listItem: itemToUpdate, - signal: new AbortController().signal, - }; - - expect(spyOnUpdateExceptionListItem).toHaveBeenCalledWith(expected); }); }); }); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts index b0c831ef3b857..9e4e338b09dbf 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts @@ -17,6 +17,7 @@ import { } from '../../../common/schemas'; import { ApiCallFindListsItemsMemoProps, ApiCallMemoProps, ApiListExportProps } from '../types'; import { getIdsAndNamespaces } from '../utils'; +import { transformInput, transformNewItemOutput, transformOutput } from '../transforms'; export interface ExceptionsApi { addExceptionListItem: (arg: { @@ -46,10 +47,11 @@ export const useApi = (http: HttpStart): ExceptionsApi => { listItem: CreateExceptionListItemSchema; }): Promise { const abortCtrl = new AbortController(); + const sanitizedItem: CreateExceptionListItemSchema = transformNewItemOutput(listItem); return Api.addExceptionListItem({ http, - listItem, + listItem: sanitizedItem, signal: abortCtrl.signal, }); }, @@ -124,12 +126,14 @@ export const useApi = (http: HttpStart): ExceptionsApi => { const abortCtrl = new AbortController(); try { - const item = await Api.fetchExceptionListItemById({ - http, - id, - namespaceType, - signal: abortCtrl.signal, - }); + const item = transformInput( + await Api.fetchExceptionListItemById({ + http, + id, + namespaceType, + signal: abortCtrl.signal, + }) + ); onSuccess(item); } catch (error) { onError(error); @@ -187,7 +191,10 @@ export const useApi = (http: HttpStart): ExceptionsApi => { signal: abortCtrl.signal, }); onSuccess({ - exceptions: data, + // This data transform is UI specific and useful for UI concerns + // to compensate for the differences and preferences of how ReactJS might prefer + // data vs. how we want to model data. View `transformInput` for more details + exceptions: data.map((item) => transformInput(item)), pagination: { page, perPage, @@ -214,10 +221,11 @@ export const useApi = (http: HttpStart): ExceptionsApi => { listItem: UpdateExceptionListItemSchema; }): Promise { const abortCtrl = new AbortController(); + const sanitizedItem: UpdateExceptionListItemSchema = transformOutput(listItem); return Api.updateExceptionListItem({ http, - listItem, + listItem: sanitizedItem, signal: abortCtrl.signal, }); }, diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts index d5d2638781879..1191b240d27bb 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts @@ -12,9 +12,14 @@ import * as api from '../api'; import { getFoundExceptionListItemSchemaMock } from '../../../common/schemas/response/found_exception_list_item_schema.mock'; import { ExceptionListItemSchema } from '../../../common/schemas'; import { UseExceptionListItemsSuccess, UseExceptionListProps } from '../types'; +import { transformInput } from '../transforms'; import { ReturnExceptionListAndItems, useExceptionListItems } from './use_exception_list_items'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + const mockKibanaHttpService = coreMock.createStart().http; describe('useExceptionListItems', () => { @@ -99,8 +104,9 @@ describe('useExceptionListItems', () => { await waitForNextUpdate(); await waitForNextUpdate(); - const expectedListItemsResult: ExceptionListItemSchema[] = getFoundExceptionListItemSchemaMock() - .data; + const expectedListItemsResult: ExceptionListItemSchema[] = getFoundExceptionListItemSchemaMock().data.map( + (item) => transformInput(item) + ); const expectedResult: UseExceptionListItemsSuccess = { exceptions: expectedListItemsResult, pagination: { page: 1, perPage: 1, total: 1 }, diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts index 50271530b42e7..b9a8628d2ceac 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts @@ -11,6 +11,7 @@ import { fetchExceptionListsItemsByListIds } from '../api'; import { FilterExceptionsOptions, Pagination, UseExceptionListProps } from '../types'; import { ExceptionListItemSchema } from '../../../common/schemas'; import { getIdsAndNamespaces } from '../utils'; +import { transformInput } from '../transforms'; type Func = () => void; export type ReturnExceptionListAndItems = [ @@ -95,8 +96,12 @@ export const useExceptionListItems = ({ } setLoading(false); } else { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { page, per_page, total, data } = await fetchExceptionListsItemsByListIds({ + const { + page, + per_page: perPage, + total, + data, + } = await fetchExceptionListsItemsByListIds({ filterOptions: filters, http, listIds: ids, @@ -108,20 +113,24 @@ export const useExceptionListItems = ({ signal: abortCtrl.signal, }); + // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes + // for context around the temporary `id` + const transformedData = data.map((item) => transformInput(item)); + if (isSubscribed) { setPagination({ page, - perPage: per_page, + perPage, total, }); - setExceptionListItems(data); + setExceptionListItems(transformedData); if (onSuccess != null) { onSuccess({ - exceptions: data, + exceptions: transformedData, pagination: { page, - perPage: per_page, + perPage, total, }, }); diff --git a/x-pack/plugins/lists/public/exceptions/transforms.test.ts b/x-pack/plugins/lists/public/exceptions/transforms.test.ts new file mode 100644 index 0000000000000..12b0f0bd8624a --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/transforms.test.ts @@ -0,0 +1,282 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ExceptionListItemSchema } from '../../common/schemas/response/exception_list_item_schema'; +import { UpdateExceptionListItemSchema } from '../../common/schemas/request/update_exception_list_item_schema'; +import { CreateExceptionListItemSchema } from '../../common/schemas/request/create_exception_list_item_schema'; +import { getCreateExceptionListItemSchemaMock } from '../../common/schemas/request/create_exception_list_item_schema.mock'; +import { getUpdateExceptionListItemSchemaMock } from '../../common/schemas/request/update_exception_list_item_schema.mock'; +import { getExceptionListItemSchemaMock } from '../../common/schemas/response/exception_list_item_schema.mock'; +import { ENTRIES_WITH_IDS } from '../../common/constants.mock'; +import { Entry, EntryMatch, EntryNested } from '../../common/schemas'; + +import { + addIdToExceptionItemEntries, + removeIdFromExceptionItemsEntries, + transformInput, + transformOutput, +} from './transforms'; + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + +describe('Exceptions transforms', () => { + describe('transformOutput', () => { + it('returns same output as input with stripped ids per entry - CreateExceptionListItemSchema', () => { + const mockCreateExceptionItem = { + ...getCreateExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; + const output = transformOutput(mockCreateExceptionItem); + const expectedOutput: CreateExceptionListItemSchema = getCreateExceptionListItemSchemaMock(); + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with stripped ids per entry - UpdateExceptionListItemSchema', () => { + const mockUpdateExceptionItem = { + ...getUpdateExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; + const output = transformOutput(mockUpdateExceptionItem); + const expectedOutput: UpdateExceptionListItemSchema = getUpdateExceptionListItemSchemaMock(); + + expect(output).toEqual(expectedOutput); + }); + }); + + describe('transformInput', () => { + it('returns same output as input with added ids per entry', () => { + const mockExceptionItem = getExceptionListItemSchemaMock(); + const output = transformInput(mockExceptionItem); + const expectedOutput: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; + + expect(output).toEqual(expectedOutput); + }); + }); + + describe('addIdToExceptionItemEntries', () => { + it('returns same output as input with added ids per entry', () => { + const mockExceptionItem: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + }; + const output = addIdToExceptionItemEntries(mockExceptionItem); + const expectedOutput: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as Entry & { id: string }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with added ids per nested entry', () => { + const mockExceptionItem: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + ], + }; + const output = addIdToExceptionItemEntries(mockExceptionItem); + const expectedOutput: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as EntryMatch & { id: string }, + ], + field: 'some.parentField', + id: '123', + type: 'nested', + } as EntryNested & { id: string }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + }); + + describe('removeIdFromExceptionItemsEntries', () => { + it('returns same output as input with stripped ids per entry - CreateExceptionListItemSchema', () => { + const mockCreateExceptionItem = { + ...getCreateExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as Entry & { id: string }, + ], + }; + const output = removeIdFromExceptionItemsEntries(mockCreateExceptionItem); + const expectedOutput: CreateExceptionListItemSchema = { + ...getCreateExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with stripped ids per nested entry - CreateExceptionListItemSchema', () => { + const mockCreateExceptionItem = { + ...getCreateExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as EntryMatch & { id: string }, + ], + field: 'some.parentField', + id: '123', + type: 'nested', + } as EntryNested & { id: string }, + ], + }; + const output = removeIdFromExceptionItemsEntries(mockCreateExceptionItem); + const expectedOutput: CreateExceptionListItemSchema = { + ...getCreateExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with stripped ids per entry - UpdateExceptionListItemSchema', () => { + const mockUpdateExceptionItem = { + ...getUpdateExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as Entry & { id: string }, + ], + }; + const output = removeIdFromExceptionItemsEntries(mockUpdateExceptionItem); + const expectedOutput: UpdateExceptionListItemSchema = { + ...getUpdateExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with stripped ids per nested entry - UpdateExceptionListItemSchema', () => { + const mockUpdateExceptionItem = { + ...getUpdateExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as EntryMatch & { id: string }, + ], + field: 'some.parentField', + id: '123', + type: 'nested', + } as EntryNested & { id: string }, + ], + }; + const output = removeIdFromExceptionItemsEntries(mockUpdateExceptionItem); + const expectedOutput: UpdateExceptionListItemSchema = { + ...getUpdateExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/transforms.ts b/x-pack/plugins/lists/public/exceptions/transforms.ts new file mode 100644 index 0000000000000..0791760611bf5 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/transforms.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { flow } from 'fp-ts/lib/function'; + +import { + CreateExceptionListItemSchema, + EntriesArray, + Entry, + ExceptionListItemSchema, + UpdateExceptionListItemSchema, +} from '../../common'; +import { addIdToItem, removeIdFromItem } from '../../common/shared_imports'; + +// These are a collection of transforms that are UI specific and useful for UI concerns +// that are inserted between the API and the actual user interface. In some ways these +// might be viewed as technical debt or to compensate for the differences and preferences +// of how ReactJS might prefer data vs. how we want to model data. Each function should have +// a description giving context around the transform. + +/** + * Transforms the output of exception items to compensate for technical debt or UI concerns such as + * ReactJS preferences for having ids within arrays if the data is not modeled that way. + * + * If you add a new transform of the output called "myNewTransform" do it + * in the form of: + * flow(removeIdFromExceptionItemsEntries, myNewTransform)(exceptionItem) + * + * @param exceptionItem The exceptionItem to transform the output of + * @returns The exceptionItem transformed from the output + */ +export const transformOutput = ( + exceptionItem: UpdateExceptionListItemSchema | ExceptionListItemSchema +): UpdateExceptionListItemSchema | ExceptionListItemSchema => + flow(removeIdFromExceptionItemsEntries)(exceptionItem); + +export const transformNewItemOutput = ( + exceptionItem: CreateExceptionListItemSchema +): CreateExceptionListItemSchema => flow(removeIdFromExceptionItemsEntries)(exceptionItem); + +/** + * Transforms the output of rules to compensate for technical debt or UI concerns such as + * ReactJS preferences for having ids within arrays if the data is not modeled that way. + * + * If you add a new transform of the input called "myNewTransform" do it + * in the form of: + * flow(addIdToExceptionItemEntries, myNewTransform)(exceptionItem) + * + * @param exceptionItem The exceptionItem to transform the output of + * @returns The exceptionItem transformed from the output + */ +export const transformInput = (exceptionItem: ExceptionListItemSchema): ExceptionListItemSchema => + flow(addIdToExceptionItemEntries)(exceptionItem); + +/** + * This adds an id to the incoming exception item entries as ReactJS prefers to have + * an id added to them for use as a stable id. Later if we decide to change the data + * model to have id's within the array then this code should be removed. If not, then + * this code should stay as an adapter for ReactJS. + * + * This does break the type system slightly as we are lying a bit to the type system as we return + * the same exceptionItem as we have previously but are augmenting the arrays with an id which TypeScript + * doesn't mind us doing here. However, downstream you will notice that you have an id when the type + * does not indicate it. In that case use (ExceptionItem & { id: string }) temporarily if you're using the id. If you're not, + * you can ignore the id and just use the normal TypeScript with ReactJS. + * + * @param exceptionItem The exceptionItem to add an id to the threat matches. + * @returns exceptionItem The exceptionItem but with id added to the exception item entries + */ +export const addIdToExceptionItemEntries = ( + exceptionItem: ExceptionListItemSchema +): ExceptionListItemSchema => { + const entries = exceptionItem.entries.map((entry) => { + if (entry.type === 'nested') { + return addIdToItem({ + ...entry, + entries: entry.entries.map((nestedEntry) => addIdToItem(nestedEntry)), + }); + } else { + return addIdToItem(entry); + } + }); + return { ...exceptionItem, entries }; +}; + +/** + * This removes an id from the exceptionItem entries as ReactJS prefers to have + * an id added to them for use as a stable id. Later if we decide to change the data + * model to have id's within the array then this code should be removed. If not, then + * this code should stay as an adapter for ReactJS. + * + * @param exceptionItem The exceptionItem to remove an id from the entries. + * @returns exceptionItem The exceptionItem but with id removed from the entries + */ +export const removeIdFromExceptionItemsEntries = ( + exceptionItem: T +): T => { + const { entries } = exceptionItem; + const entriesNoId = entries.map((entry) => { + if (entry.type === 'nested') { + return removeIdFromItem({ + ...entry, + entries: entry.entries.map((nestedEntry) => removeIdFromItem(nestedEntry)), + }); + } else { + return removeIdFromItem(entry); + } + }); + return { ...exceptionItem, entries: entriesNoId }; +}; diff --git a/x-pack/plugins/lists/public/exceptions/types.ts b/x-pack/plugins/lists/public/exceptions/types.ts index e37c03978c9f6..03cae387711f8 100644 --- a/x-pack/plugins/lists/public/exceptions/types.ts +++ b/x-pack/plugins/lists/public/exceptions/types.ts @@ -33,8 +33,6 @@ export interface Pagination { export type AddExceptionList = UpdateExceptionListSchema | CreateExceptionListSchema; -export type AddExceptionListItem = CreateExceptionListItemSchema | UpdateExceptionListItemSchema; - export interface PersistHookProps { http: HttpStart; onError: (arg: Error) => void; diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts index b039076305498..7e15bfa9a340e 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; import { EmbeddableFactoryDefinition, IContainer, @@ -13,8 +14,10 @@ import { import '../index.scss'; import { MAP_SAVED_OBJECT_TYPE, APP_ICON } from '../../common/constants'; import { getMapEmbeddableDisplayName } from '../../common/i18n_getters'; -import { MapByReferenceInput, MapEmbeddableInput } from './types'; +import { MapByReferenceInput, MapEmbeddableInput, MapByValueInput } from './types'; import { lazyLoadMapModules } from '../lazy_load_bundle'; +// @ts-expect-error +import { extractReferences } from '../../common/migrations/references'; export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { type = MAP_SAVED_OBJECT_TYPE; @@ -61,4 +64,16 @@ export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { parent ); }; + + extract(state: EmbeddableStateWithType) { + const maybeMapByValueInput = state as EmbeddableStateWithType | MapByValueInput; + + if ((maybeMapByValueInput as MapByValueInput).attributes !== undefined) { + const { references } = extractReferences(maybeMapByValueInput); + + return { state, references }; + } + + return { state, references: [] }; + } } diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx index 740d127e1b08d..344464bfe9590 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx @@ -14,6 +14,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, + EuiIconTip, EuiSelect, EuiSpacer, EuiSwitch, @@ -57,6 +58,24 @@ const TOGGLE_OFF = i18n.translate('xpack.ml.splom.toggleOff', { const sampleSizeOptions = [100, 1000, 10000].map((d) => ({ value: d, text: '' + d })); +interface OptionLabelWithIconTipProps { + label: string; + tooltip: string; +} + +const OptionLabelWithIconTip: FC = ({ label, tooltip }) => ( + <> + {label} + + +); + export interface ScatterplotMatrixProps { fields: string[]; index: string; @@ -252,9 +271,16 @@ export const ScatterplotMatrix: FC = ({ + } display="rowCompressed" fullWidth > @@ -276,9 +302,16 @@ export const ScatterplotMatrix: FC = ({ + } display="rowCompressed" fullWidth > @@ -292,9 +325,17 @@ export const ScatterplotMatrix: FC = ({ + } display="rowCompressed" fullWidth > @@ -310,9 +351,16 @@ export const ScatterplotMatrix: FC = ({ {resultsField !== undefined && legendType === undefined && ( + } display="rowCompressed" fullWidth > diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss index d946a0cf94032..65a72a1d4a48d 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss @@ -46,6 +46,7 @@ } .body { display: block; + height: 315px; } } & > li.has-body.active { diff --git a/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts b/x-pack/plugins/security_solution/common/add_remove_id_to_item.test.ts similarity index 100% rename from x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts rename to x-pack/plugins/security_solution/common/add_remove_id_to_item.test.ts diff --git a/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts b/x-pack/plugins/security_solution/common/add_remove_id_to_item.ts similarity index 100% rename from x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts rename to x-pack/plugins/security_solution/common/add_remove_id_to_item.ts diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 31b4cef1a9d45..c277ce369dca0 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -45,10 +45,10 @@ export const DEFAULT_RULE_REFRESH_INTERVAL_VALUE = 60000; // ms export const DEFAULT_RULE_REFRESH_IDLE_VALUE = 2700000; // ms export const DEFAULT_RULE_NOTIFICATION_QUERY_SIZE = 100; -// Document path where threat indicator fields are expected. Used as -// both the source of enrichment fields and the destination for enrichment in -// the generated detection alert -export const DEFAULT_INDICATOR_PATH = 'threat.indicator'; +// Document path where threat indicator fields are expected. Fields are used +// to enrich signals, and are copied to threat.indicator. +export const DEFAULT_INDICATOR_SOURCE_PATH = 'threatintel.indicator'; +export const INDICATOR_DESTINATION_PATH = 'threat.indicator'; export enum SecurityPageName { detections = 'detections', diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index d97820f010a80..bfe450d240b08 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -444,13 +444,19 @@ export const threat_technique = t.intersection([ ]); export type ThreatTechnique = t.TypeOf; export const threat_techniques = t.array(threat_technique); -export const threat = t.exact( - t.type({ - framework: threat_framework, - tactic: threat_tactic, - technique: threat_techniques, - }) -); +export const threat = t.intersection([ + t.exact( + t.type({ + framework: threat_framework, + tactic: threat_tactic, + }) + ), + t.exact( + t.partial({ + technique: threat_techniques, + }) + ), +]); export type Threat = t.TypeOf; export const threats = t.array(threat); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts index 93094e3445488..f3bef5ad7445f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts @@ -924,7 +924,7 @@ describe('add prepackaged rules schema', () => { expect(message.schema).toEqual({}); }); - test('You cannot send in an array of threat that are missing "technique"', () => { + test('You can send in an array of threat that are missing "technique"', () => { const payload: Omit & { threat: Array>>; } = { @@ -944,10 +944,21 @@ describe('add prepackaged rules schema', () => { const decoded = addPrepackagedRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "threat,technique"', - ]); - expect(message.schema).toEqual({}); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: AddPrepackagedRulesSchemaDecoded = { + ...getAddPrepackagedRulesSchemaDecodedMock(), + threat: [ + { + framework: 'fake', + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, + }, + ], + }; + expect(message.schema).toEqual(expected); }); test('You can optionally send in an array of false positives', () => { diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts index a59c873658411..2caedd2e01193 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts @@ -926,7 +926,7 @@ describe('import rules schema', () => { expect(message.schema).toEqual({}); }); - test('You cannot send in an array of threat that are missing "technique"', () => { + test('You can send in an array of threat that are missing "technique"', () => { const payload: Omit & { threat: Array>>; } = { @@ -946,10 +946,21 @@ describe('import rules schema', () => { const decoded = importRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "threat,technique"', - ]); - expect(message.schema).toEqual({}); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: ImportRulesSchemaDecoded = { + ...getImportRulesSchemaDecodedMock(), + threat: [ + { + framework: 'fake', + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, + }, + ], + }; + expect(message.schema).toEqual(expected); }); test('You can optionally send in an array of false positives', () => { diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts index 8cdb85a555451..3dfa12acc29d5 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts @@ -973,7 +973,7 @@ describe('patch_rules_schema', () => { expect(message.schema).toEqual({}); }); - test('threat is invalid when updated with missing technique', () => { + test('threat is valid when updated with missing technique', () => { const threat: Omit = [ { framework: 'fake', @@ -993,10 +993,8 @@ describe('patch_rules_schema', () => { const decoded = patchRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "threat,technique"', - ]); - expect(message.schema).toEqual({}); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); }); test('validates with timeline_id and timeline_title', () => { diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts index 3bae9551f4df7..a51c1f77844d5 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { DEFAULT_INDICATOR_PATH } from '../../../constants'; +import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../constants'; import { MachineLearningCreateSchema, MachineLearningUpdateSchema, @@ -57,7 +57,7 @@ export const getCreateThreatMatchRulesSchemaMock = ( rule_id: ruleId, threat_query: '*:*', threat_index: ['list-index'], - threat_indicator_path: DEFAULT_INDICATOR_PATH, + threat_indicator_path: DEFAULT_INDICATOR_SOURCE_PATH, threat_mapping: [ { entries: [ diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts index 6b8211b23088c..70ff921d3b334 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts @@ -618,7 +618,7 @@ describe('create rules schema', () => { expect(message.schema).toEqual({}); }); - test('You cannot send in an array of threat that are missing "technique"', () => { + test('You can send in an array of threat that are missing "technique"', () => { const payload = { ...getCreateRulesSchemaMock(), threat: [ @@ -636,10 +636,8 @@ describe('create rules schema', () => { const decoded = createRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "threat,technique"', - ]); - expect(message.schema).toEqual({}); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); }); test('You can optionally send in an array of false positives', () => { diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts index 8dc7427ed0933..730e2949d7a11 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { DEFAULT_INDICATOR_PATH } from '../../../constants'; +import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../constants'; import { getListArrayMock } from '../types/lists.mock'; import { RulesSchema } from './rules_schema'; @@ -151,7 +151,7 @@ export const getThreatMatchingSchemaPartialMock = (enabled = false): Partial { cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', newThreatIndicatorRule.riskScore); }); }); + + describe('Duplicates the indicator rule', () => { + beforeEach(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + goToManageAlertsDetectionRules(); + createCustomIndicatorRule(newThreatIndicatorRule); + reload(); + }); + + it('Allows the rule to be duplicated from the table', () => { + waitForKibana(); + duplicateFirstRule(); + cy.contains(RULE_NAME, `${newThreatIndicatorRule.name} [Duplicate]`); + }); + + it('Allows the rule to be duplicated from the edit screen', () => { + waitForKibana(); + goToRuleDetails(); + duplicateRuleFromMenu(); + goBackToAllRulesTable(); + reload(); + cy.contains(RULE_NAME, `${newThreatIndicatorRule.name} [Duplicate]`); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts new file mode 100644 index 0000000000000..154e90d509c61 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { newRule } from '../../objects/rule'; + +import { RULE_STATUS } from '../../screens/create_new_rule'; + +import { goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated } from '../../tasks/alerts'; +import { createCustomRule } from '../../tasks/api_calls/rules'; +import { goToRuleDetails } from '../../tasks/alerts_detection_rules'; +import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { openExceptionModalFromRuleSettings, goToExceptionsTab } from '../../tasks/rule_details'; +import { + addExceptionEntryFieldValue, + addExceptionEntryFieldValueOfItemX, + closeExceptionBuilderModal, +} from '../../tasks/exceptions'; +import { + ADD_AND_BTN, + ADD_OR_BTN, + ADD_NESTED_BTN, + ENTRY_DELETE_BTN, + FIELD_INPUT, + LOADING_SPINNER, + EXCEPTION_ITEM_CONTAINER, + ADD_EXCEPTIONS_BTN, +} from '../../screens/exceptions'; + +import { DETECTIONS_URL } from '../../urls/navigation'; +import { cleanKibana } from '../../tasks/common'; + +// NOTE: You might look at these tests and feel they're overkill, +// but the exceptions modal has a lot of logic making it difficult +// to test in enzyme and very small changes can inadvertently add +// bugs. As the complexity within the builder grows, these should +// ensure the most basic logic holds. +describe('Exceptions modal', () => { + before(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + waitForAlertsIndexToBeCreated(); + createCustomRule(newRule); + goToManageAlertsDetectionRules(); + goToRuleDetails(); + + cy.get(RULE_STATUS).should('have.text', '—'); + + // this is a made-up index that has just the necessary + // mappings to conduct tests, avoiding loading large + // amounts of data like in auditbeat_exceptions + esArchiverLoad('exceptions'); + + goToExceptionsTab(); + }); + + after(() => { + esArchiverUnload('exceptions'); + }); + + it('Does not overwrite values and-ed together', () => { + cy.get(ADD_EXCEPTIONS_BTN).click({ force: true }); + + // add multiple entries with invalid field values + addExceptionEntryFieldValue('agent.name', 0); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValue('@timestamp', 1); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValue('c', 2); + + // delete second item, invalid values 'a' and 'c' should remain + cy.get(ENTRY_DELETE_BTN).eq(1).click(); + cy.get(FIELD_INPUT).eq(0).should('have.text', 'agent.name'); + cy.get(FIELD_INPUT).eq(1).should('have.text', 'c'); + + closeExceptionBuilderModal(); + }); + + it('Does not overwrite values or-ed together', () => { + cy.get(ADD_EXCEPTIONS_BTN).click({ force: true }); + + // exception item 1 + addExceptionEntryFieldValueOfItemX('agent.name', 0, 0); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('user.id.keyword', 0, 1); + + // exception item 2 + cy.get(ADD_OR_BTN).click(); + addExceptionEntryFieldValueOfItemX('user.first', 1, 0); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('user.last', 1, 1); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('e', 1, 2); + + // delete single entry from exception item 2 + cy.get(ENTRY_DELETE_BTN).eq(3).click(); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(1) + .should('have.text', 'user.id.keyword'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'user.first'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(1).should('have.text', 'e'); + + // delete remaining entries in exception item 2 + cy.get(ENTRY_DELETE_BTN).eq(2).click(); + cy.get(ENTRY_DELETE_BTN).eq(2).click(); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(1) + .should('have.text', 'user.id.keyword'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).should('not.exist'); + + closeExceptionBuilderModal(); + }); + + it('Does not overwrite values of nested entry items', () => { + openExceptionModalFromRuleSettings(); + cy.get(LOADING_SPINNER).should('not.exist'); + + // exception item 1 + addExceptionEntryFieldValueOfItemX('agent.name', 0, 0); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('b', 0, 1); + + // exception item 2 with nested field + cy.get(ADD_OR_BTN).click(); + addExceptionEntryFieldValueOfItemX('c', 1, 0); + cy.get(ADD_NESTED_BTN).click(); + addExceptionEntryFieldValueOfItemX('user.id{downarrow}{enter}', 1, 1); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('last{downarrow}{enter}', 1, 3); + // This button will now read `Add non-nested button` + cy.get(ADD_NESTED_BTN).click(); + addExceptionEntryFieldValueOfItemX('@timestamp', 1, 4); + + // should have only deleted `user.id` + cy.get(ENTRY_DELETE_BTN).eq(4).click(); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(0).should('have.text', 'c'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(1).should('have.text', 'user'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(2).should('have.text', 'last'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(3) + .should('have.text', '@timestamp'); + + // deleting the last value of a nested entry, should delete the child and parent + cy.get(ENTRY_DELETE_BTN).eq(4).click(); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(0).should('have.text', 'c'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(1) + .should('have.text', '@timestamp'); + + closeExceptionBuilderModal(); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index 68baad7d3d259..30365c9bd4c70 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -17,6 +17,12 @@ export const DELETE_RULE_ACTION_BTN = '[data-test-subj="deleteRuleAction"]'; export const EDIT_RULE_ACTION_BTN = '[data-test-subj="editRuleAction"]'; +export const DUPLICATE_RULE_ACTION_BTN = '[data-test-subj="duplicateRuleAction"]'; + +export const DUPLICATE_RULE_MENU_PANEL_BTN = '[data-test-subj="rules-details-duplicate-rule"]'; + +export const REFRESH_BTN = '[data-test-subj="refreshRulesAction"] button'; + export const DELETE_RULE_BULK_BTN = '[data-test-subj="deleteRuleBulk"]'; export const ELASTIC_RULES_BTN = '[data-test-subj="showElasticRulesFilterButton"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts index 7cd273b1db746..2479b76cf1de4 100644 --- a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts @@ -24,6 +24,20 @@ export const OPERATOR_INPUT = '[data-test-subj="operatorAutocompleteComboBox"]'; export const VALUES_INPUT = '[data-test-subj="valuesAutocompleteMatch"] [data-test-subj="comboBoxInput"]'; +export const ADD_AND_BTN = '[data-test-subj="exceptionsAndButton"]'; + +export const ADD_OR_BTN = '[data-test-subj="exceptionsOrButton"]'; + +export const ADD_NESTED_BTN = '[data-test-subj="exceptionsNestedButton"]'; + +export const ENTRY_DELETE_BTN = '[data-test-subj="builderItemEntryDeleteButton"]'; + +export const FIELD_INPUT_LIST_BTN = '[data-test-subj="comboBoxToggleListButton"]'; + +export const CANCEL_BTN = '[data-test-subj="cancelExceptionAddButton"]'; + +export const BUILDER_MODAL_BODY = '[data-test-subj="exceptionsBuilderWrapper"]'; + export const EXCEPTIONS_TABLE_TAB = '[data-test-subj="allRulesTableTab-exceptions"]'; export const EXCEPTIONS_TABLE = '[data-test-subj="exceptions-table"]'; @@ -43,3 +57,5 @@ export const EXCEPTIONS_TABLE_LIST_NAME = '[data-test-subj="exceptionsTableName" export const EXCEPTIONS_TABLE_MODAL = '[data-test-subj="referenceErrorModal"]'; export const EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN = '[data-test-subj="confirmModalConfirmButton"]'; + +export const EXCEPTION_ITEM_CONTAINER = '[data-test-subj="exceptionEntriesContainer"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 3553889449e6d..10644e046a68b 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -31,6 +31,8 @@ import { RULE_AUTO_REFRESH_IDLE_MODAL_CONTINUE, rowsPerPageSelector, pageSelector, + DUPLICATE_RULE_ACTION_BTN, + DUPLICATE_RULE_MENU_PANEL_BTN, } from '../screens/alerts_detection_rules'; import { ALL_ACTIONS, DELETE_RULE } from '../screens/rule_details'; @@ -45,6 +47,33 @@ export const editFirstRule = () => { cy.get(EDIT_RULE_ACTION_BTN).click(); }; +export const duplicateFirstRule = () => { + cy.get(COLLAPSED_ACTION_BTN).should('be.visible'); + cy.get(COLLAPSED_ACTION_BTN).first().click({ force: true }); + cy.get(DUPLICATE_RULE_ACTION_BTN).should('be.visible'); + cy.get(DUPLICATE_RULE_ACTION_BTN).click(); +}; + +/** + * Duplicates the rule from the menu and does additional + * pipes and checking that the elements are present on the + * page as well as removed when doing the clicks to help reduce + * flake. + */ +export const duplicateRuleFromMenu = () => { + cy.get(ALL_ACTIONS).should('be.visible'); + cy.root() + .pipe(($el) => { + $el.find(ALL_ACTIONS).trigger('click'); + return $el.find(DUPLICATE_RULE_MENU_PANEL_BTN); + }) + .should(($el) => expect($el).to.be.visible); + // Because of a fade effect and fast clicking this can produce more than one click + cy.get(DUPLICATE_RULE_MENU_PANEL_BTN) + .pipe(($el) => $el.trigger('click')) + .should('not.be.visible'); +}; + export const deleteFirstRule = () => { cy.get(COLLAPSED_ACTION_BTN).first().click({ force: true }); cy.get(DELETE_RULE_ACTION_BTN).click(); @@ -87,9 +116,19 @@ export const reloadDeletedRules = () => { cy.get(RELOAD_PREBUILT_RULES_BTN).click({ force: true }); }; +/** + * Selects the number of rules. Since there can be missing click handlers + * when the page loads at first, we use a pipe and a trigger of click + * on it and then check to ensure that it is checked before continuing + * with the tests. + * @param numberOfRules The number of rules to click/check + */ export const selectNumberOfRules = (numberOfRules: number) => { for (let i = 0; i < numberOfRules; i++) { - cy.get(RULE_CHECKBOX).eq(i).click({ force: true }); + cy.get(RULE_CHECKBOX) + .eq(i) + .pipe(($el) => $el.trigger('click')) + .should('be.checked'); } }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts index ab6063f5809c4..99f5bd9c20230 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CustomRule } from '../../objects/rule'; +import { CustomRule, ThreatIndicatorRule } from '../../objects/rule'; export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing') => cy.request({ @@ -29,6 +29,44 @@ export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing') => failOnStatusCode: false, }); +export const createCustomIndicatorRule = (rule: ThreatIndicatorRule, ruleId = 'rule_testing') => + cy.request({ + method: 'POST', + url: 'api/detection_engine/rules', + body: { + rule_id: ruleId, + risk_score: parseInt(rule.riskScore, 10), + description: rule.description, + interval: '10s', + name: rule.name, + severity: rule.severity.toLocaleLowerCase(), + type: 'threat_match', + threat_mapping: [ + { + entries: [ + { + field: rule.indicatorMapping, + type: 'mapping', + value: rule.indicatorMapping, + }, + ], + }, + ], + threat_query: '*:*', + threat_language: 'kuery', + threat_filters: [], + threat_index: ['mock*'], + threat_indicator_path: '', + from: 'now-17520h', + index: ['exceptions-*'], + query: rule.customQuery || '*:*', + language: 'kuery', + enabled: false, + }, + headers: { 'kbn-xsrf': 'cypress-creds' }, + failOnStatusCode: false, + }); + export const createCustomRuleActivated = (rule: CustomRule, ruleId = '1') => cy.request({ method: 'POST', diff --git a/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts b/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts new file mode 100644 index 0000000000000..97e93ef8194a4 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Exception } from '../objects/exception'; +import { + FIELD_INPUT, + OPERATOR_INPUT, + VALUES_INPUT, + CANCEL_BTN, + BUILDER_MODAL_BODY, + EXCEPTION_ITEM_CONTAINER, +} from '../screens/exceptions'; + +export const addExceptionEntryFieldValueOfItemX = ( + field: string, + itemIndex = 0, + fieldIndex = 0 +) => { + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(itemIndex) + .find(FIELD_INPUT) + .eq(fieldIndex) + .type(`${field}{enter}`); + cy.get(BUILDER_MODAL_BODY).click(); +}; + +export const addExceptionEntryFieldValue = (field: string, index = 0) => { + cy.get(FIELD_INPUT).eq(index).type(`${field}{enter}`); + cy.get(BUILDER_MODAL_BODY).click(); +}; + +export const addExceptionEntryOperatorValue = (operator: string, index = 0) => { + cy.get(OPERATOR_INPUT).eq(index).type(`${operator}{enter}`); + cy.get(BUILDER_MODAL_BODY).click(); +}; + +export const addExceptionEntryValue = (values: string[], index = 0) => { + values.forEach((value) => { + cy.get(VALUES_INPUT).eq(index).type(`${value}{enter}`); + }); + cy.get(BUILDER_MODAL_BODY).click(); +}; + +export const addExceptionEntry = (exception: Exception, index = 0) => { + addExceptionEntryFieldValue(exception.field, index); + addExceptionEntryOperatorValue(exception.operator, index); + addExceptionEntryValue(exception.values, index); +}; + +export const addNestedExceptionEntry = (exception: Exception, index = 0) => { + addExceptionEntryFieldValue(exception.field, index); + addExceptionEntryOperatorValue(exception.operator, index); + addExceptionEntryValue(exception.values, index); +}; + +export const closeExceptionBuilderModal = () => { + cy.get(CANCEL_BTN).click(); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index 57037e9f269b4..411f326a0ace6 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -54,6 +54,12 @@ export const addsException = (exception: Exception) => { cy.get(CONFIRM_BTN).should('not.exist'); }; +export const openExceptionModalFromRuleSettings = () => { + cy.get(ADD_EXCEPTIONS_BTN).click(); + cy.get(LOADING_SPINNER).should('not.exist'); + cy.get(FIELD_INPUT).should('be.visible'); +}; + export const addsExceptionFromRuleSettings = (exception: Exception) => { cy.get(ADD_EXCEPTIONS_BTN).click(); cy.get(LOADING_SPINNER).should('exist'); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts index 0867dc41eeb78..77c263385df0a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts @@ -87,7 +87,7 @@ export const PRIORITY = i18n.translate( export const ALERT_FIELDS_LABEL = i18n.translate( 'xpack.securitySolution.components.connectors.serviceNow.alertFieldsTitle', { - defaultMessage: 'Fields associated with alerts', + defaultMessage: 'Select Observables to push', } ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index b083ecbafeb2c..3a2170d126a24 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -466,7 +466,9 @@ export const AddExceptionModal = memo(function AddExceptionModal({ )} {fetchOrCreateListError == null && ( - {i18n.CANCEL} + + {i18n.CANCEL} + { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { }).onChange([{ label: 'machine.os' }]); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'machine.os', operator: 'included', type: 'match', value: '' }, + { id: '123', field: 'machine.os', operator: 'included', type: 'match', value: '' }, 0 ); }); @@ -445,6 +456,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { }).onChange([{ label: 'is not' }]); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'ip', operator: 'excluded', type: 'match', value: '1234' }, + { id: '123', field: 'ip', operator: 'excluded', type: 'match', value: '1234' }, 0 ); }); @@ -480,6 +492,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { }).onCreateOption('126.45.211.34'); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'ip', operator: 'excluded', type: 'match', value: '126.45.211.34' }, + { id: '123', field: 'ip', operator: 'excluded', type: 'match', value: '126.45.211.34' }, 0 ); }); @@ -515,6 +528,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { }).onCreateOption('126.45.211.34'); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'ip', operator: 'included', type: 'match_any', value: ['126.45.211.34'] }, + { id: '123', field: 'ip', operator: 'included', type: 'match_any', value: ['126.45.211.34'] }, 0 ); }); @@ -550,6 +564,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { expect(mockOnChange).toHaveBeenCalledWith( { + id: '123', field: 'ip', operator: 'excluded', type: 'list', @@ -590,6 +606,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { wrapper = mount( = ({ ); } else { - return comboBox; + return ( + + {comboBox} + + ); } }, [handleFieldChange, indexPattern, entry, listType] @@ -176,7 +180,11 @@ export const BuilderEntryItem: React.FC = ({ ); } else { - return comboBox; + return ( + + {comboBox} + + ); } }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx index 402496ef00f66..f9afa48408e39 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx @@ -111,35 +111,38 @@ export const BuilderExceptionListItemComponent = React.memo - {entries.map((item, index) => ( - - - {item.nested === 'child' && } - - { + const key = (item as typeof item & { id?: string }).id ?? `${index}`; + return ( + + + {item.nested === 'child' && } + + + + - - - - - ))} + + + ); + })}
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx index ea948ab9b3b5c..8d0f042e7a498 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx @@ -28,7 +28,15 @@ import { } from '../../autocomplete/operators'; import { BuilderEntry, ExceptionsBuilderExceptionItem, FormattedBuilderEntry } from '../types'; import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; -import { Entry, EntryNested } from '../../../../lists_plugin_deps'; +import { + EntryMatch, + EntryMatchAny, + EntryNested, + EntryList, + EntryExists, + OperatorTypeEnum, + OperatorEnum, +} from '../../../../shared_imports'; import { getEntryFromOperator, @@ -46,6 +54,31 @@ import { getCorrespondingKeywordField, } from './helpers'; import { OperatorOption } from '../../autocomplete/types'; +import { ENTRIES_WITH_IDS } from '../../../../../../lists/common/constants.mock'; + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + +const getEntryNestedWithIdMock = () => ({ + id: '123', + ...getEntryNestedMock(), +}); + +const getEntryExistsWithIdMock = () => ({ + id: '123', + ...getEntryExistsMock(), +}); + +const getEntryMatchWithIdMock = () => ({ + id: '123', + ...getEntryMatchMock(), +}); + +const getEntryMatchAnyWithIdMock = () => ({ + id: '123', + ...getEntryMatchAnyMock(), +}); const getMockIndexPattern = (): IIndexPattern => ({ id: '1234', @@ -54,6 +87,7 @@ const getMockIndexPattern = (): IIndexPattern => ({ }); const getMockBuilderEntry = (): FormattedBuilderEntry => ({ + id: '123', field: getField('ip'), operator: isOperator, value: 'some value', @@ -64,15 +98,16 @@ const getMockBuilderEntry = (): FormattedBuilderEntry => ({ }); const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ + id: '123', field: getField('nestedField.child'), operator: isOperator, value: 'some value', nested: 'child', parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchMock(), field: 'child' }], + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], }, parentIndex: 0, }, @@ -81,6 +116,7 @@ const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ }); const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ + id: '123', field: { ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }, operator: isOperator, value: undefined, @@ -225,15 +261,16 @@ describe('Exception builder helpers', () => { test('it returns nested fields that match parent value when "item.nested" is "child"', () => { const payloadItem: FormattedBuilderEntry = { + id: '123', field: getEndpointField('file.Ext.code_signature.status'), operator: isOperator, value: 'some value', nested: 'child', parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'file.Ext.code_signature', - entries: [{ ...getEntryMatchMock(), field: 'child' }], + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], }, parentIndex: 0, }, @@ -351,7 +388,7 @@ describe('Exception builder helpers', () => { ], }; const payloadItem: BuilderEntry = { - ...getEntryMatchMock(), + ...getEntryMatchWithIdMock(), field: 'machine.os.raw.text', value: 'some os', }; @@ -363,6 +400,7 @@ describe('Exception builder helpers', () => { undefined ); const expected: FormattedBuilderEntry = { + id: '123', entryIndex: 0, field: { name: 'machine.os.raw.text', @@ -385,11 +423,11 @@ describe('Exception builder helpers', () => { test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: BuilderEntry = { ...getEntryMatchMock(), field: 'child' }; + const payloadItem: BuilderEntry = { ...getEntryMatchWithIdMock(), field: 'child' }; const payloadParent: EntryNested = { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchMock(), field: 'child' }], + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], }; const output = getFormattedBuilderEntry( payloadIndexPattern, @@ -399,6 +437,7 @@ describe('Exception builder helpers', () => { 1 ); const expected: FormattedBuilderEntry = { + id: '123', entryIndex: 0, field: { aggregatable: false, @@ -419,9 +458,10 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: { parent: { + id: '123', entries: [{ ...payloadItem }], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, parentIndex: 1, }, @@ -433,7 +473,11 @@ describe('Exception builder helpers', () => { test('it returns non nested "FormattedBuilderEntry" when "parent" and "parentIndex" are not defined', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: BuilderEntry = { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }; + const payloadItem: BuilderEntry = { + ...getEntryMatchWithIdMock(), + field: 'ip', + value: 'some ip', + }; const output = getFormattedBuilderEntry( payloadIndexPattern, payloadItem, @@ -442,6 +486,7 @@ describe('Exception builder helpers', () => { undefined ); const expected: FormattedBuilderEntry = { + id: '123', entryIndex: 0, field: { aggregatable: true, @@ -465,14 +510,14 @@ describe('Exception builder helpers', () => { describe('#isEntryNested', () => { test('it returns "false" if payload is not of type EntryNested', () => { - const payload: BuilderEntry = getEntryMatchMock(); + const payload: BuilderEntry = getEntryMatchWithIdMock(); const output = isEntryNested(payload); const expected = false; expect(output).toEqual(expected); }); test('it returns "true if payload is of type EntryNested', () => { - const payload: EntryNested = getEntryNestedMock(); + const payload: EntryNested = getEntryNestedWithIdMock(); const output = isEntryNested(payload); const expected = true; expect(output).toEqual(expected); @@ -482,10 +527,11 @@ describe('Exception builder helpers', () => { describe('#getFormattedBuilderEntries', () => { test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItems: BuilderEntry[] = [getEntryMatchMock()]; + const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()]; const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); const expected: FormattedBuilderEntry[] = [ { + id: '123', entryIndex: 0, field: undefined, nested: undefined, @@ -501,12 +547,13 @@ describe('Exception builder helpers', () => { test('it returns formatted entries when no nested entries exist', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); const payloadItems: BuilderEntry[] = [ - { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }, - { ...getEntryMatchAnyMock(), field: 'extension', value: ['some extension'] }, + { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, + { ...getEntryMatchAnyWithIdMock(), field: 'extension', value: ['some extension'] }, ]; const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); const expected: FormattedBuilderEntry[] = [ { + id: '123', entryIndex: 0, field: { aggregatable: true, @@ -525,6 +572,7 @@ describe('Exception builder helpers', () => { correspondingKeywordField: undefined, }, { + id: '123', entryIndex: 1, field: { aggregatable: true, @@ -549,18 +597,19 @@ describe('Exception builder helpers', () => { test('it returns formatted entries when nested entries exist', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); const payloadParent: EntryNested = { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchMock(), field: 'child' }], + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], }; const payloadItems: BuilderEntry[] = [ - { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }, + { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, { ...payloadParent }, ]; const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); const expected: FormattedBuilderEntry[] = [ { + id: '123', entryIndex: 0, field: { aggregatable: true, @@ -579,6 +628,7 @@ describe('Exception builder helpers', () => { correspondingKeywordField: undefined, }, { + id: '123', entryIndex: 1, field: { aggregatable: false, @@ -594,6 +644,7 @@ describe('Exception builder helpers', () => { correspondingKeywordField: undefined, }, { + id: '123', entryIndex: 0, field: { aggregatable: false, @@ -614,16 +665,18 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: { parent: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'some host name', }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, parentIndex: 1, }, @@ -637,15 +690,19 @@ describe('Exception builder helpers', () => { describe('#getUpdatedEntriesOnDelete', () => { test('it removes entry corresponding to "entryIndex"', () => { - const payloadItem: ExceptionsBuilderExceptionItem = { ...getExceptionListItemSchemaMock() }; + const payloadItem: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; const output = getUpdatedEntriesOnDelete(payloadItem, 0, null); const expected: ExceptionsBuilderExceptionItem = { ...getExceptionListItemSchemaMock(), entries: [ { + id: '123', field: 'some.not.nested.field', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'some value', }, ], @@ -658,15 +715,17 @@ describe('Exception builder helpers', () => { ...getExceptionListItemSchemaMock(), entries: [ { - ...getEntryNestedMock(), - entries: [{ ...getEntryExistsMock() }, { ...getEntryMatchAnyMock() }], + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryExistsWithIdMock() }, { ...getEntryMatchAnyWithIdMock() }], }, ], }; const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); const expected: ExceptionsBuilderExceptionItem = { ...getExceptionListItemSchemaMock(), - entries: [{ ...getEntryNestedMock(), entries: [{ ...getEntryMatchAnyMock() }] }], + entries: [ + { ...getEntryNestedWithIdMock(), entries: [{ ...getEntryMatchAnyWithIdMock() }] }, + ], }; expect(output).toEqual(expected); }); @@ -676,8 +735,8 @@ describe('Exception builder helpers', () => { ...getExceptionListItemSchemaMock(), entries: [ { - ...getEntryNestedMock(), - entries: [{ ...getEntryExistsMock() }], + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryExistsWithIdMock() }], }, ], }; @@ -698,10 +757,11 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatch & { id?: string } = { + id: '123', field: 'ip', operator: 'excluded', - type: 'match', + type: OperatorTypeEnum.MATCH, value: 'I should stay the same', }; expect(output).toEqual(expected); @@ -715,10 +775,11 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatch & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'I should stay the same', }; expect(output).toEqual(expected); @@ -732,10 +793,11 @@ describe('Exception builder helpers', () => { value: ['I should stay the same'], }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatch & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: '', }; expect(output).toEqual(expected); @@ -749,10 +811,11 @@ describe('Exception builder helpers', () => { value: ['I should stay the same'], }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatchAny & { id?: string } = { + id: '123', field: 'ip', operator: 'excluded', - type: 'match_any', + type: OperatorTypeEnum.MATCH_ANY, value: ['I should stay the same'], }; expect(output).toEqual(expected); @@ -766,10 +829,11 @@ describe('Exception builder helpers', () => { value: ['I should stay the same'], }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatchAny & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', - type: 'match_any', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, value: ['I should stay the same'], }; expect(output).toEqual(expected); @@ -783,10 +847,11 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatchAny & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', - type: 'match_any', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, value: [], }; expect(output).toEqual(expected); @@ -799,7 +864,8 @@ describe('Exception builder helpers', () => { operator: existsOperator, }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryExists & { id?: string } = { + id: '123', field: 'ip', operator: 'excluded', type: 'exists', @@ -814,9 +880,10 @@ describe('Exception builder helpers', () => { operator: doesNotExistOperator, }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryExists & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', + operator: OperatorEnum.INCLUDED, type: 'exists', }; expect(output).toEqual(expected); @@ -830,9 +897,10 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryExists & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', + operator: OperatorEnum.INCLUDED, type: 'exists', }; expect(output).toEqual(expected); @@ -846,9 +914,10 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryList & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', + operator: OperatorEnum.INCLUDED, type: 'list', list: { id: '', type: 'ip' }, }; @@ -943,12 +1012,21 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); const payloadIFieldType: IFieldType = getField('nestedField.child'); const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { - entries: [{ field: 'child', operator: 'included', type: 'match', value: '' }], + id: '123', + entries: [ + { + id: '123', + field: 'child', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: '', + }, + ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -959,24 +1037,34 @@ describe('Exception builder helpers', () => { ...getMockNestedBuilderEntry(), parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchMock(), field: 'child' }, getEntryMatchAnyMock()], + entries: [ + { ...getEntryMatchWithIdMock(), field: 'child' }, + getEntryMatchAnyWithIdMock(), + ], }, parentIndex: 0, }, }; const payloadIFieldType: IFieldType = getField('nestedField.child'); const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ - { field: 'child', operator: 'included', type: 'match', value: '' }, - getEntryMatchAnyMock(), + { + id: '123', + field: 'child', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: '', + }, + getEntryMatchAnyWithIdMock(), ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -986,12 +1074,13 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); const payloadIFieldType: IFieldType = getField('ip'); const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', field: 'ip', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: '', }, }; @@ -1004,8 +1093,14 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); const payloadOperator: OperatorOption = isNotOperator; const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry; index: number } = { - updatedEntry: { field: 'ip', type: 'match', value: 'some value', operator: 'excluded' }, + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + updatedEntry: { + id: '123', + field: 'ip', + type: OperatorTypeEnum.MATCH, + value: 'some value', + operator: 'excluded', + }, index: 0, }; expect(output).toEqual(expected); @@ -1015,8 +1110,14 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); const payloadOperator: OperatorOption = isOneOfOperator; const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry; index: number } = { - updatedEntry: { field: 'ip', type: 'match_any', value: [], operator: 'included' }, + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + updatedEntry: { + id: '123', + field: 'ip', + type: OperatorTypeEnum.MATCH_ANY, + value: [], + operator: OperatorEnum.INCLUDED, + }, index: 0, }; expect(output).toEqual(expected); @@ -1026,19 +1127,21 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); const payloadOperator: OperatorOption = isNotOperator; const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'excluded', - type: 'match', + operator: OperatorEnum.EXCLUDED, + type: OperatorTypeEnum.MATCH, value: 'some value', }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1048,19 +1151,21 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); const payloadOperator: OperatorOption = isOneOfOperator; const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'included', - type: 'match_any', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, value: [], }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1071,8 +1176,14 @@ describe('Exception builder helpers', () => { test('it returns entry with updated value', () => { const payload: FormattedBuilderEntry = getMockBuilderEntry(); const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry; index: number } = { - updatedEntry: { field: 'ip', type: 'match', value: 'jibber jabber', operator: 'included' }, + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + updatedEntry: { + id: '123', + field: 'ip', + type: OperatorTypeEnum.MATCH, + value: 'jibber jabber', + operator: OperatorEnum.INCLUDED, + }, index: 0, }; expect(output).toEqual(expected); @@ -1081,8 +1192,14 @@ describe('Exception builder helpers', () => { test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { const payload: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry; index: number } = { - updatedEntry: { field: '', type: 'match', value: 'jibber jabber', operator: 'included' }, + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + updatedEntry: { + id: '123', + field: '', + type: OperatorTypeEnum.MATCH, + value: 'jibber jabber', + operator: OperatorEnum.INCLUDED, + }, index: 0, }; expect(output).toEqual(expected); @@ -1091,19 +1208,21 @@ describe('Exception builder helpers', () => { test('it returns nested entry with updated value', () => { const payload: FormattedBuilderEntry = getMockNestedBuilderEntry(); const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'jibber jabber', }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1112,19 +1231,21 @@ describe('Exception builder helpers', () => { test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { const payload: FormattedBuilderEntry = { ...getMockNestedBuilderEntry(), field: undefined }; const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: '', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'jibber jabber', }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1139,12 +1260,13 @@ describe('Exception builder helpers', () => { value: ['some value'], }; const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { updatedEntry: { + id: '123', field: 'ip', - type: 'match_any', + type: OperatorTypeEnum.MATCH_ANY, value: ['jibber jabber'], - operator: 'included', + operator: OperatorEnum.INCLUDED, }, index: 0, }; @@ -1159,12 +1281,13 @@ describe('Exception builder helpers', () => { field: undefined, }; const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { updatedEntry: { + id: '123', field: '', - type: 'match_any', + type: OperatorTypeEnum.MATCH_ANY, value: ['jibber jabber'], - operator: 'included', + operator: OperatorEnum.INCLUDED, }, index: 0, }; @@ -1176,27 +1299,29 @@ describe('Exception builder helpers', () => { ...getMockNestedBuilderEntry(), parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchAnyMock(), field: 'child' }], + entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], }, parentIndex: 0, }, }; const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'included', - type: 'match_any', + type: OperatorTypeEnum.MATCH_ANY, value: ['jibber jabber'], + operator: OperatorEnum.INCLUDED, }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1208,27 +1333,29 @@ describe('Exception builder helpers', () => { field: undefined, parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchAnyMock(), field: 'child' }], + entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], }, parentIndex: 0, }, }; const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: '', - operator: 'included', - type: 'match_any', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, value: ['jibber jabber'], }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1243,12 +1370,13 @@ describe('Exception builder helpers', () => { value: '1234', }; const output = getEntryOnListChange(payload, getListResponseMock()); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { updatedEntry: { + id: '123', field: 'ip', type: 'list', list: { id: 'some-list-id', type: 'ip' }, - operator: 'included', + operator: OperatorEnum.INCLUDED, }, index: 0, }; @@ -1263,12 +1391,13 @@ describe('Exception builder helpers', () => { field: undefined, }; const output = getEntryOnListChange(payload, getListResponseMock()); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { updatedEntry: { + id: '123', field: '', type: 'list', list: { id: 'some-list-id', type: 'ip' }, - operator: 'included', + operator: OperatorEnum.INCLUDED, }, index: 0, }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx index a08f869b41d6f..8afdbce68c69a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx @@ -5,15 +5,15 @@ * 2.0. */ +import uuid from 'uuid'; + +import { addIdToItem } from '../../../../../common/add_remove_id_to_item'; import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common'; import { Entry, OperatorTypeEnum, EntryNested, ExceptionListType, - EntryMatch, - EntryMatchAny, - EntryExists, entriesList, ListSchema, OperatorEnum, @@ -160,6 +160,7 @@ export const getFormattedBuilderEntry = ( ? { ...foundField, name: foundField.name.split('.').slice(-1)[0] } : foundField, correspondingKeywordField, + id: item.id ?? `${itemIndex}`, operator: getExceptionOperatorSelect(item), value: getEntryValue(item), nested: 'child', @@ -169,6 +170,7 @@ export const getFormattedBuilderEntry = ( } else { return { field: foundField, + id: item.id ?? `${itemIndex}`, correspondingKeywordField, operator: getExceptionOperatorSelect(item), value: getEntryValue(item), @@ -215,6 +217,7 @@ export const getFormattedBuilderEntries = ( } else { const parentEntry: FormattedBuilderEntry = { operator: isOperator, + id: item.id ?? `${index}`, nested: 'parent', field: isNewNestedEntry ? undefined @@ -265,7 +268,7 @@ export const getUpdatedEntriesOnDelete = ( const itemOfInterest: BuilderEntry = exceptionItem.entries[nestedParentIndex ?? entryIndex]; if (nestedParentIndex != null && itemOfInterest.type === OperatorTypeEnum.NESTED) { - const updatedEntryEntries: Array = [ + const updatedEntryEntries = [ ...itemOfInterest.entries.slice(0, entryIndex), ...itemOfInterest.entries.slice(entryIndex + 1), ]; @@ -282,6 +285,7 @@ export const getUpdatedEntriesOnDelete = ( const { field } = itemOfInterest; const updatedItemOfInterest: EntryNested | EmptyNestedEntry = { field, + id: itemOfInterest.id ?? `${entryIndex}`, type: OperatorTypeEnum.NESTED, entries: updatedEntryEntries, }; @@ -317,12 +321,13 @@ export const getUpdatedEntriesOnDelete = ( export const getEntryFromOperator = ( selectedOperator: OperatorOption, currentEntry: FormattedBuilderEntry -): Entry => { +): Entry & { id?: string } => { const isSameOperatorType = currentEntry.operator.type === selectedOperator.type; const fieldValue = currentEntry.field != null ? currentEntry.field.name : ''; switch (selectedOperator.type) { case 'match': return { + id: currentEntry.id, field: fieldValue, type: OperatorTypeEnum.MATCH, operator: selectedOperator.operator, @@ -331,6 +336,7 @@ export const getEntryFromOperator = ( }; case 'match_any': return { + id: currentEntry.id, field: fieldValue, type: OperatorTypeEnum.MATCH_ANY, operator: selectedOperator.operator, @@ -338,6 +344,7 @@ export const getEntryFromOperator = ( }; case 'list': return { + id: currentEntry.id, field: fieldValue, type: OperatorTypeEnum.LIST, operator: selectedOperator.operator, @@ -345,6 +352,7 @@ export const getEntryFromOperator = ( }; default: return { + id: currentEntry.id, field: fieldValue, type: OperatorTypeEnum.EXISTS, operator: selectedOperator.operator, @@ -397,7 +405,7 @@ export const getEntryOnFieldChange = ( if (nested === 'parent') { // For nested entries, when user first selects to add a nested - // entry, they first see a row similiar to what is shown for when + // entry, they first see a row similar to what is shown for when // a user selects "exists", as soon as they make a selection // we can now identify the 'parent' and 'child' this is where // we first convert the entry into type "nested" @@ -408,15 +416,16 @@ export const getEntryOnFieldChange = ( return { updatedEntry: { + id: item.id, field: newParentFieldValue, type: OperatorTypeEnum.NESTED, entries: [ - { + addIdToItem({ field: newChildFieldValue ?? '', type: OperatorTypeEnum.MATCH, operator: isOperator.operator, value: '', - }, + }), ], }, index: entryIndex, @@ -428,6 +437,7 @@ export const getEntryOnFieldChange = ( entries: [ ...parent.parent.entries.slice(0, entryIndex), { + id: item.id, field: newChildFieldValue ?? '', type: OperatorTypeEnum.MATCH, operator: isOperator.operator, @@ -441,6 +451,7 @@ export const getEntryOnFieldChange = ( } else { return { updatedEntry: { + id: item.id, field: newField != null ? newField.name : '', type: OperatorTypeEnum.MATCH, operator: isOperator.operator, @@ -508,6 +519,7 @@ export const getEntryOnMatchChange = ( entries: [ ...parent.parent.entries.slice(0, entryIndex), { + id: item.id, field: fieldName, type: OperatorTypeEnum.MATCH, operator: operator.operator, @@ -521,6 +533,7 @@ export const getEntryOnMatchChange = ( } else { return { updatedEntry: { + id: item.id, field: field != null ? field.name : '', type: OperatorTypeEnum.MATCH, operator: operator.operator, @@ -554,6 +567,7 @@ export const getEntryOnMatchAnyChange = ( entries: [ ...parent.parent.entries.slice(0, entryIndex), { + id: item.id, field: fieldName, type: OperatorTypeEnum.MATCH_ANY, operator: operator.operator, @@ -567,6 +581,7 @@ export const getEntryOnMatchAnyChange = ( } else { return { updatedEntry: { + id: item.id, field: field != null ? field.name : '', type: OperatorTypeEnum.MATCH_ANY, operator: operator.operator, @@ -594,6 +609,7 @@ export const getEntryOnListChange = ( return { updatedEntry: { + id: item.id, field: field != null ? field.name : '', type: OperatorTypeEnum.LIST, operator: operator.operator, @@ -604,6 +620,7 @@ export const getEntryOnListChange = ( }; export const getDefaultEmptyEntry = (): EmptyEntry => ({ + id: uuid.v4(), field: '', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, @@ -611,6 +628,7 @@ export const getDefaultEmptyEntry = (): EmptyEntry => ({ }); export const getDefaultNestedEmptyEntry = (): EmptyNestedEntry => ({ + id: uuid.v4(), field: '', type: OperatorTypeEnum.NESTED, entries: [], diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx index 46e3ec08e7c50..3789d8e75fa2e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useReducer } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; +import { addIdToItem } from '../../../../../common'; import { Type } from '../../../../../common/detection_engine/schemas/common/schemas'; import { BuilderExceptionListItemComponent } from './exception_item'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/common'; @@ -240,8 +241,6 @@ export const ExceptionBuilderComponent = ({ entries: [...entries, isNested ? getDefaultNestedEmptyEntry() : getDefaultEmptyEntry()], }; - // setAndLogicIncluded(updatedException.entries.length > 1); - setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]); }, [setUpdateExceptions, exceptions] @@ -287,12 +286,12 @@ export const ExceptionBuilderComponent = ({ ...lastEntry, entries: [ ...lastEntry.entries, - { + addIdToItem({ field: '', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, value: '', - }, + }), ], }, ], @@ -352,7 +351,7 @@ export const ExceptionBuilderComponent = ({ }, []); return ( - + {exceptions.map((exceptionListItem, index) => ( diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.test.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.test.ts index 0741f561c1933..dbac7d325b63a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.test.ts @@ -14,6 +14,10 @@ import { ExceptionsBuilderExceptionItem } from '../types'; import { Action, State, exceptionsBuilderReducer } from './reducer'; import { getDefaultEmptyEntry } from './helpers'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + const initialState: State = { disableAnd: false, disableNested: false, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 336732016e936..954a75fc370bd 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -382,7 +382,9 @@ export const EditExceptionModal = memo(function EditExceptionModal({ )} {updateError == null && ( - {i18n.CANCEL} + + {i18n.CANCEL} + ({ + v4: jest.fn().mockReturnValue('123'), +})); + describe('Exception helpers', () => { beforeEach(() => { moment.tz.setDefault('UTC'); @@ -229,9 +237,22 @@ describe('Exception helpers', () => { }); describe('#filterExceptionItems', () => { + // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes + // for context around the temporary `id` + test('it correctly validates entries that include a temporary `id`', () => { + const output: Array< + ExceptionListItemSchema | CreateExceptionListItemSchema + > = filterExceptionItems([ + { ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }, + ]); + + expect(output).toEqual([{ ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }]); + }); + test('it removes entry items with "value" of "undefined"', () => { const { entries, ...rest } = getExceptionListItemSchemaMock(); const mockEmptyException: EmptyEntry = { + id: '123', field: 'host.name', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, @@ -250,6 +271,7 @@ describe('Exception helpers', () => { test('it removes "match" entry items with "value" of empty string', () => { const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; const mockEmptyException: EmptyEntry = { + id: '123', field: 'host.name', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, @@ -270,6 +292,7 @@ describe('Exception helpers', () => { test('it removes "match" entry items with "field" of empty string', () => { const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; const mockEmptyException: EmptyEntry = { + id: '123', field: '', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, @@ -290,6 +313,7 @@ describe('Exception helpers', () => { test('it removes "match_any" entry items with "field" of empty string', () => { const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; const mockEmptyException: EmptyEntry = { + id: '123', field: '', type: OperatorTypeEnum.MATCH_ANY, operator: OperatorEnum.INCLUDED, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 13ee06e8cbac9..c44de4f05e7f6 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -44,6 +44,7 @@ import { validate } from '../../../../common/validate'; import { Ecs } from '../../../../common/ecs'; import { CodeSignature } from '../../../../common/ecs/file'; import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; +import { addIdToItem, removeIdFromItem } from '../../../../common'; /** * Returns the operator type, may not need this if using io-ts types @@ -150,12 +151,12 @@ export const getNewExceptionItem = ({ comments: [], description: `${ruleName} - exception list item`, entries: [ - { + addIdToItem({ field: '', operator: 'included', type: 'match', value: '', - }, + }), ], item_id: undefined, list_id: listId, @@ -175,26 +176,32 @@ export const filterExceptionItems = ( return exceptions.reduce>( (acc, exception) => { const entries = exception.entries.reduce((nestedAcc, singleEntry) => { - if (singleEntry.type === 'nested') { - const nestedEntriesArray = singleEntry.entries.filter((singleNestedEntry) => { - const [validatedNestedEntry] = validate(singleNestedEntry, nestedEntryItem); + const strippedSingleEntry = removeIdFromItem(singleEntry); + + if (entriesNested.is(strippedSingleEntry)) { + const nestedEntriesArray = strippedSingleEntry.entries.filter((singleNestedEntry) => { + const noIdSingleNestedEntry = removeIdFromItem(singleNestedEntry); + const [validatedNestedEntry] = validate(noIdSingleNestedEntry, nestedEntryItem); return validatedNestedEntry != null; }); + const noIdNestedEntries = nestedEntriesArray.map((singleNestedEntry) => + removeIdFromItem(singleNestedEntry) + ); const [validatedNestedEntry] = validate( - { ...singleEntry, entries: nestedEntriesArray }, + { ...strippedSingleEntry, entries: noIdNestedEntries }, entriesNested ); if (validatedNestedEntry != null) { - return [...nestedAcc, validatedNestedEntry]; + return [...nestedAcc, { ...singleEntry, entries: nestedEntriesArray }]; } return nestedAcc; } else { - const [validatedEntry] = validate(singleEntry, entry); + const [validatedEntry] = validate(strippedSingleEntry, entry); if (validatedEntry != null) { - return [...nestedAcc, validatedEntry]; + return [...nestedAcc, singleEntry]; } return nestedAcc; } diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts index 6108a21ce5624..c7a125daa54f8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts @@ -60,16 +60,18 @@ export interface ExceptionsPagination { } export interface FormattedBuilderEntry { + id: string; field: IFieldType | undefined; operator: OperatorOption; value: string | string[] | undefined; nested: 'parent' | 'child' | undefined; entryIndex: number; - parent: { parent: EntryNested; parentIndex: number } | undefined; + parent: { parent: BuilderEntryNested; parentIndex: number } | undefined; correspondingKeywordField: IFieldType | undefined; } export interface EmptyEntry { + id: string; field: string | undefined; operator: OperatorEnum; type: OperatorTypeEnum.MATCH | OperatorTypeEnum.MATCH_ANY; @@ -77,6 +79,7 @@ export interface EmptyEntry { } export interface EmptyListEntry { + id: string; field: string | undefined; operator: OperatorEnum; type: OperatorTypeEnum.LIST; @@ -84,12 +87,31 @@ export interface EmptyListEntry { } export interface EmptyNestedEntry { + id: string; field: string | undefined; type: OperatorTypeEnum.NESTED; - entries: Array; + entries: Array< + | (EntryMatch & { id?: string }) + | (EntryMatchAny & { id?: string }) + | (EntryExists & { id?: string }) + >; } -export type BuilderEntry = Entry | EmptyListEntry | EmptyEntry | EntryNested | EmptyNestedEntry; +export type BuilderEntry = + | (Entry & { id?: string }) + | EmptyListEntry + | EmptyEntry + | BuilderEntryNested + | EmptyNestedEntry; + +export type BuilderEntryNested = Omit & { + id?: string; + entries: Array< + | (EntryMatch & { id?: string }) + | (EntryMatchAny & { id?: string }) + | (EntryExists & { id?: string }) + >; +}; export type ExceptionListItemBuilderSchema = Omit & { entries: BuilderEntry[]; diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx index eced8d785792d..ef3e9280e6e6b 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx @@ -14,7 +14,7 @@ import { import { IndexPattern, IFieldType } from '../../../../../../../src/plugins/data/common'; import { Entry, FormattedEntry, ThreatMapEntries, EmptyEntry } from './types'; -import { addIdToItem } from '../../utils/add_remove_id_to_item'; +import { addIdToItem } from '../../../../common/add_remove_id_to_item'; /** * Formats the entry into one that is easily usable for the UI. diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx index 7e2da88a58f18..af3e427056867 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx @@ -157,49 +157,54 @@ export const buildThreatDescription = ({ label, threat }: BuildThreatDescription : `${singleThreat.tactic.name} (${singleThreat.tactic.id})`} - {singleThreat.technique.map((technique, techniqueIndex) => { - const myTechnique = techniquesOptions.find((t) => t.id === technique.id); - return ( - - - {myTechnique != null - ? myTechnique.label - : `${technique.name} (${technique.id})`} - - - {technique.subtechnique != null && - technique.subtechnique.map((subtechnique, subtechniqueIndex) => { - const mySubtechnique = subtechniquesOptions.find( - (t) => t.id === subtechnique.id - ); - return ( - - { + const myTechnique = techniquesOptions.find((t) => t.id === technique.id); + return ( + + + {myTechnique != null + ? myTechnique.label + : `${technique.name} (${technique.id})`} + + + {technique.subtechnique != null && + technique.subtechnique.map((subtechnique, subtechniqueIndex) => { + const mySubtechnique = subtechniquesOptions.find( + (t) => t.id === subtechnique.id + ); + return ( + - {mySubtechnique != null - ? mySubtechnique.label - : `${subtechnique.name} (${subtechnique.id})`} - - - ); - })} - - - ); - })} + + {mySubtechnique != null + ? mySubtechnique.label + : `${subtechnique.name} (${subtechnique.id})`} + + + ); + })} + + + ); + })} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/mitre/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/mitre/helpers.test.tsx index da18f28257452..2a083ef89ab19 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/mitre/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/mitre/helpers.test.tsx @@ -8,7 +8,7 @@ import { getValidThreat } from '../../../mitre/valid_threat_mock'; import { hasSubtechniqueOptions } from './helpers'; -const mockTechniques = getValidThreat()[0].technique; +const mockTechniques = getValidThreat()[0].technique ?? []; describe('helpers', () => { describe('hasSubtechniqueOptions', () => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/mitre/subtechnique_fields.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/mitre/subtechnique_fields.tsx index e3c771534beda..d283c19bd13da 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/mitre/subtechnique_fields.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/mitre/subtechnique_fields.tsx @@ -51,45 +51,46 @@ export const MitreAttackSubtechniqueFields: React.FC = ({ const values = field.value as Threats; const technique = useMemo(() => { - return values[threatIndex].technique[techniqueIndex]; - }, [values, threatIndex, techniqueIndex]); + return [...(values[threatIndex].technique ?? [])]; + }, [values, threatIndex]); const removeSubtechnique = useCallback( (index: number) => { const threats = [...(field.value as Threats)]; - const subtechniques = threats[threatIndex].technique[techniqueIndex].subtechnique; + const subtechniques = technique[techniqueIndex].subtechnique ?? []; if (subtechniques != null) { subtechniques.splice(index, 1); - threats[threatIndex].technique[techniqueIndex] = { - ...threats[threatIndex].technique[techniqueIndex], + technique[techniqueIndex] = { + ...technique[techniqueIndex], subtechnique: subtechniques, }; + threats[threatIndex].technique = technique; onFieldChange(threats); } }, - [field, threatIndex, onFieldChange, techniqueIndex] + [field, onFieldChange, techniqueIndex, technique, threatIndex] ); const addMitreAttackSubtechnique = useCallback(() => { const threats = [...(field.value as Threats)]; - const subtechniques = threats[threatIndex].technique[techniqueIndex].subtechnique; + const subtechniques = technique[techniqueIndex].subtechnique; if (subtechniques != null) { - threats[threatIndex].technique[techniqueIndex] = { - ...threats[threatIndex].technique[techniqueIndex], + technique[techniqueIndex] = { + ...technique[techniqueIndex], subtechnique: [...subtechniques, { id: 'none', name: 'none', reference: 'none' }], }; } else { - threats[threatIndex].technique[techniqueIndex] = { - ...threats[threatIndex].technique[techniqueIndex], + technique[techniqueIndex] = { + ...technique[techniqueIndex], subtechnique: [{ id: 'none', name: 'none', reference: 'none' }], }; } - + threats[threatIndex].technique = technique; onFieldChange(threats); - }, [field, threatIndex, onFieldChange, techniqueIndex]); + }, [field, onFieldChange, techniqueIndex, technique, threatIndex]); const updateSubtechnique = useCallback( (index: number, value: string) => { @@ -99,7 +100,7 @@ export const MitreAttackSubtechniqueFields: React.FC = ({ name: '', reference: '', }; - const subtechniques = threats[threatIndex].technique[techniqueIndex].subtechnique; + const subtechniques = technique[techniqueIndex].subtechnique; if (subtechniques != null) { onFieldChange([ @@ -107,9 +108,9 @@ export const MitreAttackSubtechniqueFields: React.FC = ({ { ...threats[threatIndex], technique: [ - ...threats[threatIndex].technique.slice(0, techniqueIndex), + ...technique.slice(0, techniqueIndex), { - ...threats[threatIndex].technique[techniqueIndex], + ...technique[techniqueIndex], subtechnique: [ ...subtechniques.slice(0, index), { @@ -120,19 +121,21 @@ export const MitreAttackSubtechniqueFields: React.FC = ({ ...subtechniques.slice(index + 1), ], }, - ...threats[threatIndex].technique.slice(techniqueIndex + 1), + ...technique.slice(techniqueIndex + 1), ], }, ...threats.slice(threatIndex + 1), ]); } }, - [threatIndex, techniqueIndex, onFieldChange, field] + [threatIndex, techniqueIndex, onFieldChange, field, technique] ); const getSelectSubtechnique = useCallback( (index: number, disabled: boolean, subtechnique: ThreatSubtechnique) => { - const options = subtechniquesOptions.filter((t) => t.techniqueId === technique.id); + const options = subtechniquesOptions.filter( + (t) => t.techniqueId === technique[techniqueIndex].id + ); return ( <> @@ -166,13 +169,17 @@ export const MitreAttackSubtechniqueFields: React.FC = ({ ); }, - [field, updateSubtechnique, technique] + [field, updateSubtechnique, technique, techniqueIndex] ); + const subtechniques = useMemo(() => { + return technique[techniqueIndex].subtechnique; + }, [technique, techniqueIndex]); + return ( - {technique.subtechnique != null && - technique.subtechnique.map((subtechnique, index) => ( + {subtechniques != null && + subtechniques.map((subtechnique, index) => (
= ({ const removeTechnique = useCallback( (index: number) => { const threats = [...(field.value as Threats)]; - const techniques = threats[threatIndex].technique; + const techniques = threats[threatIndex].technique ?? []; techniques.splice(index, 1); threats[threatIndex] = { ...threats[threatIndex], @@ -73,7 +73,7 @@ export const MitreAttackTechniqueFields: React.FC = ({ threats[threatIndex] = { ...threats[threatIndex], technique: [ - ...threats[threatIndex].technique, + ...(threats[threatIndex].technique ?? []), { id: 'none', name: 'none', reference: 'none', subtechnique: [] }, ], }; @@ -88,19 +88,20 @@ export const MitreAttackTechniqueFields: React.FC = ({ name: '', reference: '', }; + const technique = threats[threatIndex].technique ?? []; onFieldChange([ ...threats.slice(0, threatIndex), { ...threats[threatIndex], technique: [ - ...threats[threatIndex].technique.slice(0, index), + ...technique.slice(0, index), { id, reference, name, subtechnique: [], }, - ...threats[threatIndex].technique.slice(index + 1), + ...technique.slice(index + 1), ], }, ...threats.slice(threatIndex + 1), @@ -147,9 +148,11 @@ export const MitreAttackTechniqueFields: React.FC = ({ [field, updateTechnique] ); + const techniques = values[threatIndex].technique ?? []; + return ( - {values[threatIndex].technique.map((technique, index) => ( + {techniques.map((technique, index) => (
= ({ euiFieldProps: { fullWidth: true, disabled: isLoading, - placeholder: DEFAULT_INDICATOR_PATH, + placeholder: DEFAULT_INDICATOR_SOURCE_PATH, }, }} /> diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx index 5e4d08c4e7939..07012af17e734 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx @@ -205,7 +205,7 @@ export const schema: FormSchema = { 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldThreatIndicatorPathHelpText', { defaultMessage: - 'Specify the document path containing your threat indicator fields. Used for enrichment of indicator match alerts. Defaults to threat.indicator unless otherwise specified.', + 'Specify the document path containing your threat indicator fields. Used for enrichment of indicator match alerts.', } ), labelAppend: OptionalFieldLabel, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts index 45961454b9c78..7eb91e259a72f 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts @@ -6,7 +6,7 @@ */ import { flow } from 'fp-ts/lib/function'; -import { addIdToItem, removeIdFromItem } from '../../../../common/utils/add_remove_id_to_item'; +import { addIdToItem, removeIdFromItem } from '../../../../../common/add_remove_id_to_item'; import { CreateRulesSchema, UpdateRulesSchema, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx index 3b1f9e620127d..6cc75a3fda03c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx @@ -8,6 +8,7 @@ import * as H from 'history'; import React, { Dispatch } from 'react'; +import { CreateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; import { deleteRules, duplicateRules, @@ -28,6 +29,7 @@ import { track, METRIC_TYPE, TELEMETRY_EVENT } from '../../../../../common/lib/t import * as i18n from '../translations'; import { bucketRulesResponse } from './helpers'; +import { transformOutput } from '../../../../containers/detection_engine/rules/transforms'; export const editRuleAction = (rule: Rule, history: H.History) => { history.push(getEditRuleUrl(rule.id)); @@ -41,7 +43,11 @@ export const duplicateRulesAction = async ( ) => { try { dispatch({ type: 'loadingRuleIds', ids: ruleIds, actionType: 'duplicate' }); - const response = await duplicateRules({ rules }); + const response = await duplicateRules({ + // We cast this back and forth here as the front end types are not really the right io-ts ones + // and the two types conflict with each other. + rules: rules.map((rule) => transformOutput(rule as CreateRulesSchema) as Rule), + }); const { errors } = bucketRulesResponse(response); if (errors.length > 0) { displayErrorToast( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx index d2488bd3d043c..d2eadef48d9c7 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx @@ -67,6 +67,7 @@ export const getActions = ( enabled: (rowItem: Rule) => canEditRuleWithActions(rowItem, actionsPrivileges), }, { + 'data-test-subj': 'duplicateRuleAction', description: i18n.DUPLICATE_RULE, icon: 'copy', name: !actionsPrivileges ? ( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts index 12e6d276c18d8..b8824d2b8798e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts @@ -182,7 +182,7 @@ export const filterEmptyThreats = (threats: Threats): Threats => { .map((threat) => { return { ...threat, - technique: trimThreatsWithNoName(threat.technique).map((technique) => { + technique: trimThreatsWithNoName(threat.technique ?? []).map((technique) => { return { ...technique, subtechnique: diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts index cdbafe692c630..7d6cd655e336d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { DEFAULT_INDICATOR_PATH } from '../../../../../common/constants'; +import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../../../common/constants'; import { SignalSearchResponse, SignalsEnrichment } from '../types'; import { enrichSignalThreatMatches } from './enrich_signal_threat_matches'; import { getThreatList } from './get_threat_list'; @@ -52,7 +52,9 @@ export const buildThreatEnrichment = ({ return threatResponse.hits.hits; }; - const defaultedIndicatorPath = threatIndicatorPath ? threatIndicatorPath : DEFAULT_INDICATOR_PATH; + const defaultedIndicatorPath = threatIndicatorPath + ? threatIndicatorPath + : DEFAULT_INDICATOR_SOURCE_PATH; return (signals: SignalSearchResponse): Promise => enrichSignalThreatMatches(signals, getMatchedThreats, defaultedIndicatorPath); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts index f98f0c88a2dfa..b77e8228e72d8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts @@ -6,7 +6,7 @@ */ import { get } from 'lodash'; -import { DEFAULT_INDICATOR_PATH } from '../../../../../common/constants'; +import { INDICATOR_DESTINATION_PATH } from '../../../../../common/constants'; import { getThreatListItemMock } from './build_threat_mapping_filter.mock'; import { @@ -75,8 +75,10 @@ describe('groupAndMergeSignalMatches', () => { describe('buildMatchedIndicator', () => { let threats: ThreatListItem[]; let queries: ThreatMatchNamedQuery[]; + let indicatorPath: string; beforeEach(() => { + indicatorPath = 'threat.indicator'; threats = [ getThreatListItemMock({ _id: '123', @@ -94,7 +96,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries: [], threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([]); @@ -104,7 +106,7 @@ describe('buildMatchedIndicator', () => { const [indicator] = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(get(indicator, 'matched.atomic')).toEqual('domain_1'); @@ -114,7 +116,7 @@ describe('buildMatchedIndicator', () => { const [indicator] = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(get(indicator, 'matched.field')).toEqual('event.field'); @@ -124,7 +126,7 @@ describe('buildMatchedIndicator', () => { const [indicator] = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(get(indicator, 'matched.type')).toEqual('type_1'); @@ -153,7 +155,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toHaveLength(queries.length); @@ -163,7 +165,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([ @@ -228,7 +230,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([ @@ -253,7 +255,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([ @@ -285,7 +287,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([ @@ -317,7 +319,7 @@ describe('buildMatchedIndicator', () => { buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }) ).toThrowError('Expected indicator field to be an object, but found: not an object'); }); @@ -338,7 +340,7 @@ describe('buildMatchedIndicator', () => { buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }) ).toThrowError('Expected indicator field to be an object, but found: not an object'); }); @@ -347,8 +349,10 @@ describe('buildMatchedIndicator', () => { describe('enrichSignalThreatMatches', () => { let getMatchedThreats: GetMatchedThreats; let matchedQuery: string; + let indicatorPath: string; beforeEach(() => { + indicatorPath = 'threat.indicator'; getMatchedThreats = async () => [ getThreatListItemMock({ _id: '123', @@ -367,7 +371,7 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); expect(enrichedSignals.hits.hits).toEqual([]); @@ -382,10 +386,10 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { existing: 'indicator' }, @@ -407,10 +411,10 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { @@ -428,10 +432,10 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { existing: 'indicator' }, @@ -451,7 +455,7 @@ describe('enrichSignalThreatMatches', () => { }); const signals = getSignalsResponseMock([signalHit]); await expect(() => - enrichSignalThreatMatches(signals, getMatchedThreats, DEFAULT_INDICATOR_PATH) + enrichSignalThreatMatches(signals, getMatchedThreats, indicatorPath) ).rejects.toThrowError('Expected threat field to be an object, but found: whoops'); }); @@ -487,7 +491,7 @@ describe('enrichSignalThreatMatches', () => { 'custom_threat.custom_indicator' ); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { @@ -530,13 +534,13 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); expect(enrichedSignals.hits.total).toEqual(expect.objectContaining({ value: 1 })); expect(enrichedSignals.hits.hits).toHaveLength(1); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts index 761a58224fac5..3c8b80886cabe 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts @@ -6,7 +6,6 @@ */ import { get, isObject } from 'lodash'; -import { DEFAULT_INDICATOR_PATH } from '../../../../../common/constants'; import type { SignalSearchResponse, SignalSourceHit } from '../types'; import type { @@ -92,7 +91,11 @@ export const enrichSignalThreatMatches = async ( if (!isObject(threat)) { throw new Error(`Expected threat field to be an object, but found: ${threat}`); } - const existingIndicatorValue = get(signalHit._source, DEFAULT_INDICATOR_PATH) ?? []; + // We are not using INDICATOR_DESTINATION_PATH here because the code above + // and below make assumptions about its current value, 'threat.indicator', + // and making this code dynamic on an arbitrary path would introduce several + // new issues. + const existingIndicatorValue = get(signalHit._source, 'threat.indicator') ?? []; const existingIndicators = [existingIndicatorValue].flat(); // ensure indicators is an array return { diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx index d82f0769c8b74..fb846d041bd17 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx @@ -123,6 +123,7 @@ export const DeleteActionModal: FC = ({ return ( = ({ closeModal, items, startAndC return ( 8.0.0) + * The release branch should match the release version (e.g., 7.x --> 7.0.0) + */ +export const mockKibanaVersion = '8.0.0'; +export const mockKibanaSemverVersion = new SemVer(mockKibanaVersion); diff --git a/x-pack/plugins/upgrade_assistant/common/types.ts b/x-pack/plugins/upgrade_assistant/common/types.ts index 91a19bfec3e81..6d83bdc5f36e9 100644 --- a/x-pack/plugins/upgrade_assistant/common/types.ts +++ b/x-pack/plugins/upgrade_assistant/common/types.ts @@ -94,7 +94,7 @@ export type ReindexSavedObject = SavedObject; export enum ReindexWarning { // 7.0 -> 8.0 warnings - apmReindex, + customTypeName, // 8.0 -> 9.0 warnings } diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx index ee722a3937216..b732f6806a388 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx @@ -6,9 +6,9 @@ */ import React from 'react'; -import SemVer from 'semver/classes/semver'; import { mountWithIntl } from '@kbn/test/jest'; import { httpServiceMock } from 'src/core/public/mocks'; +import { mockKibanaSemverVersion } from '../../../common/constants'; import { UpgradeAssistantTabs } from './tabs'; import { LoadingState } from './types'; @@ -18,7 +18,6 @@ import { OverviewTab } from './tabs/overview'; const promisesToResolve = () => new Promise((resolve) => setTimeout(resolve, 0)); const mockHttp = httpServiceMock.createSetupContract(); -const mockKibanaVersion = new SemVer('8.0.0'); jest.mock('../app_context', () => { return { @@ -29,9 +28,9 @@ jest.mock('../app_context', () => { ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', }, kibanaVersionInfo: { - currentMajor: mockKibanaVersion.major, - prevMajor: mockKibanaVersion.major - 1, - nextMajor: mockKibanaVersion.major + 1, + currentMajor: mockKibanaSemverVersion.major, + prevMajor: mockKibanaSemverVersion.major - 1, + nextMajor: mockKibanaSemverVersion.major + 1, }, }; }, diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx index 1ed1e0b01f65b..bf890c856239e 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx @@ -7,7 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; -import SemVer from 'semver/classes/semver'; +import { mockKibanaSemverVersion } from '../../../../../common/constants'; import { LoadingState } from '../../types'; import AssistanceData from '../__fixtures__/checkup_api_response.json'; @@ -22,8 +22,6 @@ const defaultProps = { setSelectedTabIndex: jest.fn(), }; -const mockKibanaVersion = new SemVer('8.0.0'); - jest.mock('../../../app_context', () => { return { useAppContext: () => { @@ -33,9 +31,9 @@ jest.mock('../../../app_context', () => { ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', }, kibanaVersionInfo: { - currentMajor: mockKibanaVersion.major, - prevMajor: mockKibanaVersion.major - 1, - nextMajor: mockKibanaVersion.major + 1, + currentMajor: mockKibanaSemverVersion.major, + prevMajor: mockKibanaSemverVersion.major - 1, + nextMajor: mockKibanaSemverVersion.major + 1, }, }; }, diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.tsx index 67aa5d8b9d7de..292887853e4b3 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.tsx @@ -143,9 +143,11 @@ export class IndexDeprecationTable extends React.Component< private generateActionsColumn() { // NOTE: this naive implementation assumes all indices in the table are - // should show the reindex button. This should work for known usecases. + // should show the reindex button. This should work for known use cases. const { indices } = this.props; - if (!indices.find((i) => i.reindex === true)) { + const hasActionsColumn = Boolean(indices.find((i) => i.reindex === true)); + + if (hasActionsColumn === false) { return null; } diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/__snapshots__/warning_step.test.tsx.snap b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/__snapshots__/warning_step.test.tsx.snap index d92db98ae40cb..dba019550f2a1 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/__snapshots__/warning_step.test.tsx.snap +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/__snapshots__/warning_step.test.tsx.snap @@ -23,30 +23,6 @@ exports[`WarningsFlyoutStep renders 1`] = `

- - } - documentationUrl="https://www.elastic.co/guide/en/observability/master/whats-new.html" - label={ - - } - onChange={[Function]} - warning={0} - /> { status: undefined, reindexTaskPercComplete: null, errorMessage: null, - reindexWarnings: [ReindexWarning.apmReindex], + reindexWarnings: [ReindexWarning.customTypeName], hasRequiredPrivileges: true, } as ReindexState, }; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx index 9f76ef0aa78ba..d365cd82ba86c 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx @@ -8,6 +8,7 @@ import { I18nProvider } from '@kbn/i18n/react'; import { mount, shallow } from 'enzyme'; import React from 'react'; +import { mockKibanaSemverVersion } from '../../../../../../../../common/constants'; import { ReindexWarning } from '../../../../../../../../common/types'; import { idForWarning, WarningsFlyoutStep } from './warnings_step'; @@ -20,6 +21,11 @@ jest.mock('../../../../../../app_context', () => { DOC_LINK_VERSION: 'current', ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', }, + kibanaVersionInfo: { + currentMajor: mockKibanaSemverVersion.major, + prevMajor: mockKibanaSemverVersion.major - 1, + nextMajor: mockKibanaSemverVersion.major + 1, + }, }; }, }; @@ -28,7 +34,7 @@ jest.mock('../../../../../../app_context', () => { describe('WarningsFlyoutStep', () => { const defaultProps = { advanceNextStep: jest.fn(), - warnings: [ReindexWarning.apmReindex], + warnings: [ReindexWarning.customTypeName], closeFlyout: jest.fn(), renderGlobalCallouts: jest.fn(), }; @@ -37,19 +43,21 @@ describe('WarningsFlyoutStep', () => { expect(shallow()).toMatchSnapshot(); }); - it('does not allow proceeding until all are checked', () => { - const wrapper = mount( - - - - ); - const button = wrapper.find('EuiButton'); - - button.simulate('click'); - expect(defaultProps.advanceNextStep).not.toHaveBeenCalled(); - - wrapper.find(`input#${idForWarning(ReindexWarning.apmReindex)}`).simulate('change'); - button.simulate('click'); - expect(defaultProps.advanceNextStep).toHaveBeenCalled(); - }); + if (mockKibanaSemverVersion.major === 7) { + it('does not allow proceeding until all are checked', () => { + const wrapper = mount( + + + + ); + const button = wrapper.find('EuiButton'); + + button.simulate('click'); + expect(defaultProps.advanceNextStep).not.toHaveBeenCalled(); + + wrapper.find(`input#${idForWarning(ReindexWarning.customTypeName)}`).simulate('change'); + button.simulate('click'); + expect(defaultProps.advanceNextStep).toHaveBeenCalled(); + }); + } }); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx index 2e6b039a2fe76..f6620e4125c9a 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx @@ -10,6 +10,7 @@ import React, { useState } from 'react'; import { EuiButton, EuiButtonEmpty, + EuiCode, EuiCallOut, EuiCheckbox, EuiFlexGroup, @@ -100,9 +101,9 @@ export const WarningsFlyoutStep: React.FunctionComponent @@ -128,25 +129,31 @@ export const WarningsFlyoutStep: React.FunctionComponent - {warnings.includes(ReindexWarning.apmReindex) && ( + {kibanaVersionInfo.currentMajor === 7 && warnings.includes(ReindexWarning.customTypeName) && ( _doc, + }} /> } description={ _doc, + }} /> } - documentationUrl={`${observabilityDocBasePath}/master/whats-new.html`} + documentationUrl={`${esDocBasePath}/${DOC_LINK_VERSION}/removal-of-types.html`} /> )} diff --git a/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts b/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts index 6caad4f5050fc..d93fe7920f1d7 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts @@ -5,16 +5,13 @@ * 2.0. */ -import { SemVer } from 'semver'; +import { mockKibanaSemverVersion } from '../../../common/constants'; -export const MOCK_VERSION_STRING = '8.0.0'; - -export const getMockVersionInfo = (versionString = MOCK_VERSION_STRING) => { - const currentVersion = new SemVer(versionString); - const currentMajor = currentVersion.major; +export const getMockVersionInfo = () => { + const currentMajor = mockKibanaSemverVersion.major; return { - currentVersion, + currentVersion: mockKibanaSemverVersion, currentMajor, prevMajor: currentMajor - 1, nextMajor: currentMajor + 1, diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts index 479a7475efd68..9ab8d0aa7cffb 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts @@ -24,6 +24,7 @@ describe('getUpgradeAssistantStatus', () => { const resolvedIndices = { indices: fakeIndexNames.map((f) => ({ name: f, attributes: ['open'] })), }; + // @ts-expect-error mock data is too loosely typed const deprecationsResponse: DeprecationAPIResponse = _.cloneDeep(fakeDeprecations); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts index 25dcd2521525d..f4631f3ba459d 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts @@ -9,7 +9,8 @@ import { SemVer } from 'semver'; import { IScopedClusterClient, kibanaResponseFactory } from 'src/core/server'; import { coreMock } from 'src/core/server/mocks'; import { licensingMock } from '../../../../plugins/licensing/server/mocks'; -import { MOCK_VERSION_STRING, getMockVersionInfo } from './__fixtures__/version'; +import { mockKibanaVersion } from '../../common/constants'; +import { getMockVersionInfo } from './__fixtures__/version'; import { esVersionCheck, @@ -97,7 +98,7 @@ describe('verifyAllMatchKibanaVersion', () => { describe('EsVersionPrecheck', () => { beforeEach(() => { - versionService.setup(MOCK_VERSION_STRING); + versionService.setup(mockKibanaVersion); }); it('returns a 403 when callCluster fails with a 403', async () => { diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts index 609f36c25619e..f778981b95054 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts @@ -5,8 +5,10 @@ * 2.0. */ +import { mockKibanaSemverVersion, mockKibanaVersion } from '../../../common/constants'; +import { ReindexWarning } from '../../../common/types'; import { versionService } from '../version'; -import { MOCK_VERSION_STRING, getMockVersionInfo } from '../__fixtures__/version'; +import { getMockVersionInfo } from '../__fixtures__/version'; import { generateNewIndexName, @@ -123,7 +125,7 @@ describe('transformFlatSettings', () => { describe('sourceNameForIndex', () => { beforeEach(() => { - versionService.setup(MOCK_VERSION_STRING); + versionService.setup(mockKibanaVersion); }); it('parses internal indices', () => { @@ -144,7 +146,7 @@ describe('sourceNameForIndex', () => { describe('generateNewIndexName', () => { beforeEach(() => { - versionService.setup(MOCK_VERSION_STRING); + versionService.setup(mockKibanaVersion); }); it('parses internal indices', () => { @@ -177,4 +179,26 @@ describe('getReindexWarnings', () => { }) ).toEqual([]); }); + + if (mockKibanaSemverVersion.major === 7) { + describe('customTypeName warning', () => { + it('returns customTypeName for non-_doc mapping types', () => { + expect( + getReindexWarnings({ + settings: {}, + mappings: { doc: {} }, + }) + ).toEqual([ReindexWarning.customTypeName]); + }); + + it('does not return customTypeName for _doc mapping types', () => { + expect( + getReindexWarnings({ + settings: {}, + mappings: { _doc: {} }, + }) + ).toEqual([]); + }); + }); + } }); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.ts index 11cc01b69d3a5..70e1992d5b3e9 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.ts @@ -8,8 +8,7 @@ import { flow, omit } from 'lodash'; import { ReindexWarning } from '../../../common/types'; import { versionService } from '../version'; -import { FlatSettings } from './types'; - +import { FlatSettings, FlatSettingsWithTypeName } from './types'; export interface ParsedIndexName { cleanIndexName: string; baseName: string; @@ -69,11 +68,24 @@ export const generateNewIndexName = (indexName: string): string => { * Returns an array of warnings that should be displayed to user before reindexing begins. * @param flatSettings */ -export const getReindexWarnings = (flatSettings: FlatSettings): ReindexWarning[] => { +export const getReindexWarnings = ( + flatSettings: FlatSettingsWithTypeName | FlatSettings +): ReindexWarning[] => { const warnings = [ // No warnings yet for 8.0 -> 9.0 ] as Array<[ReindexWarning, boolean]>; + if (versionService.getMajorVersion() === 7) { + const DEFAULT_TYPE_NAME = '_doc'; + // In 7+ it's not possible to have more than one type anyways, so always grab the first + // (and only) key. + const typeName = Object.getOwnPropertyNames(flatSettings.mappings)[0]; + + const typeNameWarning = Boolean(typeName && typeName !== DEFAULT_TYPE_NAME); + + warnings.push([ReindexWarning.customTypeName, typeNameWarning]); + } + return warnings.filter(([_, applies]) => applies).map(([warning, _]) => warning); }; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts index 59c83a05aa551..592c2d15b9c0c 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts @@ -19,9 +19,10 @@ import { ReindexStatus, ReindexStep, } from '../../../common/types'; +import { mockKibanaVersion } from '../../../common/constants'; import { versionService } from '../version'; import { LOCK_WINDOW, ReindexActions, reindexActionsFactory } from './reindex_actions'; -import { MOCK_VERSION_STRING, getMockVersionInfo } from '../__fixtures__/version'; +import { getMockVersionInfo } from '../__fixtures__/version'; const { currentMajor, prevMajor } = getMockVersionInfo(); @@ -53,7 +54,7 @@ describe('ReindexActions', () => { describe('createReindexOp', () => { beforeEach(() => { - versionService.setup(MOCK_VERSION_STRING); + versionService.setup(mockKibanaVersion); client.create.mockResolvedValue(); }); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts index 738d54c6f6d4f..fe8844b28e37a 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts @@ -21,8 +21,9 @@ import { ReindexStatus, ReindexStep, } from '../../../common/types'; +import { versionService } from '../version'; import { generateNewIndexName } from './index_settings'; -import { FlatSettings } from './types'; +import { FlatSettings, FlatSettingsWithTypeName } from './types'; // TODO: base on elasticsearch.requestTimeout? export const LOCK_WINDOW = moment.duration(90, 'seconds'); @@ -85,7 +86,7 @@ export interface ReindexActions { * Retrieve index settings (in flat, dot-notation style) and mappings. * @param indexName */ - getFlatSettings(indexName: string): Promise; + getFlatSettings(indexName: string): Promise; // ----- Functions below are for enforcing locks around groups of indices like ML or Watcher @@ -237,18 +238,33 @@ export const reindexActionsFactory = ( }, async getFlatSettings(indexName: string) { - const { body: flatSettings } = await esClient.indices.get<{ - [indexName: string]: FlatSettings; - }>({ - index: indexName, - flat_settings: true, - }); + let flatSettings; + + if (versionService.getMajorVersion() === 7) { + // On 7.x, we need to get index settings with mapping type + flatSettings = await esClient.indices.get<{ + [indexName: string]: FlatSettingsWithTypeName; + }>({ + index: indexName, + flat_settings: true, + // This @ts-ignore is needed on master since the flag is deprecated on >7.x + // @ts-ignore + include_type_name: true, + }); + } else { + flatSettings = await esClient.indices.get<{ + [indexName: string]: FlatSettings; + }>({ + index: indexName, + flat_settings: true, + }); + } - if (!flatSettings[indexName]) { + if (!flatSettings.body[indexName]) { return null; } - return flatSettings[indexName]; + return flatSettings.body[indexName]; }, async _fetchAndLockIndexGroupDoc(indexGroup) { diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts index 69105465a04f0..a91cf8ddeada9 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts @@ -20,10 +20,11 @@ import { ReindexStatus, ReindexStep, } from '../../../common/types'; +import { mockKibanaVersion } from '../../../common/constants'; import { licensingMock } from '../../../../licensing/server/mocks'; import { LicensingPluginSetup } from '../../../../licensing/server'; -import { MOCK_VERSION_STRING, getMockVersionInfo } from '../__fixtures__/version'; +import { getMockVersionInfo } from '../__fixtures__/version'; import { esIndicesStateCheck } from '../es_indices_state_check'; import { versionService } from '../version'; @@ -88,7 +89,7 @@ describe('reindexService', () => { licensingPluginSetup ); - versionService.setup(MOCK_VERSION_STRING); + versionService.setup(mockKibanaVersion); }); describe('hasRequiredPrivileges', () => { @@ -215,7 +216,7 @@ describe('reindexService', () => { 'index.provided_name': indexName, }, mappings: { - properties: { https: { type: 'boolean' } }, + _doc: { properties: { https: { type: 'boolean' } } }, }, }); @@ -571,7 +572,10 @@ describe('reindexService', () => { const mlReindexedOp = { id: '2', - attributes: { ...reindexOp.attributes, indexName: '.reindexed-v7-ml-anomalies' }, + attributes: { + ...reindexOp.attributes, + indexName: `.reindexed-v${prevMajor}-ml-anomalies`, + }, } as ReindexSavedObject; const updatedOp = await service.processNextStep(mlReindexedOp); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts index 72bcb5330f819..1b5f91e0c53b8 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts @@ -219,7 +219,7 @@ export const reindexServiceFactory = ( .cancel({ task_id: reindexOp.attributes.reindexTaskId ?? undefined, }) - .catch((e) => undefined); // Ignore any exceptions trying to cancel (it may have already completed). + .catch(() => undefined); // Ignore any exceptions trying to cancel (it may have already completed). } // Set index back to writable if we ever got past this point. @@ -347,6 +347,11 @@ export const reindexServiceFactory = ( await esClient.indices.open({ index: indexName }); } + const flatSettings = await actions.getFlatSettings(indexName); + if (!flatSettings) { + throw error.indexNotFound(`Index ${indexName} does not exist.`); + } + const { body: startReindexResponse } = await esClient.reindex({ refresh: true, wait_for_completion: false, diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/types.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/types.ts index b24625a8c2a9d..569316e276e43 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/types.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/types.ts @@ -27,3 +27,16 @@ export interface FlatSettings { _meta?: MetaProperties; }; } + +// Specific to 7.x-8 upgrade +export interface FlatSettingsWithTypeName { + settings: { + [key: string]: string; + }; + mappings: { + [typeName: string]: { + properties?: MappingProperties; + _meta?: MetaProperties; + }; + }; +} diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts index 82d039ab9413a..21dded346bbd3 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts @@ -89,7 +89,9 @@ describe('reindex API', () => { mockReindexService.findReindexOperation.mockResolvedValueOnce({ attributes: { indexName: 'wowIndex', status: ReindexStatus.inProgress }, }); - mockReindexService.detectReindexWarnings.mockResolvedValueOnce([ReindexWarning.apmReindex]); + mockReindexService.detectReindexWarnings.mockResolvedValueOnce([ + ReindexWarning.customTypeName, + ]); const resp = await routeDependencies.router.getHandler({ method: 'get', diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx index 9fcd946df2f84..befe53219a449 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx @@ -68,16 +68,16 @@ export const StepDetail: React.FC = ({ }) => { return ( <> - + - +

{stepName}

- + = ({ - + = (item) => { - return {item.name}; + return ( + + {item.name} + + ); }; interface Props { diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/legend.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/legend.tsx index c746a5cc63a9b..9a66b586d1d56 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/legend.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/legend.tsx @@ -9,18 +9,25 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { IWaterfallContext } from '../context/waterfall_chart'; import { WaterfallChartProps } from './waterfall_chart'; +import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; interface LegendProps { items: Required['legendItems']; render: Required['renderLegendItem']; } +const StyledFlexItem = euiStyled(EuiFlexItem)` + margin-right: ${(props) => props.theme.eui.paddingSizes.m}; + max-width: 7%; + min-width: 160px; +`; + export const Legend: React.FC = ({ items, render }) => { return ( - - {items.map((item, index) => { - return {render(item, index)}; - })} + + {items.map((item, index) => ( + {render(item, index)} + ))} ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart.tsx index 59990b29db5db..119c907f76ca1 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart.tsx @@ -120,8 +120,12 @@ export const WaterfallChart = ({ - - + + {shouldRenderSidebar && } { + await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { + return testSubjects.isDisplayed('createPolicyButton'); + }); + + // Navigate to create policy page and take snapshot + await testSubjects.click('createPolicyButton'); + await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { + return (await testSubjects.getVisibleText('policyTitle')) === 'Create policy'; + }); + + // Fill out form after enabling all phases and take snapshot. + await indexLifecycleManagement.fillNewPolicyForm('testPolicy', true, true, false); + await a11y.testAppSnapshot(); + }); + + it('Send Request Flyout on New Policy Page', async () => { + // Take snapshot of the show request panel + await testSubjects.click('requestButton'); + await a11y.testAppSnapshot(); + + // Close panel and save policy + await testSubjects.click('euiFlyoutCloseButton'); + await indexLifecycleManagement.saveNewPolicy(); + }); + it('List policies view', async () => { await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { await common.navigateToApp('indexLifecycleManagement'); diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts index d9e362a99e648..1b92b11eacdcc 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts @@ -15,7 +15,10 @@ import { import { schema } from '@kbn/config-schema'; import { InvalidatePendingApiKey } from '../../../../../../../plugins/alerts/server/types'; import { RawAlert } from '../../../../../../../plugins/alerts/server/types'; -import { TaskInstance } from '../../../../../../../plugins/task_manager/server'; +import { + ConcreteTaskInstance, + TaskInstance, +} from '../../../../../../../plugins/task_manager/server'; import { FixtureStartDeps } from './plugin'; export function defineRoutes(core: CoreSetup) { @@ -188,6 +191,40 @@ export function defineRoutes(core: CoreSetup) { } ); + router.put( + { + path: '/api/alerts_fixture/{id}/reset_task_status', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + status: schema.string(), + }), + }, + }, + async ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> => { + const { id } = req.params; + const { status } = req.body; + + const [{ savedObjects }] = await core.getStartServices(); + const savedObjectsWithTasksAndAlerts = await savedObjects.getScopedClient(req, { + includedHiddenTypes: ['task', 'alert'], + }); + const alert = await savedObjectsWithTasksAndAlerts.get('alert', id); + const result = await savedObjectsWithTasksAndAlerts.update( + 'task', + alert.attributes.scheduledTaskId!, + { status } + ); + return res.ok({ body: result }); + } + ); + router.get( { path: '/api/alerts_fixture/api_keys_pending_invalidation', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts index c1f65fab3669e..e8cc8ea699e17 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts @@ -11,8 +11,7 @@ import { setupSpacesAndUsers, tearDown } from '..'; // eslint-disable-next-line import/no-default-export export default function alertingTests({ loadTestFile, getService }: FtrProviderContext) { describe('Alerts', () => { - // FLAKY: https://github.com/elastic/kibana/issues/86952 - describe.skip('legacy alerts', () => { + describe('legacy alerts', () => { before(async () => { await setupSpacesAndUsers(getService); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rbac_legacy.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rbac_legacy.ts index ef5914965ddce..3db3565374740 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rbac_legacy.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rbac_legacy.ts @@ -77,6 +77,7 @@ export default function alertTests({ getService }: FtrProviderContext) { case 'space_1_all at space1': case 'superuser at space1': case 'space_1_all_with_restricted_fixture at space1': + await resetTaskStatus(migratedAlertId); await ensureLegacyAlertHasBeenMigrated(migratedAlertId); await updateMigratedAlertToUseApiKeyOfCurrentUser(migratedAlertId); @@ -92,6 +93,7 @@ export default function alertTests({ getService }: FtrProviderContext) { await ensureAlertIsRunning(); break; case 'global_read at space1': + await resetTaskStatus(migratedAlertId); await ensureLegacyAlertHasBeenMigrated(migratedAlertId); await updateMigratedAlertToUseApiKeyOfCurrentUser(migratedAlertId); @@ -115,6 +117,7 @@ export default function alertTests({ getService }: FtrProviderContext) { }); break; case 'space_1_all_alerts_none_actions at space1': + await resetTaskStatus(migratedAlertId); await ensureLegacyAlertHasBeenMigrated(migratedAlertId); await updateMigratedAlertToUseApiKeyOfCurrentUser(migratedAlertId); @@ -140,6 +143,21 @@ export default function alertTests({ getService }: FtrProviderContext) { throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); } + async function resetTaskStatus(alertId: string) { + // occasionally when the task manager starts running while the alert saved objects + // are mid-migration, the task will fail and set its status to "failed". this prevents + // the alert from running ever again and downstream tasks that depend on successful alert + // execution will fail. this ensures the task status is set to "idle" so the + // task manager will continue claiming and executing it. + await supertest + .put(`${getUrlPrefix(space.id)}/api/alerts_fixture/${alertId}/reset_task_status`) + .set('kbn-xsrf', 'foo') + .send({ + status: 'idle', + }) + .expect(200); + } + async function ensureLegacyAlertHasBeenMigrated(alertId: string) { const getResponse = await supertestWithoutAuth .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${alertId}`) diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index 9e1c290d16059..20d2b107dc2cc 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -273,6 +273,7 @@ export default ({ getService }: FtrProviderContext) => { rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', query: '*:*', + threat_indicator_path: 'threat.indicator', threat_query: 'threat.indicator.domain: *', // narrow things down to indicators with a domain threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module threat_mapping: [ @@ -353,6 +354,7 @@ export default ({ getService }: FtrProviderContext) => { rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', query: 'source.port: 57324', // narrow our query to a single record that matches two indicators + threat_indicator_path: 'threat.indicator', threat_query: 'threat.indicator.ip: *', threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module threat_mapping: [ @@ -422,6 +424,7 @@ export default ({ getService }: FtrProviderContext) => { rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', query: 'source.port: 57324', // narrow our query to a single record that matches two indicators + threat_indicator_path: 'threat.indicator', threat_query: 'threat.indicator.ip: *', threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module threat_mapping: [ @@ -519,6 +522,7 @@ export default ({ getService }: FtrProviderContext) => { rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', query: '*:*', // narrow our query to a single record that matches two indicators + threat_indicator_path: 'threat.indicator', threat_query: '', threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module threat_mapping: [ diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 6ca13b232e11a..3e9ffa18c5949 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import { range } from 'lodash'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { @@ -439,6 +440,42 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await find.allByCssSelector('.echLegendItem')).to.have.length(2); }); + it('should allow formatting on references', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('lnsDatatable'); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsDatatable_column > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + await PageObjects.lens.configureDimension({ + dimension: 'lnsDatatable_metrics > lns-empty-dimension', + operation: 'moving_average', + keepOpen: true, + }); + await PageObjects.lens.configureReference({ + operation: 'sum', + field: 'bytes', + }); + await PageObjects.lens.editDimensionFormat('Number'); + await PageObjects.lens.closeDimensionEditor(); + + const values = await Promise.all( + range(0, 6).map((index) => PageObjects.lens.getDatatableCellText(index, 1)) + ); + expect(values).to.eql([ + '-', + '222,420.00', + '702,050.00', + '1,879,613.33', + '3,482,256.25', + '4,359,953.00', + ]); + }); + /** * The edge cases are: * diff --git a/x-pack/test/functional/apps/transform/cloning.ts b/x-pack/test/functional/apps/transform/cloning.ts index d804f0ef14cf8..665c126e00a01 100644 --- a/x-pack/test/functional/apps/transform/cloning.ts +++ b/x-pack/test/functional/apps/transform/cloning.ts @@ -159,7 +159,7 @@ export default function ({ getService }: FtrProviderContext) { await transform.table.filterWithSearchString(testData.originalConfig.id, 1); await transform.testExecution.logTestStep('should show the actions popover'); - await transform.table.assertTransformRowActions(false); + await transform.table.assertTransformRowActions(testData.originalConfig.id, false); await transform.testExecution.logTestStep('should display the define pivot step'); await transform.table.clickTransformRowAction('Clone'); diff --git a/x-pack/test/functional/apps/transform/deleting.ts b/x-pack/test/functional/apps/transform/deleting.ts new file mode 100644 index 0000000000000..bdba06454c5c2 --- /dev/null +++ b/x-pack/test/functional/apps/transform/deleting.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TRANSFORM_STATE } from '../../../../plugins/transform/common/constants'; + +import { FtrProviderContext } from '../../ftr_provider_context'; +import { getLatestTransformConfig, getPivotTransformConfig } from './index'; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const transform = getService('transform'); + + describe('deleting', function () { + const PREFIX = 'deleting'; + + const testDataList = [ + { + suiteTitle: 'batch transform with pivot configuration', + originalConfig: getPivotTransformConfig(PREFIX, false), + expected: { + row: { + status: TRANSFORM_STATE.STOPPED, + mode: 'batch', + progress: 100, + }, + }, + }, + { + suiteTitle: 'continuous transform with pivot configuration', + originalConfig: getPivotTransformConfig(PREFIX, true), + expected: { + row: { + status: TRANSFORM_STATE.STOPPED, + mode: 'continuous', + progress: undefined, + }, + }, + }, + { + suiteTitle: 'batch transform with latest configuration', + originalConfig: getLatestTransformConfig(PREFIX), + transformDescription: 'updated description', + transformDocsPerSecond: '1000', + transformFrequency: '10m', + expected: { + messageText: 'updated transform.', + row: { + status: TRANSFORM_STATE.STOPPED, + mode: 'batch', + progress: 100, + }, + }, + }, + ]; + + before(async () => { + await esArchiver.loadIfNeeded('ml/ecommerce'); + await transform.testResources.createIndexPatternIfNeeded('ft_ecommerce', 'order_date'); + + for (const testData of testDataList) { + await transform.api.createAndRunTransform( + testData.originalConfig.id, + testData.originalConfig + ); + } + + await transform.testResources.setKibanaTimeZoneToUTC(); + await transform.securityUI.loginAsTransformPowerUser(); + }); + + after(async () => { + for (const testData of testDataList) { + await transform.testResources.deleteIndexPatternByTitle(testData.originalConfig.dest.index); + await transform.api.deleteIndices(testData.originalConfig.dest.index); + } + await transform.api.cleanTransformIndices(); + }); + + for (const testData of testDataList) { + describe(`${testData.suiteTitle}`, function () { + it('delete transform', async () => { + await transform.testExecution.logTestStep('should load the home page'); + await transform.navigation.navigateTo(); + await transform.management.assertTransformListPageExists(); + + await transform.testExecution.logTestStep('should display the transforms table'); + await transform.management.assertTransformsTableExists(); + + if (testData.expected.row.mode === 'continuous') { + await transform.testExecution.logTestStep('should have the delete action disabled'); + await transform.table.assertTransformRowActionEnabled( + testData.originalConfig.id, + 'Delete', + false + ); + + await transform.testExecution.logTestStep('should stop the transform'); + await transform.table.clickTransformRowActionWithRetry( + testData.originalConfig.id, + 'Stop' + ); + } + + await transform.testExecution.logTestStep('should display the stopped transform'); + await transform.table.assertTransformRowFields(testData.originalConfig.id, { + id: testData.originalConfig.id, + description: testData.originalConfig.description, + status: testData.expected.row.status, + mode: testData.expected.row.mode, + progress: testData.expected.row.progress, + }); + + await transform.testExecution.logTestStep('should show the delete modal'); + await transform.table.assertTransformRowActionEnabled( + testData.originalConfig.id, + 'Delete', + true + ); + await transform.table.clickTransformRowActionWithRetry( + testData.originalConfig.id, + 'Delete' + ); + await transform.table.assertTransformDeleteModalExists(); + + await transform.testExecution.logTestStep('should delete the transform'); + await transform.table.confirmDeleteTransform(); + await transform.table.assertTransformRowNotExists(testData.originalConfig.id); + }); + }); + } + }); +} diff --git a/x-pack/test/functional/apps/transform/editing.ts b/x-pack/test/functional/apps/transform/editing.ts index 71a7cf02df1fd..1f0bb058bdc38 100644 --- a/x-pack/test/functional/apps/transform/editing.ts +++ b/x-pack/test/functional/apps/transform/editing.ts @@ -109,7 +109,7 @@ export default function ({ getService }: FtrProviderContext) { await transform.table.filterWithSearchString(testData.originalConfig.id, 1); await transform.testExecution.logTestStep('should show the actions popover'); - await transform.table.assertTransformRowActions(false); + await transform.table.assertTransformRowActions(testData.originalConfig.id, false); await transform.testExecution.logTestStep('should show the edit flyout'); await transform.table.clickTransformRowAction('Edit'); diff --git a/x-pack/test/functional/apps/transform/index.ts b/x-pack/test/functional/apps/transform/index.ts index 63d8d0b51bc8c..1440f0a3f9a09 100644 --- a/x-pack/test/functional/apps/transform/index.ts +++ b/x-pack/test/functional/apps/transform/index.ts @@ -6,7 +6,10 @@ */ import { FtrProviderContext } from '../../ftr_provider_context'; -import { TransformLatestConfig } from '../../../../plugins/transform/common/types/transform'; +import { + TransformLatestConfig, + TransformPivotConfig, +} from '../../../../plugins/transform/common/types/transform'; export default function ({ getService, loadTestFile }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -41,6 +44,8 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./cloning')); loadTestFile(require.resolve('./editing')); loadTestFile(require.resolve('./feature_controls')); + loadTestFile(require.resolve('./deleting')); + loadTestFile(require.resolve('./starting')); }); } export interface ComboboxOption { @@ -80,20 +85,46 @@ export function isLatestTransformTestData(arg: any): arg is LatestTransformTestD return arg.type === 'latest'; } -export function getLatestTransformConfig(): TransformLatestConfig { +export function getPivotTransformConfig( + prefix: string, + continuous?: boolean +): TransformPivotConfig { const timestamp = Date.now(); return { - id: `ec_cloning_2_${timestamp}`, + id: `ec_${prefix}_pivot_${timestamp}_${continuous ? 'cont' : 'batch'}`, + source: { index: ['ft_ecommerce'] }, + pivot: { + group_by: { category: { terms: { field: 'category.keyword' } } }, + aggregations: { 'products.base_price.avg': { avg: { field: 'products.base_price' } } }, + }, + description: `ecommerce ${ + continuous ? 'continuous' : 'batch' + } transform with avg(products.base_price) grouped by terms(category.keyword)`, + dest: { index: `user-ec_2_${timestamp}` }, + ...(continuous ? { sync: { time: { field: 'order_date', delay: '60s' } } } : {}), + }; +} + +export function getLatestTransformConfig( + prefix: string, + continuous?: boolean +): TransformLatestConfig { + const timestamp = Date.now(); + return { + id: `ec_${prefix}_latest_${timestamp}_${continuous ? 'cont' : 'batch'}`, source: { index: ['ft_ecommerce'] }, latest: { unique_key: ['category.keyword'], sort: 'order_date', }, - description: 'ecommerce batch transform with category unique key and sorted by order date', + description: `ecommerce ${ + continuous ? 'continuous' : 'batch' + } transform with category unique key and sorted by order date`, frequency: '3s', settings: { max_page_search_size: 250, }, dest: { index: `user-ec_3_${timestamp}` }, + ...(continuous ? { sync: { time: { field: 'order_date', delay: '60s' } } } : {}), }; } diff --git a/x-pack/test/functional/apps/transform/starting.ts b/x-pack/test/functional/apps/transform/starting.ts new file mode 100644 index 0000000000000..4b0b6f8dade66 --- /dev/null +++ b/x-pack/test/functional/apps/transform/starting.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; +import { TRANSFORM_STATE } from '../../../../plugins/transform/common/constants'; +import { getLatestTransformConfig, getPivotTransformConfig } from './index'; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const transform = getService('transform'); + + describe('starting', function () { + const PREFIX = 'starting'; + const testDataList = [ + { + suiteTitle: 'batch transform with pivot configuration', + originalConfig: getPivotTransformConfig(PREFIX, false), + mode: 'batch', + }, + { + suiteTitle: 'continuous transform with pivot configuration', + originalConfig: getPivotTransformConfig(PREFIX, true), + mode: 'continuous', + }, + { + suiteTitle: 'batch transform with latest configuration', + originalConfig: getLatestTransformConfig(PREFIX, false), + mode: 'batch', + }, + { + suiteTitle: 'continuous transform with latest configuration', + originalConfig: getLatestTransformConfig(PREFIX, true), + mode: 'continuous', + }, + ]; + + before(async () => { + await esArchiver.loadIfNeeded('ml/ecommerce'); + await transform.testResources.createIndexPatternIfNeeded('ft_ecommerce', 'order_date'); + + for (const testData of testDataList) { + await transform.api.createTransform(testData.originalConfig.id, testData.originalConfig); + } + await transform.testResources.setKibanaTimeZoneToUTC(); + await transform.securityUI.loginAsTransformPowerUser(); + }); + + after(async () => { + for (const testData of testDataList) { + await transform.testResources.deleteIndexPatternByTitle(testData.originalConfig.dest.index); + await transform.api.deleteIndices(testData.originalConfig.dest.index); + } + + await transform.api.cleanTransformIndices(); + }); + + for (const testData of testDataList) { + const transformId = testData.originalConfig.id; + + describe(`${testData.suiteTitle}`, function () { + it('start transform', async () => { + await transform.testExecution.logTestStep('should load the home page'); + await transform.navigation.navigateTo(); + await transform.management.assertTransformListPageExists(); + + await transform.testExecution.logTestStep('should display the transforms table'); + await transform.management.assertTransformsTableExists(); + + await transform.testExecution.logTestStep( + 'should display the original transform in the transform list' + ); + await transform.table.filterWithSearchString(transformId, 1); + + await transform.testExecution.logTestStep('should start the transform'); + await transform.table.assertTransformRowActionEnabled(transformId, 'Start', true); + await transform.table.clickTransformRowActionWithRetry(transformId, 'Start'); + await transform.table.confirmStartTransform(); + await transform.table.clearSearchString(testDataList.length); + + if (testData.mode === 'continuous') { + await transform.testExecution.logTestStep('should display the started transform'); + await transform.table.assertTransformRowStatusNotEql( + testData.originalConfig.id, + TRANSFORM_STATE.STOPPED + ); + } else { + await transform.table.assertTransformRowProgressGreaterThan(transformId, 0); + } + + await transform.table.assertTransformRowStatusNotEql( + testData.originalConfig.id, + TRANSFORM_STATE.FAILED + ); + await transform.table.assertTransformRowStatusNotEql( + testData.originalConfig.id, + TRANSFORM_STATE.ABORTING + ); + }); + }); + } + }); +} diff --git a/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts b/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts index 04db9e4544c9a..ddf46926f122a 100644 --- a/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts +++ b/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function IndexLifecycleManagementPageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); + const retry = getService('retry'); return { async sectionHeadingText() { @@ -17,5 +18,40 @@ export function IndexLifecycleManagementPageProvider({ getService }: FtrProvider async createPolicyButton() { return await testSubjects.find('createPolicyButton'); }, + async fillNewPolicyForm( + policyName: string, + warmEnabled: boolean = false, + coldEnabled: boolean = false, + deletePhaseEnabled: boolean = false + ) { + await testSubjects.setValue('policyNameField', policyName); + if (warmEnabled) { + await retry.try(async () => { + await testSubjects.click('enablePhaseSwitch-warm'); + }); + } + if (coldEnabled) { + await retry.try(async () => { + await testSubjects.click('enablePhaseSwitch-cold'); + }); + } + if (deletePhaseEnabled) { + await retry.try(async () => { + await testSubjects.click('enableDeletePhaseButton'); + }); + } + }, + async saveNewPolicy() { + await testSubjects.click('savePolicyButton'); + }, + async createNewPolicyAndSave( + policyName: string, + warmEnabled: boolean = false, + coldEnabled: boolean = false, + deletePhaseEnabled: boolean = false + ) { + await this.fillNewPolicyForm(policyName, warmEnabled, coldEnabled, deletePhaseEnabled); + await this.saveNewPolicy(); + }, }; } diff --git a/x-pack/test/functional/services/transform/management.ts b/x-pack/test/functional/services/transform/management.ts index fdfd1d1d9b40f..807c3d49e344c 100644 --- a/x-pack/test/functional/services/transform/management.ts +++ b/x-pack/test/functional/services/transform/management.ts @@ -5,8 +5,11 @@ * 2.0. */ +import { ProvidedType } from '@kbn/test/types/ftr'; import { FtrProviderContext } from '../../ftr_provider_context'; +export type TransformManagement = ProvidedType; + export function TransformManagementProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); diff --git a/x-pack/test/functional/services/transform/transform_table.ts b/x-pack/test/functional/services/transform/transform_table.ts index 72626580e9461..ce2625677e479 100644 --- a/x-pack/test/functional/services/transform/transform_table.ts +++ b/x-pack/test/functional/services/transform/transform_table.ts @@ -12,6 +12,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export function TransformTableProvider({ getService }: FtrProviderContext) { const retry = getService('retry'); const testSubjects = getService('testSubjects'); + const browser = getService('browser'); return new (class TransformTable { public async parseTransformTable() { @@ -129,21 +130,63 @@ export function TransformTableProvider({ getService }: FtrProviderContext) { const filteredRows = rows.filter((row) => row.id === filter); expect(filteredRows).to.have.length( expectedRowCount, - `Filtered DFA job table should have ${expectedRowCount} row(s) for filter '${filter}' (got matching items '${filteredRows}')` + `Filtered Transform table should have ${expectedRowCount} row(s) for filter '${filter}' (got matching items '${filteredRows}')` ); } - public async assertTransformRowFields(transformId: string, expectedRow: object) { + public async clearSearchString(expectedRowCount: number = 1) { + await this.waitForTransformsToLoad(); + const tableListContainer = await testSubjects.find('transformListTableContainer'); + const searchBarInput = await tableListContainer.findByClassName('euiFieldSearch'); + await searchBarInput.clearValueWithKeyboard(); const rows = await this.parseTransformTable(); - const transformRow = rows.filter((row) => row.id === transformId)[0]; - expect(transformRow).to.eql( - expectedRow, - `Expected transform row to be '${JSON.stringify(expectedRow)}' (got '${JSON.stringify( - transformRow - )}')` + expect(rows).to.have.length( + expectedRowCount, + `Transform table should have ${expectedRowCount} row(s) after clearing search' (got '${rows.length}')` ); } + public async assertTransformRowFields(transformId: string, expectedRow: object) { + await retry.tryForTime(30 * 1000, async () => { + await this.refreshTransformList(); + const rows = await this.parseTransformTable(); + const transformRow = rows.filter((row) => row.id === transformId)[0]; + expect(transformRow).to.eql( + expectedRow, + `Expected transform row to be '${JSON.stringify(expectedRow)}' (got '${JSON.stringify( + transformRow + )}')` + ); + }); + } + + public async assertTransformRowProgressGreaterThan( + transformId: string, + expectedProgress: number + ) { + await retry.tryForTime(30 * 1000, async () => { + await this.refreshTransformList(); + const rows = await this.parseTransformTable(); + const transformRow = rows.filter((row) => row.id === transformId)[0]; + expect(transformRow.progress).to.greaterThan( + 0, + `Expected transform row progress to be greater than '${expectedProgress}' (got '${transformRow.progress}')` + ); + }); + } + + public async assertTransformRowStatusNotEql(transformId: string, status: string) { + await retry.tryForTime(30 * 1000, async () => { + await this.refreshTransformList(); + const rows = await this.parseTransformTable(); + const transformRow = rows.filter((row) => row.id === transformId)[0]; + expect(transformRow.status).to.not.eql( + status, + `Expected transform row status to not be '${status}' (got '${transformRow.status}')` + ); + }); + } + public async assertTransformExpandedRow() { await testSubjects.click('transformListRowDetailsToggle'); @@ -185,8 +228,13 @@ export function TransformTableProvider({ getService }: FtrProviderContext) { }); } - public async assertTransformRowActions(isTransformRunning = false) { - await testSubjects.click('euiCollapsedItemActionsButton'); + public rowSelector(transformId: string, subSelector?: string) { + const row = `~transformListTable > ~row-${transformId}`; + return !subSelector ? row : `${row} > ${subSelector}`; + } + + public async assertTransformRowActions(transformId: string, isTransformRunning = false) { + await testSubjects.click(this.rowSelector(transformId, 'euiCollapsedItemActionsButton')); await testSubjects.existOrFail('transformActionClone'); await testSubjects.existOrFail('transformActionDelete'); @@ -201,6 +249,42 @@ export function TransformTableProvider({ getService }: FtrProviderContext) { } } + public async assertTransformRowActionEnabled( + transformId: string, + action: 'Delete' | 'Start' | 'Stop' | 'Clone' | 'Edit', + expectedValue: boolean + ) { + const selector = `transformAction${action}`; + await retry.tryForTime(60 * 1000, async () => { + await this.refreshTransformList(); + + await browser.pressKeys(browser.keys.ESCAPE); + await testSubjects.click(this.rowSelector(transformId, 'euiCollapsedItemActionsButton')); + + await testSubjects.existOrFail(selector); + const isEnabled = await testSubjects.isEnabled(selector); + expect(isEnabled).to.eql( + expectedValue, + `Expected '${action}' button to be '${expectedValue ? 'enabled' : 'disabled'}' (got '${ + isEnabled ? 'enabled' : 'disabled' + }')` + ); + }); + } + + public async clickTransformRowActionWithRetry( + transformId: string, + action: 'Delete' | 'Start' | 'Stop' | 'Clone' | 'Edit' + ) { + await retry.tryForTime(30 * 1000, async () => { + await browser.pressKeys(browser.keys.ESCAPE); + await testSubjects.click(this.rowSelector(transformId, 'euiCollapsedItemActionsButton')); + await testSubjects.existOrFail(`transformAction${action}`); + await testSubjects.click(`transformAction${action}`); + await testSubjects.missingOrFail(`transformAction${action}`); + }); + } + public async clickTransformRowAction(action: string) { await testSubjects.click(`transformAction${action}`); } @@ -214,5 +298,53 @@ export function TransformTableProvider({ getService }: FtrProviderContext) { await this.waitForTransformsExpandedRowPreviewTabToLoad(); await this.assertEuiDataGridColumnValues('transformPivotPreview', column, values); } + + public async assertTransformDeleteModalExists() { + await testSubjects.existOrFail('transformDeleteModal', { timeout: 60 * 1000 }); + } + + public async assertTransformDeleteModalNotExists() { + await testSubjects.missingOrFail('transformDeleteModal', { timeout: 60 * 1000 }); + } + + public async assertTransformStartModalExists() { + await testSubjects.existOrFail('transformStartModal', { timeout: 60 * 1000 }); + } + + public async assertTransformStartModalNotExists() { + await testSubjects.missingOrFail('transformStartModal', { timeout: 60 * 1000 }); + } + + public async confirmDeleteTransform() { + await retry.tryForTime(30 * 1000, async () => { + await this.assertTransformDeleteModalExists(); + await testSubjects.click('transformDeleteModal > confirmModalConfirmButton'); + await this.assertTransformDeleteModalNotExists(); + }); + } + + public async assertTransformRowNotExists(transformId: string) { + await retry.tryForTime(30 * 1000, async () => { + // If after deletion, and there's no transform left + const noTransformsFoundMessageExists = await testSubjects.exists( + 'transformNoTransformsFound' + ); + + if (noTransformsFoundMessageExists) { + return true; + } else { + // Checks that the tranform was deleted + await this.filterWithSearchString(transformId, 0); + } + }); + } + + public async confirmStartTransform() { + await retry.tryForTime(30 * 1000, async () => { + await this.assertTransformStartModalExists(); + await testSubjects.click('transformStartModal > confirmModalConfirmButton'); + await this.assertTransformStartModalNotExists(); + }); + } })(); } diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts index 7274105413145..a631bf3781d09 100644 --- a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts @@ -9,7 +9,8 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { - describe('uptime alerts', () => { + // FLAKY: https://github.com/elastic/kibana/issues/88177 + describe.skip('uptime alerts', () => { const pageObjects = getPageObjects(['common', 'uptime']); const supertest = getService('supertest'); const retry = getService('retry'); diff --git a/x-pack/test/security_solution_cypress/es_archives/exceptions/data.json b/x-pack/test/security_solution_cypress/es_archives/exceptions/data.json new file mode 100644 index 0000000000000..b7de2dba02d19 --- /dev/null +++ b/x-pack/test/security_solution_cypress/es_archives/exceptions/data.json @@ -0,0 +1,23 @@ +{ + "type": "doc", + "value": { + "id": "_aZE5nwBOpWiDweSth_D", + "index": "exceptions-0001", + "source": { + "@timestamp": "2019-09-01T00:41:06.527Z", + "agent": { + "name": "bond" + }, + "user" : [ + { + "name" : "john", + "id" : "c5baec68-e774-46dc-b728-417e71d68444" + }, + { + "name" : "alice", + "id" : "6e831997-deab-4e56-9218-a90ef045556e" + } + ] + } + } +} diff --git a/x-pack/test/security_solution_cypress/es_archives/exceptions/mappings.json b/x-pack/test/security_solution_cypress/es_archives/exceptions/mappings.json new file mode 100644 index 0000000000000..e63b86392756f --- /dev/null +++ b/x-pack/test/security_solution_cypress/es_archives/exceptions/mappings.json @@ -0,0 +1,42 @@ +{ + "type": "index", + "value": { + "aliases": { + "exceptions": { + "is_write_index": false + } + }, + "settings": { + "index": { + "refresh_interval": "5s" + } + }, + "index": "exceptions-0001", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "type": "nested", + "properties": { + "first": { + "type": "keyword" + }, + "last": { + "type": "keyword" + } + } + } + } + } + } +} diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 40972da0044bc..ba4687e497230 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -21,7 +21,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const policyTestResources = getService('policyTestResources'); - describe('When on the Endpoint Policy Details Page', function () { + // Failing: See https://github.com/elastic/kibana/issues/92567 + describe.skip('When on the Endpoint Policy Details Page', function () { this.tags(['ciGroup7']); describe('with an invalid policy id', () => { diff --git a/yarn.lock b/yarn.lock index 4a3399ece1fd0..92c67cda974c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2107,6 +2107,14 @@ enabled "2.0.x" kuler "^2.0.0" +"@dsherret/to-absolute-glob@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@dsherret/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz#1f6475dc8bd974cea07a2daf3864b317b1dd332c" + integrity sha1-H2R13IvZdM6gei2vOGSzF7HdMyw= + dependencies: + is-absolute "^1.0.0" + is-negated-glob "^1.0.0" + "@elastic/apm-rum-core@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@elastic/apm-rum-core/-/apm-rum-core-5.7.0.tgz#2213987285324781e2ebeca607f3a71245da5a84" @@ -3428,6 +3436,10 @@ version "0.0.0" uid "" +"@kbn/docs-utils@link:packages/kbn-docs-utils": + version "0.0.0" + uid "" + "@kbn/es-archiver@link:packages/kbn-es-archiver": version "0.0.0" uid "" @@ -3484,10 +3496,6 @@ version "0.0.0" uid "" -"@kbn/release-notes@link:packages/kbn-release-notes": - version "0.0.0" - uid "" - "@kbn/std@link:packages/kbn-std": version "0.0.0" uid "" @@ -5134,6 +5142,18 @@ dependencies: "@babel/runtime" "^7.10.2" +"@ts-morph/common@~0.7.0": + version "0.7.3" + resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.7.3.tgz#380020c278e4aa6cecedf362a1157591d1003267" + integrity sha512-M6Tcu0EZDLL8Ht7WAYz7yJfDZ9eArhqR8XZ9Mk3q8jwU6MKFAttrw3JtW4JhneqTz7pZMv4XaimEdXI0E4K4rg== + dependencies: + "@dsherret/to-absolute-glob" "^2.0.2" + fast-glob "^3.2.4" + is-negated-glob "^1.0.0" + mkdirp "^1.0.4" + multimatch "^5.0.0" + typescript "~4.1.2" + "@turf/along@6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/@turf/along/-/along-6.0.1.tgz#595cecdc48fc7fcfa83c940a8e3eb24d4c2e04d4" @@ -10561,6 +10581,11 @@ coa@^2.0.2: chalk "^2.4.1" q "^1.1.2" +code-block-writer@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-10.1.1.tgz#ad5684ed4bfb2b0783c8b131281ae84ee640a42f" + integrity sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw== + code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" @@ -19991,10 +20016,10 @@ lodash.uniq@4.5.0, lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@4.17.11, lodash@4.17.15, lodash@>4.17.4, lodash@^4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.10.0, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.2.0, lodash@~4.17.10, lodash@~4.17.15, lodash@~4.17.19, lodash@~4.17.20: - version "4.17.20" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== +lodash@4.17.11, lodash@4.17.15, lodash@>4.17.4, lodash@^4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.10.0, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.2.0, lodash@~4.17.10, lodash@~4.17.15, lodash@~4.17.19, lodash@~4.17.20: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== log-ok@^0.1.1: version "0.1.1" @@ -21176,6 +21201,17 @@ multimatch@^4.0.0: arrify "^2.0.1" minimatch "^3.0.4" +multimatch@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-5.0.0.tgz#932b800963cea7a31a033328fa1e0c3a1874dbe6" + integrity sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA== + dependencies: + "@types/minimatch" "^3.0.3" + array-differ "^3.0.0" + array-union "^2.1.0" + arrify "^2.0.1" + minimatch "^3.0.4" + multiparty@^4.1.2: version "4.2.1" resolved "https://registry.yarnpkg.com/multiparty/-/multiparty-4.2.1.tgz#d9b6c46d8b8deab1ee70c734b0af771dd46e0b13" @@ -28444,6 +28480,15 @@ ts-log@2.1.4: resolved "https://registry.yarnpkg.com/ts-log/-/ts-log-2.1.4.tgz#063c5ad1cbab5d49d258d18015963489fb6fb59a" integrity sha512-P1EJSoyV+N3bR/IWFeAqXzKPZwHpnLY6j7j58mAvewHRipo+BQM2Y1f9Y9BjEQznKwgqqZm7H8iuixmssU7tYQ== +ts-morph@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-9.1.0.tgz#10d2088387c71f3c674f82492a3cec1e3538f0dd" + integrity sha512-sei4u651MBenr27sD6qLDXN3gZ4thiX71E3qV7SuVtDas0uvK2LtgZkIYUf9DKm/fLJ6AB/+yhRJ1vpEBJgy7Q== + dependencies: + "@dsherret/to-absolute-glob" "^2.0.2" + "@ts-morph/common" "~0.7.0" + code-block-writer "^10.1.1" + ts-pnp@^1.1.6: version "1.2.0" resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92" @@ -28648,7 +28693,7 @@ typescript-tuple@^2.2.1: dependencies: typescript-compare "^0.0.2" -typescript@4.1.3, typescript@^3.2.2, typescript@^3.3.3333, typescript@^3.5.3, typescript@~3.7.2: +typescript@4.1.3, typescript@^3.2.2, typescript@^3.3.3333, typescript@^3.5.3, typescript@~3.7.2, typescript@~4.1.2: version "4.1.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7" integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==