diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b45ff51b70da3c..307b449d50b61d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -251,6 +251,7 @@ x-pack/plugins/telemetry_collection_xpack/schema/ @elastic/kibana-core @elastic/ /x-pack/test/security_api_integration/ @elastic/kibana-security /x-pack/test/security_functional/ @elastic/kibana-security /x-pack/test/spaces_api_integration/ @elastic/kibana-security +/x-pack/test/saved_object_api_integration/ @elastic/kibana-security #CC# /x-pack/plugins/security/ @elastic/kibana-security # Kibana Alerting Services diff --git a/docs/development/core/public/kibana-plugin-core-public.appleavehandler.md b/docs/development/core/public/kibana-plugin-core-public.appleavehandler.md index d86f7b7a1a5f9e..2eacdd811f438a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appleavehandler.md +++ b/docs/development/core/public/kibana-plugin-core-public.appleavehandler.md @@ -4,6 +4,11 @@ ## AppLeaveHandler type +> Warning: This API is now obsolete. +> +> [AppMountParameters.onAppLeave](./kibana-plugin-core-public.appmountparameters.onappleave.md) has been deprecated in favor of [ScopedHistory.block](./kibana-plugin-core-public.scopedhistory.block.md) +> + A handler that will be executed before leaving the application, either when going to another application or when closing the browser tab or manually changing the url. Should return `confirm` to to prompt a message to the user before leaving the page, or `default` to keep the default behavior (doing nothing). See [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) for detailed usage examples. diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountparameters.onappleave.md b/docs/development/core/public/kibana-plugin-core-public.appmountparameters.onappleave.md index e898126a553e2d..e64e40a49e44e0 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appmountparameters.onappleave.md +++ b/docs/development/core/public/kibana-plugin-core-public.appmountparameters.onappleave.md @@ -4,6 +4,11 @@ ## AppMountParameters.onAppLeave property +> Warning: This API is now obsolete. +> +> [ScopedHistory.block](./kibana-plugin-core-public.scopedhistory.block.md) should be used instead. +> + A function that can be used to register a handler that will be called when the user is leaving the current application, allowing to prompt a confirmation message before actually changing the page. This will be called either when the user goes to another application, or when trying to close the tab or manually changing the url. diff --git a/docs/development/core/public/kibana-plugin-core-public.scopedhistory.block.md b/docs/development/core/public/kibana-plugin-core-public.scopedhistory.block.md index 922cab9ef3769d..eb632465e46990 100644 --- a/docs/development/core/public/kibana-plugin-core-public.scopedhistory.block.md +++ b/docs/development/core/public/kibana-plugin-core-public.scopedhistory.block.md @@ -4,15 +4,10 @@ ## ScopedHistory.block property -Not supported. Use [AppMountParameters.onAppLeave](./kibana-plugin-core-public.appmountparameters.onappleave.md). +Add a block prompt requesting user confirmation when navigating away from the current page. Signature: ```typescript block: (prompt?: string | boolean | History.TransitionPromptHook | undefined) => UnregisterCallback; ``` - -## Remarks - -We prefer that applications use the `onAppLeave` API because it supports a more graceful experience that prefers a modal when possible, falling back to a confirm dialog box in the beforeunload case. - diff --git a/docs/development/core/public/kibana-plugin-core-public.scopedhistory.md b/docs/development/core/public/kibana-plugin-core-public.scopedhistory.md index 1818d2bc0851db..15ed4e74c4dc5f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.scopedhistory.md +++ b/docs/development/core/public/kibana-plugin-core-public.scopedhistory.md @@ -27,7 +27,7 @@ export declare class ScopedHistory implements Hi | Property | Modifiers | Type | Description | | --- | --- | --- | --- | | [action](./kibana-plugin-core-public.scopedhistory.action.md) | | Action | The last action dispatched on the history stack. | -| [block](./kibana-plugin-core-public.scopedhistory.block.md) | | (prompt?: string | boolean | History.TransitionPromptHook<HistoryLocationState> | undefined) => UnregisterCallback | Not supported. Use [AppMountParameters.onAppLeave](./kibana-plugin-core-public.appmountparameters.onappleave.md). | +| [block](./kibana-plugin-core-public.scopedhistory.block.md) | | (prompt?: string | boolean | History.TransitionPromptHook<HistoryLocationState> | undefined) => UnregisterCallback | Add a block prompt requesting user confirmation when navigating away from the current page. | | [createHref](./kibana-plugin-core-public.scopedhistory.createhref.md) | | (location: LocationDescriptorObject<HistoryLocationState>, { prependBasePath }?: {
prependBasePath?: boolean | undefined;
}) => Href | Creates an href (string) to the location. If prependBasePath is true (default), it will prepend the location's path with the scoped history basePath. | | [createSubHistory](./kibana-plugin-core-public.scopedhistory.createsubhistory.md) | | <SubHistoryLocationState = unknown>(basePath: string) => ScopedHistory<SubHistoryLocationState> | Creates a ScopedHistory for a subpath of this ScopedHistory. Useful for applications that may have sub-apps that do not need access to the containing application's history. | | [go](./kibana-plugin-core-public.scopedhistory.go.md) | | (n: number) => void | Send the user forward or backwards in the history stack. | diff --git a/docs/setup/docker.asciidoc b/docs/setup/docker.asciidoc index 75a9799d70fbdb..25883307e69f0d 100644 --- a/docs/setup/docker.asciidoc +++ b/docs/setup/docker.asciidoc @@ -120,7 +120,6 @@ The following settings have different default values when using the Docker images: [horizontal] -`server.name`:: `kibana` `server.host`:: `"0.0.0.0"` `elasticsearch.hosts`:: `http://elasticsearch:9200` `monitoring.ui.container.elasticsearch.enabled`:: `true` diff --git a/examples/search_examples/kibana.json b/examples/search_examples/kibana.json index 07bb6a0b750e30..83996c20343811 100644 --- a/examples/search_examples/kibana.json +++ b/examples/search_examples/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["navigation", "data", "developerExamples", "kibanaUtils"], + "requiredPlugins": ["navigation", "data", "developerExamples", "kibanaUtils", "share"], "optionalPlugins": [], - "requiredBundles": [] + "requiredBundles": ["kibanaReact"] } diff --git a/examples/search_examples/public/application.tsx b/examples/search_examples/public/application.tsx index 7d3b585ba48526..1920cdbe5c697c 100644 --- a/examples/search_examples/public/application.tsx +++ b/examples/search_examples/public/application.tsx @@ -8,26 +8,67 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { Router, Route, Redirect } from 'react-router-dom'; +import { I18nProvider } from '@kbn/i18n/react'; import { AppMountParameters, CoreStart } from '../../../src/core/public'; import { AppPluginStartDependencies } from './types'; -import { SearchExamplesApp } from './components/app'; +import { SearchExamplePage, ExampleLink } from './common/example_page'; +import { SearchExamplesApp } from './search/app'; +import { SearchSessionsExampleApp } from './search_sessions/app'; +import { RedirectAppLinks } from '../../../src/plugins/kibana_react/public'; + +const LINKS: ExampleLink[] = [ + { + path: '/search', + title: 'Search', + }, + { + path: '/search-sessions', + title: 'Search Sessions', + }, + { + path: 'https://github.com/elastic/kibana/blob/master/src/plugins/data/README.mdx', + title: 'README (GitHub)', + }, +]; export const renderApp = ( - { notifications, savedObjects, http }: CoreStart, - { navigation, data }: AppPluginStartDependencies, - { appBasePath, element }: AppMountParameters + { notifications, savedObjects, http, application }: CoreStart, + { data, navigation }: AppPluginStartDependencies, + { element, history }: AppMountParameters ) => { ReactDOM.render( - , + + + + + + + + + + + + + + + + + , element ); - return () => ReactDOM.unmountComponentAtNode(element); + return () => { + data.search.session.clear(); + ReactDOM.unmountComponentAtNode(element); + }; }; diff --git a/examples/search_examples/public/common/example_page.tsx b/examples/search_examples/public/common/example_page.tsx new file mode 100644 index 00000000000000..baa3a8ca6ef0a5 --- /dev/null +++ b/examples/search_examples/public/common/example_page.tsx @@ -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 React, { PropsWithChildren } from 'react'; +import { EuiPage, EuiPageSideBar, EuiSideNav } from '@elastic/eui'; +import { IBasePath } from 'kibana/public'; +import { PLUGIN_ID } from '../../common'; + +export interface ExampleLink { + title: string; + path: string; +} + +interface NavProps { + exampleLinks: ExampleLink[]; + basePath: IBasePath; +} + +const SideNav: React.FC = ({ exampleLinks, basePath }: NavProps) => { + const navItems = exampleLinks.map((example) => ({ + id: example.path, + name: example.title, + 'data-test-subj': example.path, + href: example.path.startsWith('http') + ? example.path + : basePath.prepend(`/app/${PLUGIN_ID}${example.path}`), + })); + + return ( + + ); +}; + +interface Props { + exampleLinks: ExampleLink[]; + basePath: IBasePath; +} + +export const SearchExamplePage: React.FC = ({ + children, + exampleLinks, + basePath, +}: PropsWithChildren) => { + return ( + + + + + {children} + + ); +}; diff --git a/examples/search_examples/public/components/app.tsx b/examples/search_examples/public/components/app.tsx deleted file mode 100644 index bed2dea596caeb..00000000000000 --- a/examples/search_examples/public/components/app.tsx +++ /dev/null @@ -1,459 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { useState, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; -import { BrowserRouter as Router } from 'react-router-dom'; - -import { - EuiButtonEmpty, - EuiCodeBlock, - EuiPage, - EuiPageBody, - EuiPageContent, - EuiPageContentBody, - EuiPageHeader, - EuiTitle, - EuiText, - EuiFlexGrid, - EuiFlexGroup, - EuiFlexItem, - EuiCheckbox, - EuiSpacer, - EuiCode, - EuiComboBox, - EuiFormLabel, -} from '@elastic/eui'; - -import { CoreStart } from '../../../../src/core/public'; -import { mountReactNode } from '../../../../src/core/public/utils'; -import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public'; - -import { - PLUGIN_ID, - PLUGIN_NAME, - IMyStrategyResponse, - SERVER_SEARCH_ROUTE_PATH, -} from '../../common'; - -import { - DataPublicPluginStart, - IndexPattern, - IndexPatternField, - isCompleteResponse, - isErrorResponse, -} from '../../../../src/plugins/data/public'; - -interface SearchExamplesAppDeps { - basename: string; - notifications: CoreStart['notifications']; - http: CoreStart['http']; - savedObjectsClient: CoreStart['savedObjects']['client']; - navigation: NavigationPublicPluginStart; - data: DataPublicPluginStart; -} - -function getNumeric(fields?: IndexPatternField[]) { - if (!fields) return []; - return fields?.filter((f) => f.type === 'number' && f.aggregatable); -} - -function formatFieldToComboBox(field?: IndexPatternField | null) { - if (!field) return []; - return formatFieldsToComboBox([field]); -} - -function formatFieldsToComboBox(fields?: IndexPatternField[]) { - if (!fields) return []; - - return fields?.map((field) => { - return { - label: field.displayName || field.name, - }; - }); -} - -export const SearchExamplesApp = ({ - http, - basename, - notifications, - savedObjectsClient, - navigation, - data, -}: SearchExamplesAppDeps) => { - const { IndexPatternSelect } = data.ui; - const [getCool, setGetCool] = useState(false); - const [timeTook, setTimeTook] = useState(); - const [indexPattern, setIndexPattern] = useState(); - const [fields, setFields] = useState(); - const [selectedFields, setSelectedFields] = useState([]); - const [selectedNumericField, setSelectedNumericField] = useState< - IndexPatternField | null | undefined - >(); - const [request, setRequest] = useState>({}); - const [response, setResponse] = useState>({}); - - // Fetch the default index pattern using the `data.indexPatterns` service, as the component is mounted. - useEffect(() => { - const setDefaultIndexPattern = async () => { - const defaultIndexPattern = await data.indexPatterns.getDefault(); - setIndexPattern(defaultIndexPattern); - }; - - setDefaultIndexPattern(); - }, [data]); - - // Update the fields list every time the index pattern is modified. - useEffect(() => { - setFields(indexPattern?.fields); - }, [indexPattern]); - useEffect(() => { - setSelectedNumericField(fields?.length ? getNumeric(fields)[0] : null); - }, [fields]); - - const doAsyncSearch = async (strategy?: string) => { - if (!indexPattern || !selectedNumericField) return; - - // Constuct the query portion of the search request - const query = data.query.getEsQuery(indexPattern); - - // Constuct the aggregations portion of the search request by using the `data.search.aggs` service. - const aggs = [{ type: 'avg', params: { field: selectedNumericField!.name } }]; - const aggsDsl = data.search.aggs.createAggConfigs(indexPattern, aggs).toDsl(); - - const req = { - params: { - index: indexPattern.title, - body: { - aggs: aggsDsl, - query, - }, - }, - // Add a custom request parameter to be consumed by `MyStrategy`. - ...(strategy ? { get_cool: getCool } : {}), - }; - - // Submit the search request using the `data.search` service. - setRequest(req.params.body); - const searchSubscription$ = data.search - .search(req, { - strategy, - }) - .subscribe({ - next: (res) => { - if (isCompleteResponse(res)) { - setResponse(res.rawResponse); - setTimeTook(res.rawResponse.took); - const avgResult: number | undefined = res.rawResponse.aggregations - ? res.rawResponse.aggregations[1].value - : undefined; - const message = ( - - Searched {res.rawResponse.hits.total} documents.
- The average of {selectedNumericField!.name} is{' '} - {avgResult ? Math.floor(avgResult) : 0}. -
- Is it Cool? {String((res as IMyStrategyResponse).cool)} -
- ); - notifications.toasts.addSuccess({ - title: 'Query result', - text: mountReactNode(message), - }); - searchSubscription$.unsubscribe(); - } else if (isErrorResponse(res)) { - // TODO: Make response error status clearer - notifications.toasts.addWarning('An error has occurred'); - searchSubscription$.unsubscribe(); - } - }, - error: () => { - notifications.toasts.addDanger('Failed to run search'); - }, - }); - }; - - const doSearchSourceSearch = async () => { - if (!indexPattern) return; - - 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); - } - - try { - 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) : ['*']); - - if (selectedNumericField) { - searchSource.setField('aggs', () => { - return data.search.aggs - .createAggConfigs(indexPattern, [ - { type: 'avg', params: { field: selectedNumericField.name } }, - ]) - .toDsl(); - }); - } - - setRequest(await searchSource.getSearchRequestBody()); - const res = await searchSource.fetch(); - setResponse(res); - - const message = Searched {res.hits.total} documents.; - notifications.toasts.addSuccess({ - title: 'Query result', - text: mountReactNode(message), - }); - } catch (e) { - setResponse(e.body); - notifications.toasts.addWarning(`An error has occurred: ${e.message}`); - } - }; - - const onClickHandler = () => { - doAsyncSearch(); - }; - - const onMyStrategyClickHandler = () => { - doAsyncSearch('myStrategy'); - }; - - const onServerClickHandler = async () => { - if (!indexPattern || !selectedNumericField) return; - try { - const res = await http.get(SERVER_SEARCH_ROUTE_PATH, { - query: { - index: indexPattern.title, - field: selectedNumericField!.name, - }, - }); - - notifications.toasts.addSuccess(`Server returned ${JSON.stringify(res)}`); - } catch (e) { - notifications.toasts.addDanger('Failed to run search'); - } - }; - - const onSearchSourceClickHandler = () => { - doSearchSourceSearch(); - }; - - return ( - - - <> - - - - - -

- -

-
-
- - - - - - - - Index Pattern - { - const newIndexPattern = await data.indexPatterns.get( - newIndexPatternId - ); - setIndexPattern(newIndexPattern); - }} - isClearable={false} - /> - - - Numeric Field to Aggregate - { - const fld = indexPattern?.getFieldByName(option[0].label); - setSelectedNumericField(fld || null); - }} - sortMatchesBy="startsWith" - /> - - - - - - Fields to query (leave blank to include all fields) - - { - const flds = option - .map((opt) => indexPattern?.getFieldByName(opt?.label)) - .filter((f) => f); - setSelectedFields(flds.length ? (flds as IndexPatternField[]) : []); - }} - sortMatchesBy="startsWith" - /> - - - - - -

- Searching Elasticsearch using data.search -

-
- - If you want to fetch data from Elasticsearch, you can use the different - services provided by the data plugin. These help you get - the index pattern and search bar configuration, format them into a DSL query - and send it to Elasticsearch. - - - - - - - - - - -

Writing a custom search strategy

-
- - If you want to do some pre or post processing on the server, you might want - to create a custom search strategy. This example uses such a strategy, - passing in custom input and receiving custom output back. - - - } - checked={getCool} - onChange={(event) => setGetCool(event.target.checked)} - /> - - - - - - -

Using search on the server

-
- - You can also run your search request from the server, without registering a - search strategy. This request does not take the configuration of{' '} - TopNavMenu into account, but you could pass those down to - the server as well. - - - - - -
- - -

Request

-
- Search body sent to ES - - {JSON.stringify(request, null, 2)} - -
- - -

Response

-
- - - - - {JSON.stringify(response, null, 2)} - -
-
-
-
-
-
- -
-
- ); -}; diff --git a/examples/search_examples/public/plugin.ts b/examples/search_examples/public/plugin.ts index 495e5b2571a0bc..87992e5493f0e3 100644 --- a/examples/search_examples/public/plugin.ts +++ b/examples/search_examples/public/plugin.ts @@ -19,7 +19,9 @@ import { AppPluginSetupDependencies, AppPluginStartDependencies, } from './types'; +import { createSearchSessionsExampleUrlGenerator } from './search_sessions/url_generator'; import { PLUGIN_NAME } from '../common'; +import img from './search_examples.png'; export class SearchExamplesPlugin implements @@ -31,14 +33,14 @@ export class SearchExamplesPlugin > { public setup( core: CoreSetup, - { developerExamples }: AppPluginSetupDependencies + { developerExamples, share }: AppPluginSetupDependencies ): SearchExamplesPluginSetup { // Register an application into the side navigation menu core.application.register({ id: 'searchExamples', title: PLUGIN_NAME, navLinkStatus: AppNavLinkStatus.hidden, - async mount(params: AppMountParameters) { + mount: async (params: AppMountParameters) => { // Load application bundle const { renderApp } = await import('./application'); // Get start services as specified in kibana.json @@ -51,9 +53,28 @@ export class SearchExamplesPlugin developerExamples.register({ appId: 'searchExamples', title: 'Search Examples', - description: `Search Examples`, + description: `Examples on searching elasticsearch using data plugin: low-level search client (data.search.search), high-level search client (SearchSource), search sessions (data.search.sessions)`, + image: img, + links: [ + { + label: 'README', + href: 'https://github.com/elastic/kibana/tree/master/src/plugins/data/README.mdx', + iconType: 'logoGithub', + target: '_blank', + size: 's', + }, + ], }); + // we need an URL generator for search session examples for restoring a search session + share.urlGenerators.registerUrlGenerator( + createSearchSessionsExampleUrlGenerator(() => { + return core + .getStartServices() + .then(([coreStart]) => ({ appBasePath: coreStart.http.basePath.get() })); + }) + ); + return {}; } diff --git a/examples/search_examples/public/search/app.tsx b/examples/search_examples/public/search/app.tsx new file mode 100644 index 00000000000000..09966b958aff61 --- /dev/null +++ b/examples/search_examples/public/search/app.tsx @@ -0,0 +1,433 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiButtonEmpty, + EuiCodeBlock, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiTitle, + EuiText, + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiCheckbox, + EuiSpacer, + EuiCode, + EuiComboBox, + EuiFormLabel, +} from '@elastic/eui'; + +import { CoreStart } from '../../../../src/core/public'; +import { mountReactNode } from '../../../../src/core/public/utils'; +import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public'; + +import { + PLUGIN_ID, + PLUGIN_NAME, + IMyStrategyResponse, + SERVER_SEARCH_ROUTE_PATH, +} from '../../common'; + +import { + DataPublicPluginStart, + IndexPattern, + IndexPatternField, + isCompleteResponse, + isErrorResponse, +} from '../../../../src/plugins/data/public'; + +interface SearchExamplesAppDeps { + notifications: CoreStart['notifications']; + http: CoreStart['http']; + navigation: NavigationPublicPluginStart; + data: DataPublicPluginStart; +} + +function getNumeric(fields?: IndexPatternField[]) { + if (!fields) return []; + return fields?.filter((f) => f.type === 'number' && f.aggregatable); +} + +function formatFieldToComboBox(field?: IndexPatternField | null) { + if (!field) return []; + return formatFieldsToComboBox([field]); +} + +function formatFieldsToComboBox(fields?: IndexPatternField[]) { + if (!fields) return []; + + return fields?.map((field) => { + return { + label: field.displayName || field.name, + }; + }); +} + +export const SearchExamplesApp = ({ + http, + notifications, + navigation, + data, +}: SearchExamplesAppDeps) => { + const { IndexPatternSelect } = data.ui; + const [getCool, setGetCool] = useState(false); + const [timeTook, setTimeTook] = useState(); + const [indexPattern, setIndexPattern] = useState(); + const [fields, setFields] = useState(); + const [selectedFields, setSelectedFields] = useState([]); + const [selectedNumericField, setSelectedNumericField] = useState< + IndexPatternField | null | undefined + >(); + const [request, setRequest] = useState>({}); + const [response, setResponse] = useState>({}); + + // Fetch the default index pattern using the `data.indexPatterns` service, as the component is mounted. + useEffect(() => { + const setDefaultIndexPattern = async () => { + const defaultIndexPattern = await data.indexPatterns.getDefault(); + setIndexPattern(defaultIndexPattern); + }; + + setDefaultIndexPattern(); + }, [data]); + + // Update the fields list every time the index pattern is modified. + useEffect(() => { + setFields(indexPattern?.fields); + }, [indexPattern]); + useEffect(() => { + setSelectedNumericField(fields?.length ? getNumeric(fields)[0] : null); + }, [fields]); + + const doAsyncSearch = async (strategy?: string) => { + if (!indexPattern || !selectedNumericField) return; + + // Construct the query portion of the search request + const query = data.query.getEsQuery(indexPattern); + + // Construct the aggregations portion of the search request by using the `data.search.aggs` service. + const aggs = [{ type: 'avg', params: { field: selectedNumericField!.name } }]; + const aggsDsl = data.search.aggs.createAggConfigs(indexPattern, aggs).toDsl(); + + const req = { + params: { + index: indexPattern.title, + body: { + aggs: aggsDsl, + query, + }, + }, + // Add a custom request parameter to be consumed by `MyStrategy`. + ...(strategy ? { get_cool: getCool } : {}), + }; + + // Submit the search request using the `data.search` service. + setRequest(req.params.body); + const searchSubscription$ = data.search + .search(req, { + strategy, + }) + .subscribe({ + next: (res) => { + if (isCompleteResponse(res)) { + setResponse(res.rawResponse); + setTimeTook(res.rawResponse.took); + const avgResult: number | undefined = res.rawResponse.aggregations + ? res.rawResponse.aggregations[1].value + : undefined; + const message = ( + + Searched {res.rawResponse.hits.total} documents.
+ The average of {selectedNumericField!.name} is{' '} + {avgResult ? Math.floor(avgResult) : 0}. +
+ Is it Cool? {String((res as IMyStrategyResponse).cool)} +
+ ); + notifications.toasts.addSuccess({ + title: 'Query result', + text: mountReactNode(message), + }); + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(res)) { + // TODO: Make response error status clearer + notifications.toasts.addWarning('An error has occurred'); + searchSubscription$.unsubscribe(); + } + }, + error: () => { + notifications.toasts.addDanger('Failed to run search'); + }, + }); + }; + + const doSearchSourceSearch = async () => { + if (!indexPattern) return; + + 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); + } + + try { + 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) : ['*']); + + if (selectedNumericField) { + searchSource.setField('aggs', () => { + return data.search.aggs + .createAggConfigs(indexPattern, [ + { type: 'avg', params: { field: selectedNumericField.name } }, + ]) + .toDsl(); + }); + } + + setRequest(await searchSource.getSearchRequestBody()); + const res = await searchSource.fetch(); + setResponse(res); + + const message = Searched {res.hits.total} documents.; + notifications.toasts.addSuccess({ + title: 'Query result', + text: mountReactNode(message), + }); + } catch (e) { + setResponse(e.body); + notifications.toasts.addWarning(`An error has occurred: ${e.message}`); + } + }; + + const onClickHandler = () => { + doAsyncSearch(); + }; + + const onMyStrategyClickHandler = () => { + doAsyncSearch('myStrategy'); + }; + + const onServerClickHandler = async () => { + if (!indexPattern || !selectedNumericField) return; + try { + const res = await http.get(SERVER_SEARCH_ROUTE_PATH, { + query: { + index: indexPattern.title, + field: selectedNumericField!.name, + }, + }); + + notifications.toasts.addSuccess(`Server returned ${JSON.stringify(res)}`); + } catch (e) { + notifications.toasts.addDanger('Failed to run search'); + } + }; + + const onSearchSourceClickHandler = () => { + doSearchSourceSearch(); + }; + + return ( + + + +

+ +

+
+
+ + + + + + + + + Index Pattern + { + const newIndexPattern = await data.indexPatterns.get(newIndexPatternId); + setIndexPattern(newIndexPattern); + }} + isClearable={false} + /> + + + Numeric Field to Aggregate + { + const fld = indexPattern?.getFieldByName(option[0].label); + setSelectedNumericField(fld || null); + }} + sortMatchesBy="startsWith" + /> + + + + + Fields to query (leave blank to include all fields) + { + const flds = option + .map((opt) => indexPattern?.getFieldByName(opt?.label)) + .filter((f) => f); + setSelectedFields(flds.length ? (flds as IndexPatternField[]) : []); + }} + sortMatchesBy="startsWith" + /> + + + + + +

+ Searching Elasticsearch using data.search +

+
+ + If you want to fetch data from Elasticsearch, you can use the different services + provided by the data plugin. These help you get the index pattern + and search bar configuration, format them into a DSL query and send it to + Elasticsearch. + + + + + + + + + + +

Writing a custom search strategy

+
+ + If you want to do some pre or post processing on the server, you might want to + create a custom search strategy. This example uses such a strategy, passing in + custom input and receiving custom output back. + + + } + checked={getCool} + onChange={(event) => setGetCool(event.target.checked)} + /> + + + + + + +

Using search on the server

+
+ + You can also run your search request from the server, without registering a search + strategy. This request does not take the configuration of{' '} + TopNavMenu into account, but you could pass those down to the + server as well. + + + + + +
+ + +

Request

+
+ Search body sent to ES + + {JSON.stringify(request, null, 2)} + +
+ + +

Response

+
+ + + + + {JSON.stringify(response, null, 2)} + +
+
+
+
+
+ ); +}; diff --git a/examples/search_examples/public/search_examples.png b/examples/search_examples/public/search_examples.png new file mode 100644 index 00000000000000..f17827a5bf4681 Binary files /dev/null and b/examples/search_examples/public/search_examples.png differ diff --git a/examples/search_examples/public/search_sessions/app.tsx b/examples/search_examples/public/search_sessions/app.tsx new file mode 100644 index 00000000000000..3c9d38776dfdf0 --- /dev/null +++ b/examples/search_examples/public/search_sessions/app.tsx @@ -0,0 +1,768 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import useObservable from 'react-use/lib/useObservable'; +import { + EuiAccordion, + EuiButtonEmpty, + EuiCallOut, + EuiCode, + EuiCodeBlock, + EuiComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiFormLabel, + EuiLoadingSpinner, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { catchError, map, tap } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { CoreStart } from '../../../../src/core/public'; +import { mountReactNode } from '../../../../src/core/public/utils'; +import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public'; + +import { PLUGIN_ID } from '../../common'; + +import { + connectToQueryState, + DataPublicPluginStart, + IEsSearchRequest, + IEsSearchResponse, + IndexPattern, + IndexPatternField, + isCompleteResponse, + isErrorResponse, + QueryState, + SearchSessionState, + TimeRange, +} from '../../../../src/plugins/data/public'; +import { + createStateContainer, + useContainerState, +} from '../../../../src/plugins/kibana_utils/public'; +import { + getInitialStateFromUrl, + SEARCH_SESSIONS_EXAMPLES_APP_URL_GENERATOR, + SearchSessionExamplesUrlGeneratorState, +} from './url_generator'; + +interface SearchSessionsExampleAppDeps { + notifications: CoreStart['notifications']; + navigation: NavigationPublicPluginStart; + data: DataPublicPluginStart; +} + +/** + * This example is an app with a step by step guide + * walking through search session lifecycle + * These enum represents all important steps in this demo + */ +enum DemoStep { + ConfigureQuery, + RunSession, + SaveSession, + RestoreSessionOnScreen, + RestoreSessionViaManagement, +} + +interface State extends QueryState { + indexPatternId?: string; + numericFieldName?: string; + + /** + * If landed into the app with restore URL + */ + restoreSessionId?: string; +} + +export const SearchSessionsExampleApp = ({ + notifications, + navigation, + data, +}: SearchSessionsExampleAppDeps) => { + const { IndexPatternSelect } = data.ui; + + const [isSearching, setIsSearching] = useState(false); + const [request, setRequest] = useState(null); + const [response, setResponse] = useState(null); + const [tookMs, setTookMs] = useState(null); + const nextRequestIdRef = useRef(0); + + const [restoreRequest, setRestoreRequest] = useState(null); + const [restoreResponse, setRestoreResponse] = useState(null); + const [restoreTookMs, setRestoreTookMs] = useState(null); + + const sessionState = useObservable(data.search.session.state$) || SearchSessionState.None; + + const demoStep: DemoStep = (() => { + switch (sessionState) { + case SearchSessionState.None: + case SearchSessionState.Canceled: + return DemoStep.ConfigureQuery; + case SearchSessionState.Loading: + case SearchSessionState.Completed: + return DemoStep.RunSession; + case SearchSessionState.BackgroundCompleted: + case SearchSessionState.BackgroundLoading: + return DemoStep.SaveSession; + case SearchSessionState.Restored: + return DemoStep.RestoreSessionOnScreen; + } + })(); + + const { + numericFieldName, + indexPattern, + selectedField, + fields, + setIndexPattern, + setNumericFieldName, + state, + } = useAppState({ data }); + + const isRestoring = !!state.restoreSessionId; + + const enableSessionStorage = useCallback(() => { + data.search.session.enableStorage({ + getName: async () => 'Search sessions example', + getUrlGeneratorData: async () => ({ + initialState: { + time: data.query.timefilter.timefilter.getTime(), + filters: data.query.filterManager.getFilters(), + query: data.query.queryString.getQuery(), + indexPatternId: indexPattern?.id, + numericFieldName, + } as SearchSessionExamplesUrlGeneratorState, + restoreState: { + time: data.query.timefilter.timefilter.getAbsoluteTime(), + filters: data.query.filterManager.getFilters(), + query: data.query.queryString.getQuery(), + indexPatternId: indexPattern?.id, + numericFieldName, + searchSessionId: data.search.session.getSessionId(), + } as SearchSessionExamplesUrlGeneratorState, + urlGeneratorId: SEARCH_SESSIONS_EXAMPLES_APP_URL_GENERATOR, + }), + }); + }, [ + data.query.filterManager, + data.query.queryString, + data.query.timefilter.timefilter, + data.search.session, + indexPattern?.id, + numericFieldName, + ]); + + const reset = useCallback(() => { + setRequest(null); + setResponse(null); + setRestoreRequest(null); + setRestoreResponse(null); + setTookMs(null); + setRestoreTookMs(null); + setIsSearching(false); + data.search.session.clear(); + enableSessionStorage(); + nextRequestIdRef.current = 0; + }, [ + setRequest, + setResponse, + setRestoreRequest, + setRestoreResponse, + setIsSearching, + data.search.session, + enableSessionStorage, + ]); + + useEffect(() => { + enableSessionStorage(); + return () => { + data.search.session.clear(); + }; + }, [data.search.session, enableSessionStorage]); + + useEffect(() => { + reset(); + }, [reset, state]); + + const search = useCallback( + (restoreSearchSessionId?: string) => { + if (!indexPattern) return; + if (!numericFieldName) return; + setIsSearching(true); + const requestId = ++nextRequestIdRef.current; + doSearch({ indexPattern, numericFieldName, restoreSearchSessionId }, { data, notifications }) + .then(({ response: res, request: req, tookMs: _tookMs }) => { + if (requestId !== nextRequestIdRef.current) return; // no longer interested in this result + if (restoreSearchSessionId) { + setRestoreRequest(req); + setRestoreResponse(res); + setRestoreTookMs(_tookMs ?? null); + } else { + setRequest(req); + setResponse(res); + setTookMs(_tookMs ?? null); + } + }) + .finally(() => { + if (requestId !== nextRequestIdRef.current) return; // no longer interested in this result + setIsSearching(false); + }); + }, + [data, notifications, indexPattern, numericFieldName] + ); + + useEffect(() => { + if (state.restoreSessionId) { + search(state.restoreSessionId); + } + }, [search, state.restoreSessionId]); + + return ( + + + + +

Search session example

+
+ + {!isShardDelayEnabled(data) && ( + <> + + + + )} + +

+ This example shows how you can use data.search.session service to + group your searches into a search session and allow user to save search results for + later.
+ Start a long-running search, save the session and then restore it. See how fast search + is completed when restoring the session comparing to when doing initial search.
+
+ Follow this demo step-by-step:{' '} + configure the query, start the search and then save your session. You can save + your session both when search is still in progress or when it is completed. After you + save the session and when initial search is completed you can{' '} + restore the session: the search will re-run reusing previous results. It will + finish a lot faster then the initial search. You can also{' '} + go to search sessions management and get back to the stored results from + there. +

+
+
+
+ + + {!isRestoring && ( + <> + +

1. Configure the search query (OK to leave defaults)

+
+ + + + Index Pattern + { + if (!id) return; + setIndexPattern(id); + }} + isClearable={false} + /> + + + Numeric Field to Aggregate + { + const fld = indexPattern?.getFieldByName(option[0].label); + if (!fld) return; + setNumericFieldName(fld?.name); + }} + sortMatchesBy="startsWith" + /> + + + + +

+ 2. Start the search using data.search service +

+
+ + In this example each search creates a new session by calling{' '} + data.search.session.start() that returns a{' '} + searchSessionId. Then this searchSessionId is + passed into a search request. + +
+ {demoStep === DemoStep.ConfigureQuery && ( + search()} + iconType="play" + disabled={isSearching} + > + Start the search from low-level client (data.search.search) + + )} + {isSearching && } + + {response && request && ( + + )} +
+
+ + {(demoStep === DemoStep.RunSession || + demoStep === DemoStep.RestoreSessionOnScreen || + demoStep === DemoStep.SaveSession) && ( + <> + +

3. Save your session

+
+ + Use the search session indicator in the Kibana header to save the search + session. +
+ { + // hack for demo purposes: + document + .querySelector('[data-test-subj="searchSessionIndicator"]') + ?.querySelector('button') + ?.click(); + }} + isDisabled={ + demoStep === DemoStep.RestoreSessionOnScreen || + demoStep === DemoStep.SaveSession + } + > + Try saving the session using the search session indicator in the header. + +
+
+ + )} + {(demoStep === DemoStep.RestoreSessionOnScreen || + demoStep === DemoStep.SaveSession) && ( + <> + + +

4. Restore the session

+
+ + Now you can restore your saved session. The same search request completes + significantly faster because it reuses stored results. + +
+ {!isSearching && !restoreResponse && ( + { + search(data.search.session.getSessionId()); + }} + > + Restore the search session + + )} + {isSearching && } + + {restoreRequest && restoreResponse && ( + + )} +
+
+ + )} + {demoStep === DemoStep.RestoreSessionOnScreen && ( + <> + + +

5. Restore from Management

+
+ + You can also get back to your session from the Search Session Management. +
+ { + // hack for demo purposes: + document + .querySelector('[data-test-subj="searchSessionIndicator"]') + ?.querySelector('button') + ?.click(); + }} + > + Use Search Session indicator to navigate to management + +
+
+ + )} + + )} + {isRestoring && ( + <> + +

You restored the search session!

+
+ + + {isSearching && } + + {restoreRequest && restoreResponse && ( + + )} + + + )} + + { + // hack to quickly reset all the state and remove state stuff from the URL + window.location.assign(window.location.href.split('?')[0]); + }} + > + Start again + +
+
+
+ ); +}; + +function SearchInspector({ + accordionId, + response, + request, + tookMs, +}: { + accordionId: string; + response: IEsSearchResponse; + request: IEsSearchRequest; + tookMs: number | null; +}) { + return ( +
+ The search took: {tookMs ? Math.round(tookMs) : 'unknown'}ms + + + + +

Request

+
+ Search body sent to ES + + {JSON.stringify(request, null, 2)} + +
+ + +

Response

+
+ + {JSON.stringify(response, null, 2)} + +
+
+
+
+ ); +} + +function useAppState({ data }: { data: DataPublicPluginStart }) { + const stateContainer = useMemo(() => { + const { + filters, + time, + searchSessionId, + numericFieldName, + indexPatternId, + query, + } = getInitialStateFromUrl(); + + if (filters) { + data.query.filterManager.setFilters(filters); + } + + if (query) { + data.query.queryString.setQuery(query); + } + + if (time) { + data.query.timefilter.timefilter.setTime(time); + } + + return createStateContainer({ + restoreSessionId: searchSessionId, + numericFieldName, + indexPatternId, + }); + }, [data.query.filterManager, data.query.queryString, data.query.timefilter.timefilter]); + const setState = useCallback( + (state: Partial) => stateContainer.set({ ...stateContainer.get(), ...state }), + [stateContainer] + ); + const state = useContainerState(stateContainer); + useEffect(() => { + return connectToQueryState(data.query, stateContainer, { + time: true, + query: true, + filters: true, + refreshInterval: false, + }); + }, [stateContainer, data.query]); + + const [fields, setFields] = useState(); + const [indexPattern, setIndexPattern] = useState(); + + // Fetch the default index pattern using the `data.indexPatterns` service, as the component is mounted. + useEffect(() => { + let canceled = false; + const loadIndexPattern = async () => { + const loadedIndexPattern = state.indexPatternId + ? await data.indexPatterns.get(state.indexPatternId) + : await data.indexPatterns.getDefault(); + if (canceled) return; + if (!loadedIndexPattern) return; + if (!state.indexPatternId) { + setState({ + indexPatternId: loadedIndexPattern.id, + }); + } + + setIndexPattern(loadedIndexPattern); + }; + + loadIndexPattern(); + return () => { + canceled = true; + }; + }, [data, setState, state.indexPatternId]); + + // Update the fields list every time the index pattern is modified. + useEffect(() => { + setFields(indexPattern?.fields); + }, [indexPattern]); + useEffect(() => { + if (state.numericFieldName) return; + setState({ numericFieldName: fields?.length ? getNumeric(fields)[0]?.name : undefined }); + }, [setState, fields, state.numericFieldName]); + + const selectedField: IndexPatternField | undefined = useMemo( + () => indexPattern?.fields.find((field) => field.name === state.numericFieldName), + [indexPattern?.fields, state.numericFieldName] + ); + + return { + selectedField, + indexPattern, + numericFieldName: state.numericFieldName, + fields, + setNumericFieldName: (field: string) => setState({ numericFieldName: field }), + setIndexPattern: (indexPatternId: string) => setState({ indexPatternId }), + state, + }; +} + +function doSearch( + { + indexPattern, + numericFieldName, + restoreSearchSessionId, + }: { + indexPattern: IndexPattern; + numericFieldName: string; + restoreSearchSessionId?: string; + }, + { + data, + notifications, + }: { data: DataPublicPluginStart; notifications: CoreStart['notifications'] } +): Promise<{ request: IEsSearchRequest; response: IEsSearchResponse; tookMs?: number }> { + if (!indexPattern) return Promise.reject('Select an index patten'); + if (!numericFieldName) return Promise.reject('Select a field to aggregate on'); + + // start a new session or restore an existing one + let restoreTimeRange: TimeRange | undefined; + if (restoreSearchSessionId) { + // when restoring need to make sure we are forcing absolute time range + restoreTimeRange = data.query.timefilter.timefilter.getAbsoluteTime(); + data.search.session.restore(restoreSearchSessionId); + } + const sessionId = restoreSearchSessionId ? restoreSearchSessionId : data.search.session.start(); + + // Construct the query portion of the search request + const query = data.query.getEsQuery(indexPattern, restoreTimeRange); + + // Construct the aggregations portion of the search request by using the `data.search.aggs` service. + + const aggs = isShardDelayEnabled(data) + ? [ + { type: 'avg', params: { field: numericFieldName } }, + { type: 'shard_delay', params: { delay: '5s' } }, + ] + : [{ type: 'avg', params: { field: numericFieldName } }]; + + const aggsDsl = data.search.aggs.createAggConfigs(indexPattern, aggs).toDsl(); + + const req = { + params: { + index: indexPattern.title, + body: { + aggs: aggsDsl, + query, + }, + }, + }; + + const startTs = performance.now(); + + // Submit the search request using the `data.search` service. + return data.search + .search(req, { sessionId }) + .pipe( + tap((res) => { + if (isCompleteResponse(res)) { + const avgResult: number | undefined = res.rawResponse.aggregations + ? res.rawResponse.aggregations[1]?.value ?? res.rawResponse.aggregations[2]?.value + : undefined; + const message = ( + + Searched {res.rawResponse.hits.total} documents.
+ The average of {numericFieldName} is {avgResult ? Math.floor(avgResult) : 0} + . +
+
+ ); + notifications.toasts.addSuccess({ + title: 'Query result', + text: mountReactNode(message), + }); + } else if (isErrorResponse(res)) { + notifications.toasts.addWarning('An error has occurred'); + } + }), + map((res) => ({ response: res, request: req, tookMs: performance.now() - startTs })), + catchError((e) => { + notifications.toasts.addDanger('Failed to run search'); + return of({ request: req, response: e }); + }) + ) + .toPromise(); +} + +function getNumeric(fields?: IndexPatternField[]) { + if (!fields) return []; + return fields?.filter((f) => f.type === 'number' && f.aggregatable); +} + +function formatFieldToComboBox(field?: IndexPatternField | null) { + if (!field) return []; + return formatFieldsToComboBox([field]); +} + +function formatFieldsToComboBox(fields?: IndexPatternField[]) { + if (!fields) return []; + + return fields?.map((field) => { + return { + label: field.displayName || field.name, + }; + }); +} + +/** + * To make this demo more convincing it uses `shardDelay` agg which adds artificial delay to a search request, + * to enable `shardDelay` make sure to set `data.search.aggs.shardDelay.enabled: true` in your kibana.dev.yml + */ +function isShardDelayEnabled(data: DataPublicPluginStart): boolean { + try { + return !!data.search.aggs.types.get('shard_delay'); + } catch (e) { + return false; + } +} + +function NoShardDelayCallout() { + return ( + + shardDelay is missing! + + } + color="warning" + iconType="help" + > +

+ This demo works best with shardDelay aggregation which simulates slow + queries.
+ We recommend to enable it in your kibana.dev.yml: +

+ data.search.aggs.shardDelay.enabled: true +
+ ); +} diff --git a/examples/search_examples/public/search_sessions/url_generator.ts b/examples/search_examples/public/search_sessions/url_generator.ts new file mode 100644 index 00000000000000..69355f9046c46e --- /dev/null +++ b/examples/search_examples/public/search_sessions/url_generator.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { TimeRange, Filter, Query, esFilters } from '../../../../src/plugins/data/public'; +import { getStatesFromKbnUrl, setStateToKbnUrl } from '../../../../src/plugins/kibana_utils/public'; +import { UrlGeneratorsDefinition } from '../../../../src/plugins/share/public'; + +export const STATE_STORAGE_KEY = '_a'; +export const GLOBAL_STATE_STORAGE_KEY = '_g'; + +export const SEARCH_SESSIONS_EXAMPLES_APP_URL_GENERATOR = + 'SEARCH_SESSIONS_EXAMPLES_APP_URL_GENERATOR'; + +export interface AppUrlState { + filters?: Filter[]; + query?: Query; + indexPatternId?: string; + numericFieldName?: string; + searchSessionId?: string; +} + +export interface GlobalUrlState { + filters?: Filter[]; + time?: TimeRange; +} + +export type SearchSessionExamplesUrlGeneratorState = AppUrlState & GlobalUrlState; + +export const createSearchSessionsExampleUrlGenerator = ( + getStartServices: () => Promise<{ + appBasePath: string; + }> +): UrlGeneratorsDefinition => ({ + id: SEARCH_SESSIONS_EXAMPLES_APP_URL_GENERATOR, + createUrl: async (state: SearchSessionExamplesUrlGeneratorState) => { + const startServices = await getStartServices(); + const appBasePath = startServices.appBasePath; + const path = `${appBasePath}/app/searchExamples/search-sessions`; + + let url = setStateToKbnUrl( + STATE_STORAGE_KEY, + { + query: state.query, + filters: state.filters?.filter((f) => !esFilters.isFilterPinned(f)), + indexPatternId: state.indexPatternId, + numericFieldName: state.numericFieldName, + searchSessionId: state.searchSessionId, + } as AppUrlState, + { useHash: false, storeInHashQuery: false }, + path + ); + + url = setStateToKbnUrl( + GLOBAL_STATE_STORAGE_KEY, + { + time: state.time, + filters: state.filters?.filter((f) => esFilters.isFilterPinned(f)), + } as GlobalUrlState, + { useHash: false, storeInHashQuery: false }, + url + ); + + return url; + }, +}); + +export function getInitialStateFromUrl(): SearchSessionExamplesUrlGeneratorState { + const { + _a: { numericFieldName, indexPatternId, searchSessionId, filters: aFilters, query } = {}, + _g: { filters: gFilters, time } = {}, + } = getStatesFromKbnUrl<{ _a: AppUrlState; _g: GlobalUrlState }>( + window.location.href, + ['_a', '_g'], + { + getFromHashQuery: false, + } + ); + + return { + numericFieldName, + searchSessionId, + time, + filters: [...(gFilters ?? []), ...(aFilters ?? [])], + indexPatternId, + query, + }; +} diff --git a/examples/search_examples/public/types.ts b/examples/search_examples/public/types.ts index f2e0fe09589717..fd3b869af3fe3c 100644 --- a/examples/search_examples/public/types.ts +++ b/examples/search_examples/public/types.ts @@ -9,6 +9,7 @@ import { NavigationPublicPluginStart } from '../../../src/plugins/navigation/public'; import { DataPublicPluginStart } from '../../../src/plugins/data/public'; import { DeveloperExamplesSetup } from '../../developer_examples/public'; +import { SharePluginSetup } from '../../../src/plugins/share/public'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SearchExamplesPluginSetup {} @@ -17,6 +18,7 @@ export interface SearchExamplesPluginStart {} export interface AppPluginSetupDependencies { developerExamples: DeveloperExamplesSetup; + share: SharePluginSetup; } export interface AppPluginStartDependencies { diff --git a/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts index 64e5626c94c8bb..ae750023a8e392 100644 --- a/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts +++ b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts @@ -57,20 +57,6 @@ export async function migrateKibanaIndex({ client: Client; kbnClient: KbnClient; }) { - // we allow dynamic mappings on the index, as some interceptors are accessing documents before - // the migration is actually performed. The migrator will put the value back to `strict` after migration. - await client.indices.putMapping( - { - index: '.kibana', - body: { - dynamic: true, - }, - }, - { - ignore: [404], - } - ); - await kbnClient.savedObjects.migrate(); } diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 0d977d104951ac..5e999ff94b9ce9 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; -import { map, shareReplay, takeUntil, distinctUntilChanged, filter } from 'rxjs/operators'; +import { map, shareReplay, takeUntil, distinctUntilChanged, filter, take } from 'rxjs/operators'; import { createBrowserHistory, History } from 'history'; import { MountPoint } from '../types'; @@ -31,6 +31,7 @@ import { NavigateToAppOptions, } from './types'; import { getLeaveAction, isConfirmAction } from './application_leave'; +import { getUserConfirmationHandler } from './navigation_confirm'; import { appendAppPath, parseAppUrl, relativeToAbsolute, getAppInfo } from './utils'; interface SetupDeps { @@ -92,6 +93,7 @@ export class ApplicationService { private history?: History; private navigate?: (url: string, state: unknown, replace: boolean) => void; private redirectTo?: (url: string) => void; + private overlayStart$ = new Subject(); public setup({ http: { basePath }, @@ -101,7 +103,14 @@ export class ApplicationService { history, }: SetupDeps): InternalApplicationSetup { const basename = basePath.get(); - this.history = history || createBrowserHistory({ basename }); + this.history = + history || + createBrowserHistory({ + basename, + getUserConfirmation: getUserConfirmationHandler({ + overlayPromise: this.overlayStart$.pipe(take(1)).toPromise(), + }), + }); this.navigate = (url, state, replace) => { // basePath not needed here because `history` is configured with basename @@ -173,6 +182,8 @@ export class ApplicationService { throw new Error('ApplicationService#setup() must be invoked before start.'); } + this.overlayStart$.next(overlays); + const httpLoadingCount$ = new BehaviorSubject(0); http.addLoadingCountSource(httpLoadingCount$); diff --git a/src/core/public/application/navigation_confirm.test.ts b/src/core/public/application/navigation_confirm.test.ts new file mode 100644 index 00000000000000..d31f25fd94c934 --- /dev/null +++ b/src/core/public/application/navigation_confirm.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 { OverlayStart } from '../overlays'; +import { overlayServiceMock } from '../overlays/overlay_service.mock'; +import { getUserConfirmationHandler, ConfirmHandler } from './navigation_confirm'; + +const nextTick = () => new Promise((resolve) => setImmediate(resolve)); + +describe('getUserConfirmationHandler', () => { + let overlayStart: ReturnType; + let overlayPromise: Promise; + let resolvePromise: Function; + let rejectPromise: Function; + let fallbackHandler: jest.MockedFunction; + let handler: ConfirmHandler; + + beforeEach(() => { + overlayStart = overlayServiceMock.createStartContract(); + overlayPromise = new Promise((resolve, reject) => { + resolvePromise = () => resolve(overlayStart); + rejectPromise = () => reject('some error'); + }); + fallbackHandler = jest.fn().mockImplementation((message, callback) => { + callback(true); + }); + + handler = getUserConfirmationHandler({ + overlayPromise, + fallbackHandler, + }); + }); + + it('uses the fallback handler if the promise is not resolved yet', () => { + const callback = jest.fn(); + handler('foo', callback); + + expect(fallbackHandler).toHaveBeenCalledTimes(1); + expect(fallbackHandler).toHaveBeenCalledWith('foo', callback); + }); + + it('calls the callback with the value returned by the fallback handler', async () => { + const callback = jest.fn(); + handler('foo', callback); + + expect(fallbackHandler).toHaveBeenCalledTimes(1); + expect(fallbackHandler).toHaveBeenCalledWith('foo', callback); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(true); + }); + + it('uses the overlay handler once the promise is resolved', async () => { + resolvePromise(); + await nextTick(); + + const callback = jest.fn(); + handler('foo', callback); + + expect(fallbackHandler).not.toHaveBeenCalled(); + + expect(overlayStart.openConfirm).toHaveBeenCalledTimes(1); + expect(overlayStart.openConfirm).toHaveBeenCalledWith('foo', expect.any(Object)); + }); + + it('calls the callback with the value returned by `openConfirm`', async () => { + overlayStart.openConfirm.mockResolvedValue(true); + + resolvePromise(); + await nextTick(); + + const callback = jest.fn(); + handler('foo', callback); + + await nextTick(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(true); + }); + + it('uses the fallback handler if the promise rejects', async () => { + rejectPromise(); + await nextTick(); + + const callback = jest.fn(); + handler('foo', callback); + + expect(fallbackHandler).toHaveBeenCalledTimes(1); + expect(overlayStart.openConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/public/application/navigation_confirm.ts b/src/core/public/application/navigation_confirm.ts new file mode 100644 index 00000000000000..9bae41c71e2d00 --- /dev/null +++ b/src/core/public/application/navigation_confirm.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 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 { OverlayStart } from 'kibana/public'; + +export type ConfirmHandlerCallback = (result: boolean) => void; +export type ConfirmHandler = (message: string, callback: ConfirmHandlerCallback) => void; + +interface GetUserConfirmationHandlerParams { + overlayPromise: Promise; + fallbackHandler?: ConfirmHandler; +} + +export const getUserConfirmationHandler = ({ + overlayPromise, + fallbackHandler = windowConfirm, +}: GetUserConfirmationHandlerParams): ConfirmHandler => { + let overlayConfirm: ConfirmHandler; + + overlayPromise.then( + (overlay) => { + overlayConfirm = getOverlayConfirmHandler(overlay); + }, + () => { + // should never append, but even if it does, we don't need to do anything, + // and will just use the default window confirm instead + } + ); + + return (message: string, callback: ConfirmHandlerCallback) => { + if (overlayConfirm) { + overlayConfirm(message, callback); + } else { + fallbackHandler(message, callback); + } + }; +}; + +const windowConfirm: ConfirmHandler = (message: string, callback: ConfirmHandlerCallback) => { + const confirmed = window.confirm(message); + callback(confirmed); +}; + +const getOverlayConfirmHandler = (overlay: OverlayStart): ConfirmHandler => { + return (message: string, callback: ConfirmHandlerCallback) => { + overlay + .openConfirm(message, { title: ' ', 'data-test-subj': 'navigationBlockConfirmModal' }) + .then( + (confirmed) => { + callback(confirmed); + }, + () => { + callback(false); + } + ); + }; +}; diff --git a/src/core/public/application/scoped_history.test.ts b/src/core/public/application/scoped_history.test.ts index 9e25809d670079..2c8c66d447c5f7 100644 --- a/src/core/public/application/scoped_history.test.ts +++ b/src/core/public/application/scoped_history.test.ts @@ -7,7 +7,8 @@ */ import { ScopedHistory } from './scoped_history'; -import { createMemoryHistory } from 'history'; +import { createMemoryHistory, History } from 'history'; +import type { ConfirmHandler } from './navigation_confirm'; describe('ScopedHistory', () => { describe('construction', () => { @@ -336,4 +337,153 @@ describe('ScopedHistory', () => { expect(gh.length).toBe(4); }); }); + + describe('block', () => { + let gh: History; + let h: ScopedHistory; + + const initHistory = ({ + initialPath = '/app/wow', + scopedHistoryPath = '/app/wow', + confirmHandler, + }: { + initialPath?: string; + scopedHistoryPath?: string; + confirmHandler?: ConfirmHandler; + } = {}) => { + gh = createMemoryHistory({ + getUserConfirmation: confirmHandler, + }); + gh.push(initialPath); + h = new ScopedHistory(gh, scopedHistoryPath); + }; + + it('calls block on the global history', () => { + initHistory(); + + const blockSpy = jest.spyOn(gh, 'block'); + h.block('confirm'); + + expect(blockSpy).toHaveBeenCalledTimes(1); + expect(blockSpy).toHaveBeenCalledWith('confirm'); + }); + + it('returns a wrapped unregister function', () => { + initHistory(); + + const blockSpy = jest.spyOn(gh, 'block'); + const unregister = jest.fn(); + blockSpy.mockReturnValue(unregister); + + const wrapperUnregister = h.block('confirm'); + + expect(unregister).not.toHaveBeenCalled(); + + wrapperUnregister(); + + expect(unregister).toHaveBeenCalledTimes(1); + }); + + it('calls the block handler when navigating to another app', () => { + initHistory(); + + const blockHandler = jest.fn().mockReturnValue(true); + + h.block(blockHandler); + + gh.push('/app/other'); + + expect(blockHandler).toHaveBeenCalledTimes(1); + expect(gh.location.pathname).toEqual('/app/other'); + }); + + it('calls the block handler when navigating inside the current app', () => { + initHistory(); + + const blockHandler = jest.fn().mockReturnValue(true); + + h.block(blockHandler); + + gh.push('/app/wow/another-page'); + + expect(blockHandler).toHaveBeenCalledTimes(1); + expect(gh.location.pathname).toEqual('/app/wow/another-page'); + }); + + it('can block the navigation', () => { + initHistory(); + + const blockHandler = jest.fn().mockReturnValue(false); + + h.block(blockHandler); + + gh.push('/app/other'); + + expect(blockHandler).toHaveBeenCalledTimes(1); + expect(gh.location.pathname).toEqual('/app/wow'); + }); + + it('no longer blocks the navigation when unregistered', () => { + initHistory(); + + const blockHandler = jest.fn().mockReturnValue(false); + + const unregister = h.block(blockHandler); + + gh.push('/app/other'); + + expect(gh.location.pathname).toEqual('/app/wow'); + + unregister(); + + gh.push('/app/other'); + + expect(gh.location.pathname).toEqual('/app/other'); + }); + + it('throws if the history is no longer active', () => { + initHistory(); + + gh.push('/app/other'); + + expect(() => h.block()).toThrowErrorMatchingInlineSnapshot( + `"ScopedHistory instance has fell out of navigation scope for basePath: /app/wow"` + ); + }); + + it('unregisters the block handler when the history is no longer active', () => { + initHistory(); + + const blockSpy = jest.spyOn(gh, 'block'); + const unregister = jest.fn(); + blockSpy.mockReturnValue(unregister); + + h.block('confirm'); + + expect(unregister).not.toHaveBeenCalled(); + + gh.push('/app/other'); + + expect(unregister).toHaveBeenCalledTimes(1); + }); + + it('calls the defined global history confirm handler', () => { + const confirmHandler: jest.MockedFunction = jest + .fn() + .mockImplementation((message, callback) => { + callback(true); + }); + + initHistory({ + confirmHandler, + }); + + h.block('are you sure'); + + gh.push('/app/other'); + + expect(confirmHandler).toHaveBeenCalledTimes(1); + expect(gh.location.pathname).toEqual('/app/other'); + }); + }); }); diff --git a/src/core/public/application/scoped_history.ts b/src/core/public/application/scoped_history.ts index daf0aee7921814..b932465f800cd2 100644 --- a/src/core/public/application/scoped_history.ts +++ b/src/core/public/application/scoped_history.ts @@ -51,6 +51,10 @@ export class ScopedHistory * The key of the current position of the window in the history stack. */ private currentLocationKeyIndex: number = 0; + /** + * Array of the current {@link block} unregister callbacks + */ + private blockUnregisterCallbacks: Set = new Set(); constructor(private readonly parentHistory: History, private readonly basePath: string) { const parentPath = this.parentHistory.location.pathname; @@ -176,18 +180,20 @@ export class ScopedHistory }; /** - * Not supported. Use {@link AppMountParameters.onAppLeave}. - * - * @remarks - * We prefer that applications use the `onAppLeave` API because it supports a more graceful experience that prefers - * a modal when possible, falling back to a confirm dialog box in the beforeunload case. + * Add a block prompt requesting user confirmation when navigating away from the current page. */ public block = ( prompt?: boolean | string | TransitionPromptHook ): UnregisterCallback => { - throw new Error( - `history.block is not supported. Please use the AppMountParameters.onAppLeave API.` - ); + this.verifyActive(); + + const unregisterCallback = this.parentHistory.block(prompt); + this.blockUnregisterCallbacks.add(unregisterCallback); + + return () => { + this.blockUnregisterCallbacks.delete(unregisterCallback); + unregisterCallback(); + }; }; /** @@ -290,6 +296,12 @@ export class ScopedHistory if (!location.pathname.startsWith(this.basePath)) { unlisten(); this.isActive = false; + + for (const unregisterBlock of this.blockUnregisterCallbacks) { + unregisterBlock(); + } + this.blockUnregisterCallbacks.clear(); + return; } diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index a94f96e48ba6ca..0643b9070d9c69 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -478,6 +478,8 @@ export interface AppMountParameters { * return renderApp({ element, history }); * } * ``` + * + * @deprecated {@link ScopedHistory.block} should be used instead. */ onAppLeave: (handler: AppLeaveHandler) => void; @@ -523,6 +525,7 @@ export interface AppMountParameters { * See {@link AppMountParameters} for detailed usage examples. * * @public + * @deprecated {@link AppMountParameters.onAppLeave} has been deprecated in favor of {@link ScopedHistory.block} */ export type AppLeaveHandler = ( factory: AppLeaveActionFactory, @@ -590,6 +593,7 @@ export interface AppLeaveActionFactory { * so we can show to the user the right UX for him to saved his/her/their changes */ confirm(text: string, title?: string, callback?: () => void): AppLeaveConfirmAction; + /** * Returns a default action, resulting on executing the default behavior when * the user tries to leave an application diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index b068606b880472..d79cba5346a73f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -116,7 +116,7 @@ export interface AppLeaveDefaultAction { // Warning: (ae-forgotten-export) The symbol "AppLeaveActionFactory" needs to be exported by the entry point index.d.ts // -// @public +// @public @deprecated export type AppLeaveHandler = (factory: AppLeaveActionFactory, nextAppId?: string) => AppLeaveAction; // @public (undocumented) @@ -153,6 +153,7 @@ export interface AppMountParameters { appBasePath: string; element: HTMLElement; history: ScopedHistory; + // @deprecated onAppLeave: (handler: AppLeaveHandler) => void; setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; } diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/kibana_yml.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/kibana_yml.template.ts index 86443061fca64c..c8eb16530507f9 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/kibana_yml.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/kibana_yml.template.ts @@ -17,7 +17,6 @@ function generator({ imageFlavor }: TemplateContext) { # # Default Kibana configuration for docker target - server.name: kibana server.host: "0.0.0.0" elasticsearch.hosts: [ "http://elasticsearch:9200" ] ${!imageFlavor ? 'monitoring.ui.container.elasticsearch.enabled: true' : ''} diff --git a/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh b/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh index 243dbaa6197e6d..ad123eeb050958 100644 --- a/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh +++ b/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh @@ -41,9 +41,10 @@ for x in functional jest; do # Need to override COVERAGE_INGESTION_KIBANA_ROOT since json file has original intake worker path export COVERAGE_INGESTION_KIBANA_ROOT=/dev/shm/workspace/kibana fi - - node scripts/ingest_coverage.js --verbose --path ${COVERAGE_SUMMARY_FILE} --vcsInfoPath ./VCS_INFO.txt --teamAssignmentsPath $TEAM_ASSIGN_PATH + # running in background to speed up ingestion + node scripts/ingest_coverage.js --verbose --path ${COVERAGE_SUMMARY_FILE} --vcsInfoPath ./VCS_INFO.txt --teamAssignmentsPath $TEAM_ASSIGN_PATH & done +wait echo "### Ingesting Code Coverage - Complete" echo "" diff --git a/src/plugins/data/public/query/query_service.ts b/src/plugins/data/public/query/query_service.ts index 9aaedf12ce55ee..3e86c6aa01fd9f 100644 --- a/src/plugins/data/public/query/query_service.ts +++ b/src/plugins/data/public/query/query_service.ts @@ -15,7 +15,7 @@ import { TimefilterService, TimefilterSetup } from './timefilter'; import { createSavedQueryService } from './saved_query/saved_query_service'; import { createQueryStateObservable } from './state_sync/create_global_query_observable'; import { QueryStringManager, QueryStringContract } from './query_string'; -import { buildEsQuery, getEsQueryConfig } from '../../common'; +import { buildEsQuery, getEsQueryConfig, TimeRange } from '../../common'; import { getUiSettings } from '../services'; import { NowProviderInternalContract } from '../now_provider'; import { IndexPattern } from '..'; @@ -80,8 +80,8 @@ export class QueryService { savedQueries: createSavedQueryService(savedObjectsClient), state$: this.state$, timefilter: this.timefilter, - getEsQuery: (indexPattern: IndexPattern) => { - const timeFilter = this.timefilter.timefilter.createFilter(indexPattern); + getEsQuery: (indexPattern: IndexPattern, timeRange?: TimeRange) => { + const timeFilter = this.timefilter.timefilter.createFilter(indexPattern, timeRange); return buildEsQuery( indexPattern, diff --git a/src/plugins/discover/public/application/angular/helpers/state_helpers.ts b/src/plugins/discover/public/application/angular/helpers/state_helpers.ts index ec5009dcf48392..bcd3aedd3f034e 100644 --- a/src/plugins/discover/public/application/angular/helpers/state_helpers.ts +++ b/src/plugins/discover/public/application/angular/helpers/state_helpers.ts @@ -7,6 +7,7 @@ */ import { IUiSettingsClient } from 'src/core/public'; +import { isEqual } from 'lodash'; import { SEARCH_FIELDS_FROM_SOURCE, DEFAULT_COLUMNS_SETTING } from '../../../../common'; /** @@ -25,9 +26,15 @@ export function handleSourceColumnState( const defaultColumns = uiSettings.get(DEFAULT_COLUMNS_SETTING); if (useNewFieldsApi) { // if fields API is used, filter out the source column + let cleanedColumns = state.columns.filter((column) => column !== '_source'); + if (cleanedColumns.length === 0 && !isEqual(defaultColumns, ['_source'])) { + cleanedColumns = defaultColumns; + // defaultColumns could still contain _source + cleanedColumns = cleanedColumns.filter((column) => column !== '_source'); + } return { ...state, - columns: state.columns.filter((column) => column !== '_source'), + columns: cleanedColumns, }; } else if (state.columns.length === 0) { // if _source fetching is used and there are no column, switch back to default columns diff --git a/src/plugins/discover/public/application/components/discover.tsx b/src/plugins/discover/public/application/components/discover.tsx index 1d183aa75cf3a5..e62dccbadcbd08 100644 --- a/src/plugins/discover/public/application/components/discover.tsx +++ b/src/plugins/discover/public/application/components/discover.tsx @@ -34,7 +34,6 @@ import { SkipBottomButton } from './skip_bottom_button'; import { esFilters, IndexPatternField, search } from '../../../../data/public'; import { DiscoverSidebarResponsive } from './sidebar'; import { DiscoverProps } from './types'; -import { getDisplayedColumns } from '../helpers/columns'; import { SortPairArr } from '../angular/doc_table/lib/get_sort'; import { SEARCH_FIELDS_FROM_SOURCE } from '../../../common'; import { popularizeField } from '../helpers/popularize_field'; @@ -390,7 +389,7 @@ export function Discover({
{ - const defaultColumns = columns.includes('_source'); + const displayedColumns = getDisplayedColumns(columns, indexPattern); + const defaultColumns = displayedColumns.includes('_source'); /** * Pagination @@ -207,19 +209,19 @@ export const DiscoverGrid = ({ const randomId = useMemo(() => htmlIdGenerator()(), []); const euiGridColumns = useMemo( - () => getEuiGridColumns(columns, settings, indexPattern, showTimeCol, defaultColumns), - [columns, indexPattern, showTimeCol, settings, defaultColumns] + () => getEuiGridColumns(displayedColumns, settings, indexPattern, showTimeCol, defaultColumns), + [displayedColumns, indexPattern, showTimeCol, settings, defaultColumns] ); const schemaDetectors = useMemo(() => getSchemaDetectors(), []); const popoverContents = useMemo(() => getPopoverContents(), []); const columnsVisibility = useMemo( () => ({ - visibleColumns: getVisibleColumns(columns, indexPattern, showTimeCol) as string[], + visibleColumns: getVisibleColumns(displayedColumns, indexPattern, showTimeCol) as string[], setVisibleColumns: (newColumns: string[]) => { onSetColumns(newColumns); }, }), - [columns, indexPattern, showTimeCol, onSetColumns] + [displayedColumns, indexPattern, showTimeCol, onSetColumns] ); const sorting = useMemo(() => ({ columns: sortingColumns, onSort: onTableSort }), [ sortingColumns, @@ -316,7 +318,7 @@ export const DiscoverGrid = ({ indexPattern={indexPattern} hit={expandedDoc} // if default columns are used, dont make them part of the URL - the context state handling will take care to restore them - columns={defaultColumns ? [] : columns} + columns={defaultColumns ? [] : displayedColumns} onFilter={onFilter} onRemoveColumn={onRemoveColumn} onAddColumn={onAddColumn} diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts index 2bafa239075027..4ae0fb68056e58 100644 --- a/src/plugins/discover/public/application/embeddable/search_embeddable.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts @@ -47,6 +47,7 @@ import { DiscoverGridSettings } from '../components/discover_grid/types'; import { DiscoverServices } from '../../build_services'; import { ElasticSearchHit } from '../doc_views/doc_views_types'; import { getDefaultSort } from '../angular/doc_table/lib/get_default_sort'; +import { handleSourceColumnState } from '../angular/helpers'; interface SearchScope extends ng.IScope { columns?: string[]; @@ -371,7 +372,10 @@ export class SearchEmbeddable // If there is column or sort data on the panel, that means the original columns or sort settings have // been overridden in a dashboard. - searchScope.columns = this.input.columns || this.savedSearch.columns; + searchScope.columns = handleSourceColumnState( + { columns: this.input.columns || this.savedSearch.columns }, + this.services.core.uiSettings + ).columns; const savedSearchSort = this.savedSearch.sort && this.savedSearch.sort.length ? this.savedSearch.sort diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts index 92f2f053336eb7..32704af6213b84 100644 --- a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts @@ -53,6 +53,22 @@ describe('kbn_url_storage', () => { expect(retrievedState2).toEqual(state2); }); + it('should set expanded state to url before hash', () => { + let newUrl = setStateToKbnUrl('_s', state1, { useHash: false, storeInHashQuery: false }, url); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana?_s=(testArray:!(1,2,()),testNull:!n,testNumber:0,testObj:(test:'123'),testStr:'123')#/yourApp"` + ); + const retrievedState1 = getStateFromKbnUrl('_s', newUrl, { getFromHashQuery: false }); + expect(retrievedState1).toEqual(state1); + + newUrl = setStateToKbnUrl('_s', state2, { useHash: false, storeInHashQuery: false }, newUrl); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana?_s=(test:'123')#/yourApp"` + ); + const retrievedState2 = getStateFromKbnUrl('_s', newUrl, { getFromHashQuery: false }); + expect(retrievedState2).toEqual(state2); + }); + it('should set hashed state to url', () => { let newUrl = setStateToKbnUrl('_s', state1, { useHash: true }, url); expect(newUrl).toMatchInlineSnapshot( diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts index cdbea44985d97b..99e3023cae0339 100644 --- a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts @@ -15,7 +15,7 @@ import { replaceUrlHashQuery, replaceUrlQuery } from './format'; import { url as urlUtils } from '../../../common'; /** - * Parses a kibana url and retrieves all the states encoded into url, + * Parses a kibana url and retrieves all the states encoded into the URL, * Handles both expanded rison state and hashed state (where the actual state stored in sessionStorage) * e.g.: * @@ -23,22 +23,31 @@ import { url as urlUtils } from '../../../common'; * http://localhost:5601/oxf/app/kibana#/yourApp?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'') * will return object: * {_a: {tab: 'indexedFields'}, _b: {f: 'test', i: '', l: ''}}; + * + * + * By default due to Kibana legacy reasons assumed that state is stored in a query inside a hash part of the URL: + * http://localhost:5601/oxf/app/kibana#/yourApp?_a={STATE} + * + * { getFromHashQuery: false } option should be used in case state is stored in a main query (not in a hash): + * http://localhost:5601/oxf/app/kibana?_a={STATE}#/yourApp + * */ -export function getStatesFromKbnUrl( +export function getStatesFromKbnUrl>( url: string = window.location.href, - keys?: string[] -): Record { - const query = parseUrlHash(url)?.query; + keys?: Array, + { getFromHashQuery = true }: { getFromHashQuery: boolean } = { getFromHashQuery: true } +): State { + const query = getFromHashQuery ? parseUrlHash(url)?.query : parseUrl(url).query; - if (!query) return {}; + if (!query) return {} as State; const decoded: Record = {}; Object.entries(query) - .filter(([key]) => (keys ? keys.includes(key) : true)) + .filter(([key]) => (keys ? keys.includes(key as keyof State) : true)) .forEach(([q, value]) => { decoded[q] = decodeState(value as string); }); - return decoded; + return decoded as State; } /** @@ -50,12 +59,20 @@ export function getStatesFromKbnUrl( * and key '_a' * will return object: * {tab: 'indexedFields'} + * + * + * By default due to Kibana legacy reasons assumed that state is stored in a query inside a hash part of the URL: + * http://localhost:5601/oxf/app/kibana#/yourApp?_a={STATE} + * + * { getFromHashQuery: false } option should be used in case state is stored in a main query (not in a hash): + * http://localhost:5601/oxf/app/kibana?_a={STATE}#/yourApp */ export function getStateFromKbnUrl( key: string, - url: string = window.location.href + url: string = window.location.href, + { getFromHashQuery = true }: { getFromHashQuery: boolean } = { getFromHashQuery: true } ): State | null { - return (getStatesFromKbnUrl(url, [key])[key] as State) || null; + return (getStatesFromKbnUrl(url, [key], { getFromHashQuery })[key] as State) || null; } /** @@ -69,6 +86,12 @@ export function getStateFromKbnUrl( * * will return url: * http://localhost:5601/oxf/app/kibana#/yourApp?_a=(tab:other)&_b=(f:test,i:'',l:'') + * + * By default due to Kibana legacy reasons assumed that state is stored in a query inside a hash part of the URL: + * http://localhost:5601/oxf/app/kibana#/yourApp?_a={STATE} + * + * { storeInHashQuery: false } option should be used in you want to store you state in a main query (not in a hash): + * http://localhost:5601/oxf/app/kibana?_a={STATE}#/yourApp */ export function setStateToKbnUrl( key: string, diff --git a/src/plugins/vis_default_editor/public/_default.scss b/src/plugins/vis_default_editor/public/_default.scss index 985f2c73f6efea..c412b9d915e553 100644 --- a/src/plugins/vis_default_editor/public/_default.scss +++ b/src/plugins/vis_default_editor/public/_default.scss @@ -1,10 +1,8 @@ .visEditor--default { + // height: 1px is in place to make editor children take their height in the parent + height: 1px; flex: 1 1 auto; display: flex; - - @include euiBreakpoint('xs', 's', 'm') { - flex-direction: column; - } } /** @@ -12,34 +10,17 @@ */ .visEditor__collapsibleSidebar { - background: $euiColorLightestShade; - min-width: $visEditorSidebarMinWidth; - max-width: 100%; - position: relative; - flex-shrink: 0; - - @include euiBreakpoint('xs', 's', 'm') { - // If we are on a small screen we force the editor to take 100% width. - width: 100% !important; - } - - @include euiBreakpoint('l', 'xl') { - max-width: 75%; - } + flex-grow: 1; } -// !importants on width are required to override resizable panel inline widths +// !importants on width and height are required to override resizable panel inline widths .visEditor__collapsibleSidebar-isClosed { min-width: 0; width: $euiSizeXL !important; // Just enough room for the collapse button .visEditorSidebar { display: none; - } - - @include euiBreakpoint('xs', 's', 'm') { - height: $euiSizeXXL; // Just enough room for the collapse button - width: 100% !important; + padding-left: 0; } } @@ -53,13 +34,6 @@ * Resizer */ -.visEditor__resizer { - @include kbnResizer($euiSizeM); - @include euiBreakpoint('xs', 's', 'm') { - display: none; - } -} - .visEditor__resizer-isHidden { display: none; } @@ -68,15 +42,14 @@ * Canvas area */ +.visEditor__visualization__wrapper-expanded { + width: 100% !important; +} + .visEditor__visualization { display: flex; flex: 1 1 auto; // Fixes IE bug: the editor overflows a visualization on small screens overflow: hidden; - - @include euiBreakpoint('xs', 's', 'm') { - // If we are on a small screen we force the visualization to take 100% width. - width: 100% !important; - } } .visEditor__canvas { @@ -92,13 +65,34 @@ flex-basis: 100%; } - .visualize { - display: flex; - flex-direction: column; - flex: 1 1 100%; - } - .visChart { position: relative; } } + +/** + * If we are on a small screen we change the editor layout + */ +@include euiBreakpoint('xs', 's', 'm') { + .visEditor--default { + flex-direction: column; // change the editor direction to column + } + + .visEditor__collapsibleSidebar { + width: 100% !important; // force the editor to take 100% width + } + + .visEditor__collapsibleSidebar-isClosed { + height: $euiSizeXXL !important; // Just enough room for the collapse button + } + + .visEditor__resizer { + display: none; // hide the resizer button + } + + .visEditor__visualization__wrapper { + // force the visualization to take 100% width and height. + width: 100% !important; + height: 100% !important; + } +} diff --git a/src/plugins/vis_default_editor/public/_sidebar.scss b/src/plugins/vis_default_editor/public/_sidebar.scss index 23241abcff770e..0586b6809bcc00 100644 --- a/src/plugins/vis_default_editor/public/_sidebar.scss +++ b/src/plugins/vis_default_editor/public/_sidebar.scss @@ -3,11 +3,11 @@ // .visEditorSidebar { - min-width: $visEditorSidebarMinWidth; + height: 100%; + padding-left: $euiSizeS; - // a hack for IE, issue: https://github.com/elastic/kibana/issues/66586 - > .visEditorSidebar__formWrapper { - flex-basis: auto; + @include euiBreakpoint('xs', 's', 'm') { + padding-left: 0; } } diff --git a/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx b/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx index 294cdc508c325e..52d8109b352a13 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx +++ b/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx @@ -6,7 +6,14 @@ * Side Public License, v 1. */ -import React, { useMemo, useState, useCallback, KeyboardEventHandler, useEffect } from 'react'; +import React, { + memo, + useMemo, + useState, + useCallback, + KeyboardEventHandler, + useEffect, +} from 'react'; import { isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; import { keys, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; @@ -39,7 +46,7 @@ interface DefaultEditorSideBarProps { timeRange: TimeRange; } -function DefaultEditorSideBar({ +function DefaultEditorSideBarComponent({ embeddableHandler, isCollapsed, onClickCollapse, @@ -173,7 +180,7 @@ function DefaultEditorSideBar({ gutterSize="none" responsive={false} > - +
!value); }, []); + /** + * The empty callback is in place to prevent resetting the dragging state of the resize button. + * The mouseLeave is triggered since a visualization is rendered through another call of "ReactDOM.render()"" in expressions, + * using the "visRef" node reference. + * Here is the existing React issue: https://github.com/facebook/react/issues/17064 + */ + const onEditorMouseLeave = useCallback(() => {}, []); + useEffect(() => { if (!visRef.current) { return; @@ -79,35 +88,52 @@ function DefaultEditor({ ...core, }} > - - -
- - - - - - + + {(EuiResizablePanel, EuiResizableButton) => ( + <> + +
+ + + + + + + + + )} + ); diff --git a/src/plugins/vis_default_editor/public/index.scss b/src/plugins/vis_default_editor/public/index.scss index 8f2675c1ba374c..e97c6224a1dd90 100644 --- a/src/plugins/vis_default_editor/public/index.scss +++ b/src/plugins/vis_default_editor/public/index.scss @@ -1,5 +1,3 @@ -$visEditorSidebarMinWidth: 350px; - // Main layout @import './default'; @import './sidebar'; diff --git a/src/plugins/vis_type_table/public/__snapshots__/table_vis_fn.test.ts.snap b/src/plugins/vis_type_table/public/__snapshots__/table_vis_fn.test.ts.snap index a32609c2e3d342..f3d1b134bf19ab 100644 --- a/src/plugins/vis_type_table/public/__snapshots__/table_vis_fn.test.ts.snap +++ b/src/plugins/vis_type_table/public/__snapshots__/table_vis_fn.test.ts.snap @@ -6,27 +6,30 @@ Object { "type": "render", "value": Object { "visConfig": Object { - "dimensions": Object { - "buckets": Array [], - "metrics": Array [ - Object { - "accessor": 0, - "aggType": "count", - "format": Object { - "id": "number", - }, + "buckets": Array [], + "metrics": Array [ + Object { + "accessor": 0, + "format": Object { + "id": "number", "params": Object {}, }, - ], - }, + "params": Object {}, + }, + ], "perPage": 10, + "percentageCol": "", + "row": false, "showMetricsAtAllLevels": false, "showPartialRows": false, + "showToolbar": false, "showTotal": false, "sort": Object { "columnIndex": null, "direction": null, }, + "splitColumn": undefined, + "splitRow": undefined, "title": "My Chart title", "totalFunc": "sum", }, diff --git a/src/plugins/vis_type_table/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_table/public/__snapshots__/to_ast.test.ts.snap deleted file mode 100644 index abe6f01c17e65b..00000000000000 --- a/src/plugins/vis_type_table/public/__snapshots__/to_ast.test.ts.snap +++ /dev/null @@ -1,90 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`table vis toExpressionAst function should match snapshot based on params & dimensions 1`] = ` -Object { - "chain": Array [ - Object { - "arguments": Object { - "aggs": Array [], - "index": Array [ - Object { - "chain": Array [ - Object { - "arguments": Object { - "id": Array [ - "123", - ], - }, - "function": "indexPatternLoad", - "type": "function", - }, - ], - "type": "expression", - }, - ], - "metricsAtAllLevels": Array [ - false, - ], - "partialRows": Array [ - true, - ], - }, - "function": "esaggs", - "type": "function", - }, - Object { - "arguments": Object { - "visConfig": Array [ - "{\\"perPage\\":20,\\"percentageCol\\":\\"Count\\",\\"showMetricsAtAllLevels\\":true,\\"showPartialRows\\":true,\\"showTotal\\":true,\\"showToolbar\\":false,\\"totalFunc\\":\\"sum\\",\\"dimensions\\":{\\"metrics\\":[{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"number\\"},\\"params\\":{},\\"label\\":\\"Count\\",\\"aggType\\":\\"count\\"}],\\"buckets\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"YYYY-MM-DD HH:mm\\"}},\\"params\\":{},\\"label\\":\\"order_date per 3 hours\\",\\"aggType\\":\\"date_histogram\\"}]}}", - ], - }, - "function": "kibana_table", - "type": "function", - }, - ], - "type": "expression", -} -`; - -exports[`table vis toExpressionAst function should match snapshot without params 1`] = ` -Object { - "chain": Array [ - Object { - "arguments": Object { - "aggs": Array [], - "index": Array [ - Object { - "chain": Array [ - Object { - "arguments": Object { - "id": Array [ - "123", - ], - }, - "function": "indexPatternLoad", - "type": "function", - }, - ], - "type": "expression", - }, - ], - "metricsAtAllLevels": Array [ - false, - ], - }, - "function": "esaggs", - "type": "function", - }, - Object { - "arguments": Object { - "visConfig": Array [ - "{\\"showLabel\\":false,\\"dimensions\\":{\\"metrics\\":[{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"number\\"},\\"params\\":{},\\"label\\":\\"Count\\",\\"aggType\\":\\"count\\"}],\\"buckets\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"YYYY-MM-DD HH:mm\\"}},\\"params\\":{},\\"label\\":\\"order_date per 3 hours\\",\\"aggType\\":\\"date_histogram\\"}]}}", - ], - }, - "function": "kibana_table", - "type": "function", - }, - ], - "type": "expression", -} -`; diff --git a/src/plugins/vis_type_table/public/legacy/table_vis_legacy_response_handler.ts b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_response_handler.ts index de14382fae6f1a..cb40b151eb31a2 100644 --- a/src/plugins/vis_type_table/public/legacy/table_vis_legacy_response_handler.ts +++ b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_response_handler.ts @@ -8,10 +8,17 @@ import { Required } from '@kbn/utility-types'; +import { SchemaConfig } from 'src/plugins/visualizations/public'; import { getFormatService } from '../services'; -import { Dimensions } from '../types'; import { Input } from './table_vis_legacy_fn'; +interface Dimensions { + buckets: SchemaConfig[]; + metrics: SchemaConfig[]; + splitColumn?: SchemaConfig[]; + splitRow?: SchemaConfig[]; +} + export interface TableContext { tables: Array; direction?: 'row' | 'column'; diff --git a/src/plugins/vis_type_table/public/legacy/table_vis_legacy_type.ts b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_type.ts index 9e895a206288ba..020a317e0471b1 100644 --- a/src/plugins/vis_type_table/public/legacy/table_vis_legacy_type.ts +++ b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_type.ts @@ -13,7 +13,7 @@ import { VisTypeDefinition } from '../../../visualizations/public'; import { TableOptions } from '../components/table_vis_options_lazy'; import { VIS_EVENT_TO_TRIGGER } from '../../../visualizations/public'; import { TableVisParams, VIS_TYPE_TABLE } from '../../common'; -import { toExpressionAst } from '../to_ast'; +import { toExpressionAstLegacy } from './to_ast_legacy'; export const tableVisLegacyTypeDefinition: VisTypeDefinition = { name: VIS_TYPE_TABLE, @@ -79,7 +79,7 @@ export const tableVisLegacyTypeDefinition: VisTypeDefinition = { }, ], }, - toExpressionAst, + toExpressionAst: toExpressionAstLegacy, hierarchicalData: (vis) => vis.params.showPartialRows || vis.params.showMetricsAtAllLevels, requiresSearch: true, }; diff --git a/src/plugins/vis_type_table/public/legacy/to_ast_legacy.ts b/src/plugins/vis_type_table/public/legacy/to_ast_legacy.ts new file mode 100644 index 00000000000000..b4c8505bbde764 --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/to_ast_legacy.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { + EsaggsExpressionFunctionDefinition, + IndexPatternLoadExpressionFunctionDefinition, +} from '../../../data/public'; +import { buildExpression, buildExpressionFunction } from '../../../expressions/public'; +import { getVisSchemas, VisToExpressionAst } from '../../../visualizations/public'; +import { TableVisParams } from '../../common'; +import { TableExpressionFunctionDefinition } from './table_vis_legacy_fn'; + +const buildTableVisConfig = ( + schemas: ReturnType, + visParams: TableVisParams +) => { + const metrics = schemas.metric; + const buckets = schemas.bucket || []; + const visConfig = { + dimensions: { + metrics, + buckets, + splitRow: schemas.split_row, + splitColumn: schemas.split_column, + }, + }; + + if (visParams.showPartialRows && !visParams.showMetricsAtAllLevels) { + // Handle case where user wants to see partial rows but not metrics at all levels. + // This requires calculating how many metrics will come back in the tabified response, + // and removing all metrics from the dimensions except the last set. + const metricsPerBucket = metrics.length / buckets.length; + visConfig.dimensions.metrics.splice(0, metricsPerBucket * buckets.length - metricsPerBucket); + } + return visConfig; +}; + +export const toExpressionAstLegacy: VisToExpressionAst = (vis, params) => { + const esaggs = buildExpressionFunction('esaggs', { + index: buildExpression([ + buildExpressionFunction('indexPatternLoad', { + id: vis.data.indexPattern!.id!, + }), + ]), + metricsAtAllLevels: vis.isHierarchical(), + partialRows: vis.params.showPartialRows, + aggs: vis.data.aggs!.aggs.map((agg) => buildExpression(agg.toExpressionAst())), + }); + + const schemas = getVisSchemas(vis, params); + + const visConfig = { + ...vis.params, + ...buildTableVisConfig(schemas, vis.params), + title: vis.title, + }; + + const table = buildExpressionFunction('kibana_table', { + visConfig: JSON.stringify(visConfig), + }); + + const ast = buildExpression([esaggs, table]); + + return ast.toAst(); +}; diff --git a/src/plugins/vis_type_table/public/table_vis_fn.test.ts b/src/plugins/vis_type_table/public/table_vis_fn.test.ts index 9e7b944c237853..5b7be5dea7c851 100644 --- a/src/plugins/vis_type_table/public/table_vis_fn.test.ts +++ b/src/plugins/vis_type_table/public/table_vis_fn.test.ts @@ -27,7 +27,12 @@ describe('interpreter/functions#table', () => { const visConfig = { title: 'My Chart title', perPage: 10, + percentageCol: '', + row: false, + showToolbar: false, showPartialRows: false, + splitColumn: undefined, + splitRow: undefined, showMetricsAtAllLevels: false, sort: { columnIndex: null, @@ -35,19 +40,17 @@ describe('interpreter/functions#table', () => { }, showTotal: false, totalFunc: 'sum', - dimensions: { - metrics: [ - { - accessor: 0, - format: { - id: 'number', - }, + metrics: [ + { + accessor: 0, + format: { + id: 'number', params: {}, - aggType: 'count', }, - ], - buckets: [], - }, + params: {}, + }, + ], + buckets: [], }; beforeEach(() => { @@ -55,12 +58,12 @@ describe('interpreter/functions#table', () => { }); it('returns an object with the correct structure', async () => { - const actual = await fn(context, { visConfig: JSON.stringify(visConfig) }, undefined); + const actual = await fn(context, visConfig, undefined); expect(actual).toMatchSnapshot(); }); it('calls response handler with correct values', async () => { - await fn(context, { visConfig: JSON.stringify(visConfig) }, undefined); + await fn(context, visConfig, undefined); expect(tableVisResponseHandler).toHaveBeenCalledTimes(1); expect(tableVisResponseHandler).toHaveBeenCalledWith(context, visConfig); }); diff --git a/src/plugins/vis_type_table/public/table_vis_fn.ts b/src/plugins/vis_type_table/public/table_vis_fn.ts index 40739e32490537..abc8a0d9cd90c7 100644 --- a/src/plugins/vis_type_table/public/table_vis_fn.ts +++ b/src/plugins/vis_type_table/public/table_vis_fn.ts @@ -12,12 +12,6 @@ import { TableVisData, TableVisConfig } from './types'; import { VIS_TYPE_TABLE } from '../common'; import { tableVisResponseHandler } from './utils'; -export type Input = Datatable; - -interface Arguments { - visConfig: string | null; -} - export interface TableVisRenderValue { visData: TableVisData; visType: typeof VIS_TYPE_TABLE; @@ -26,8 +20,8 @@ export interface TableVisRenderValue { export type TableExpressionFunctionDefinition = ExpressionFunctionDefinition< 'kibana_table', - Input, - Arguments, + Datatable, + TableVisConfig, Render >; @@ -39,15 +33,93 @@ export const createTableVisFn = (): TableExpressionFunctionDefinition => ({ defaultMessage: 'Table visualization', }), args: { - visConfig: { - types: ['string', 'null'], - default: '"{}"', + metrics: { + types: ['vis_dimension'], + help: i18n.translate('visTypeTable.function.args.metricsHelpText', { + defaultMessage: 'Metrics dimensions config', + }), + required: true, + multi: true, + }, + buckets: { + types: ['vis_dimension'], + help: i18n.translate('visTypeTable.function.args.bucketsHelpText', { + defaultMessage: 'Buckets dimensions config', + }), + multi: true, + }, + splitColumn: { + types: ['vis_dimension'], + help: i18n.translate('visTypeTable.function.args.splitColumnHelpText', { + defaultMessage: 'Split by column dimension config', + }), + }, + splitRow: { + types: ['vis_dimension'], + help: i18n.translate('visTypeTable.function.args.splitRowHelpText', { + defaultMessage: 'Split by row dimension config', + }), + }, + percentageCol: { + types: ['string'], + help: i18n.translate('visTypeTable.function.args.percentageColHelpText', { + defaultMessage: 'Name of column to show percentage for', + }), + default: '', + }, + perPage: { + types: ['number'], + default: '', + help: i18n.translate('visTypeTable.function.args.perPageHelpText', { + defaultMessage: 'The number of rows at a table page is used for pagination', + }), + }, + row: { + types: ['boolean'], + help: i18n.translate('visTypeTable.function.args.rowHelpText', { + defaultMessage: 'Row value is used for split table mode. Set to "true" to split by row', + }), + }, + showPartialRows: { + types: ['boolean'], + help: '', + default: false, + }, + showMetricsAtAllLevels: { + types: ['boolean'], help: '', + default: false, + }, + showToolbar: { + types: ['boolean'], + help: i18n.translate('visTypeTable.function.args.showToolbarHelpText', { + defaultMessage: `Set to "true" to show grid's toolbar with "Export" button`, + }), + default: false, + }, + showTotal: { + types: ['boolean'], + help: i18n.translate('visTypeTable.function.args.showTotalHelpText', { + defaultMessage: 'Set to "true" to show the total row', + }), + default: false, + }, + title: { + types: ['string'], + help: i18n.translate('visTypeTable.function.args.titleHelpText', { + defaultMessage: + 'The visualization title. The title is used for CSV export as a default file name', + }), + }, + totalFunc: { + types: ['string'], + help: i18n.translate('visTypeTable.function.args.totalFuncHelpText', { + defaultMessage: 'Specifies calculating function for the total row. Possible options are: ', + }), }, }, fn(input, args, handlers) { - const visConfig = args.visConfig && JSON.parse(args.visConfig); - const convertedData = tableVisResponseHandler(input, visConfig); + const convertedData = tableVisResponseHandler(input, args); if (handlers?.inspectorAdapters?.tables) { handlers.inspectorAdapters.tables.logDatatable('default', input); @@ -58,7 +130,7 @@ export const createTableVisFn = (): TableExpressionFunctionDefinition => ({ value: { visData: convertedData, visType: VIS_TYPE_TABLE, - visConfig, + visConfig: args, }, }; }, diff --git a/src/plugins/vis_type_table/public/to_ast.test.ts b/src/plugins/vis_type_table/public/to_ast.test.ts index 0bd09d8470339c..32b2b5939ed897 100644 --- a/src/plugins/vis_type_table/public/to_ast.test.ts +++ b/src/plugins/vis_type_table/public/to_ast.test.ts @@ -9,6 +9,7 @@ import { Vis } from 'src/plugins/visualizations/public'; import { toExpressionAst } from './to_ast'; import { AggTypes, TableVisParams } from '../common'; +import { buildExpressionFunction } from '../../expressions/public'; const mockSchemas = { metric: [{ accessor: 1, format: { id: 'number' }, params: {}, label: 'Count', aggType: 'count' }], @@ -23,10 +24,23 @@ const mockSchemas = { ], }; +const mockTableExpressionFunction = { + addArgument: jest.fn(), +}; + +const mockTableExpression = { + toAst: jest.fn(), +}; + jest.mock('../../visualizations/public', () => ({ getVisSchemas: () => mockSchemas, })); +jest.mock('../../expressions/public', () => ({ + buildExpression: jest.fn(() => mockTableExpression), + buildExpressionFunction: jest.fn(() => mockTableExpressionFunction), +})); + describe('table vis toExpressionAst function', () => { let vis: Vis; @@ -35,7 +49,14 @@ describe('table vis toExpressionAst function', () => { isHierarchical: () => false, type: {}, params: { + perPage: 20, + percentageCol: 'Count', showLabel: false, + showMetricsAtAllLevels: true, + showPartialRows: true, + showTotal: true, + showToolbar: false, + totalFunc: AggTypes.SUM, }, data: { indexPattern: { id: '123' }, @@ -47,22 +68,60 @@ describe('table vis toExpressionAst function', () => { } as any; }); - it('should match snapshot without params', () => { - const actual = toExpressionAst(vis, {} as any); - expect(actual).toMatchSnapshot(); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create table expression ast', () => { + toExpressionAst(vis, {} as any); + + expect((buildExpressionFunction as jest.Mock).mock.calls.length).toEqual(5); + expect((buildExpressionFunction as jest.Mock).mock.calls[0]).toEqual([ + 'indexPatternLoad', + { id: '123' }, + ]); + expect((buildExpressionFunction as jest.Mock).mock.calls[1]).toEqual([ + 'esaggs', + { + index: expect.any(Object), + metricsAtAllLevels: false, + partialRows: true, + aggs: [], + }, + ]); + // prepare metrics dimensions + expect((buildExpressionFunction as jest.Mock).mock.calls[2]).toEqual([ + 'visdimension', + { accessor: 1 }, + ]); + // prepare buckets dimensions + expect((buildExpressionFunction as jest.Mock).mock.calls[3]).toEqual([ + 'visdimension', + { accessor: 0 }, + ]); + // prepare table expression function + expect((buildExpressionFunction as jest.Mock).mock.calls[4]).toEqual([ + 'kibana_table', + { + buckets: [mockTableExpression], + metrics: [mockTableExpression], + perPage: 20, + percentageCol: 'Count', + row: undefined, + showMetricsAtAllLevels: true, + showPartialRows: true, + showToolbar: false, + showTotal: true, + title: undefined, + totalFunc: 'sum', + }, + ]); }); - it('should match snapshot based on params & dimensions', () => { - vis.params = { - perPage: 20, - percentageCol: 'Count', - showMetricsAtAllLevels: true, - showPartialRows: true, - showTotal: true, - showToolbar: false, - totalFunc: AggTypes.SUM, - }; - const actual = toExpressionAst(vis, {} as any); - expect(actual).toMatchSnapshot(); + it('should filter out invalid vis params', () => { + // @ts-expect-error + vis.params.sort = { columnIndex: null }; + toExpressionAst(vis, {} as any); + expect((buildExpressionFunction as jest.Mock).mock.calls[4][1].sort).toBeUndefined(); }); }); diff --git a/src/plugins/vis_type_table/public/to_ast.ts b/src/plugins/vis_type_table/public/to_ast.ts index dea2799737f096..f7fb620db1ca60 100644 --- a/src/plugins/vis_type_table/public/to_ast.ts +++ b/src/plugins/vis_type_table/public/to_ast.ts @@ -11,34 +11,33 @@ import { IndexPatternLoadExpressionFunctionDefinition, } from '../../data/public'; import { buildExpression, buildExpressionFunction } from '../../expressions/public'; -import { getVisSchemas, VisToExpressionAst } from '../../visualizations/public'; +import { getVisSchemas, SchemaConfig, VisToExpressionAst } from '../../visualizations/public'; import { TableVisParams } from '../common'; import { TableExpressionFunctionDefinition } from './table_vis_fn'; -import { TableVisConfig } from './types'; -const buildTableVisConfig = ( - schemas: ReturnType, - visParams: TableVisParams -) => { - const metrics = schemas.metric; - const buckets = schemas.bucket || []; - const visConfig = { - dimensions: { - metrics, - buckets, - splitRow: schemas.split_row, - splitColumn: schemas.split_column, - }, - }; +const prepareDimension = (params: SchemaConfig) => { + const visdimension = buildExpressionFunction('visdimension', { accessor: params.accessor }); + + if (params.format) { + visdimension.addArgument('format', params.format.id); + visdimension.addArgument('formatParams', JSON.stringify(params.format.params)); + } + + return buildExpression([visdimension]); +}; + +const getMetrics = (schemas: ReturnType, visParams: TableVisParams) => { + const metrics = [...schemas.metric]; - if (visParams.showPartialRows && !visParams.showMetricsAtAllLevels) { + if (schemas.bucket && visParams.showPartialRows && !visParams.showMetricsAtAllLevels) { // Handle case where user wants to see partial rows but not metrics at all levels. // This requires calculating how many metrics will come back in the tabified response, // and removing all metrics from the dimensions except the last set. - const metricsPerBucket = metrics.length / buckets.length; - visConfig.dimensions.metrics.splice(0, metricsPerBucket * buckets.length - metricsPerBucket); + const metricsPerBucket = metrics.length / schemas.bucket.length; + metrics.splice(0, metricsPerBucket * schemas.bucket.length - metricsPerBucket); } - return visConfig; + + return metrics; }; export const toExpressionAst: VisToExpressionAst = (vis, params) => { @@ -54,16 +53,32 @@ export const toExpressionAst: VisToExpressionAst = (vis, params) }); const schemas = getVisSchemas(vis, params); + const metrics = getMetrics(schemas, vis.params); - const visConfig: TableVisConfig = { - ...vis.params, - ...buildTableVisConfig(schemas, vis.params), + const args = { + // explicitly pass each param to prevent extra values trapping + perPage: vis.params.perPage, + percentageCol: vis.params.percentageCol, + row: vis.params.row, + showPartialRows: vis.params.showPartialRows, + showMetricsAtAllLevels: vis.params.showMetricsAtAllLevels, + showToolbar: vis.params.showToolbar, + showTotal: vis.params.showTotal, + totalFunc: vis.params.totalFunc, title: vis.title, + metrics: metrics.map(prepareDimension), + buckets: schemas.bucket?.map(prepareDimension), }; - const table = buildExpressionFunction('kibana_table', { - visConfig: JSON.stringify(visConfig), - }); + const table = buildExpressionFunction('kibana_table', args); + + if (schemas.split_column) { + table.addArgument('splitColumn', prepareDimension(schemas.split_column[0])); + } + + if (schemas.split_row) { + table.addArgument('splitRow', prepareDimension(schemas.split_row[0])); + } const ast = buildExpression([esaggs, table]); diff --git a/src/plugins/vis_type_table/public/types.ts b/src/plugins/vis_type_table/public/types.ts index dafe46597cf47b..8f35909d3bfba9 100644 --- a/src/plugins/vis_type_table/public/types.ts +++ b/src/plugins/vis_type_table/public/types.ts @@ -8,16 +8,9 @@ import { IFieldFormat } from 'src/plugins/data/public'; import { DatatableColumn, DatatableRow } from 'src/plugins/expressions'; -import { SchemaConfig } from 'src/plugins/visualizations/public'; +import { ExpressionValueVisDimension } from 'src/plugins/visualizations/public'; import { TableVisParams } from '../common'; -export interface Dimensions { - buckets: SchemaConfig[]; - metrics: SchemaConfig[]; - splitColumn?: SchemaConfig[]; - splitRow?: SchemaConfig[]; -} - export interface ColumnWidthData { colIndex: number; width: number; @@ -40,7 +33,10 @@ export interface TableVisUseUiStateProps { export interface TableVisConfig extends TableVisParams { title: string; - dimensions: Dimensions; + buckets?: ExpressionValueVisDimension[]; + metrics: ExpressionValueVisDimension[]; + splitColumn?: ExpressionValueVisDimension; + splitRow?: ExpressionValueVisDimension; } export interface FormattedColumn { diff --git a/src/plugins/vis_type_table/public/utils/create_formatted_table.test.ts b/src/plugins/vis_type_table/public/utils/create_formatted_table.test.ts index 0a9c7320d43593..59f6b4fdd808d2 100644 --- a/src/plugins/vis_type_table/public/utils/create_formatted_table.test.ts +++ b/src/plugins/vis_type_table/public/utils/create_formatted_table.test.ts @@ -28,20 +28,14 @@ const visConfig: TableVisConfig = { totalFunc: AggTypes.SUM, percentageCol: '', title: 'My data table', - dimensions: { - buckets: [ - { - accessor: 1, - aggType: 'terms', - format: { id: 'string' }, - label: 'category_keyword: Descending', - params: {}, - }, - ], - metrics: [ - { accessor: 0, aggType: 'count', format: { id: 'number' }, label: 'Count', params: {} }, - ], - }, + buckets: [ + { + accessor: 1, + format: { id: 'string', params: {} }, + type: 'vis_dimension', + }, + ], + metrics: [{ accessor: 0, format: { id: 'number', params: {} }, type: 'vis_dimension' }], }; describe('createFormattedTable', () => { diff --git a/src/plugins/vis_type_table/public/utils/create_formatted_table.ts b/src/plugins/vis_type_table/public/utils/create_formatted_table.ts index 348d12aae24a9c..a623e18e67a33e 100644 --- a/src/plugins/vis_type_table/public/utils/create_formatted_table.ts +++ b/src/plugins/vis_type_table/public/utils/create_formatted_table.ts @@ -16,10 +16,10 @@ export const createFormattedTable = ( table: Datatable | TableContext, visConfig: TableVisConfig ) => { - const { buckets, metrics } = visConfig.dimensions; + const { buckets, metrics } = visConfig; const formattedColumns = table.columns.reduce((acc, col, i) => { - const isBucket = buckets.find(({ accessor }) => accessor === i); + const isBucket = buckets?.find(({ accessor }) => accessor === i); const dimension = isBucket || metrics.find(({ accessor }) => accessor === i); if (!dimension) return acc; diff --git a/src/plugins/vis_type_table/public/utils/table_vis_response_handler.test.ts b/src/plugins/vis_type_table/public/utils/table_vis_response_handler.test.ts index 8adc535e802f0e..0c4c4c08cce0cd 100644 --- a/src/plugins/vis_type_table/public/utils/table_vis_response_handler.test.ts +++ b/src/plugins/vis_type_table/public/utils/table_vis_response_handler.test.ts @@ -31,7 +31,7 @@ jest.mock('./add_percentage_column', () => ({ })); import { Datatable } from 'src/plugins/expressions'; -import { SchemaConfig } from 'src/plugins/visualizations/public'; +import { ExpressionValueVisDimension } from 'src/plugins/visualizations/public'; import { AggTypes } from '../../common'; import { TableGroup, TableVisConfig } from '../types'; import { addPercentageColumn } from './add_percentage_column'; @@ -47,10 +47,8 @@ const visConfig: TableVisConfig = { totalFunc: AggTypes.AVG, percentageCol: '', title: 'My data table', - dimensions: { - buckets: [], - metrics: [], - }, + buckets: [], + metrics: [], }; describe('tableVisResponseHandler', () => { @@ -97,15 +95,11 @@ describe('tableVisResponseHandler', () => { ], type: 'datatable', }; - const split: SchemaConfig[] = [ - { - accessor: 1, - label: 'Split', - format: {}, - params: {}, - aggType: 'terms', - }, - ]; + const split: ExpressionValueVisDimension = { + accessor: 1, + format: { params: {} }, + type: 'vis_dimension', + }; const expectedOutput: TableGroup[] = [ { title: 'By Men: Gender', @@ -128,7 +122,7 @@ describe('tableVisResponseHandler', () => { it('should split data by row', () => { const output = tableVisResponseHandler(input, { ...visConfig, - dimensions: { ...visConfig.dimensions, splitRow: split }, + splitRow: split, }); expect(output.direction).toEqual('row'); @@ -139,7 +133,7 @@ describe('tableVisResponseHandler', () => { it('should split data by column', () => { const output = tableVisResponseHandler(input, { ...visConfig, - dimensions: { ...visConfig.dimensions, splitColumn: split }, + splitColumn: split, }); expect(output.direction).toEqual('column'); @@ -151,7 +145,7 @@ describe('tableVisResponseHandler', () => { const output = tableVisResponseHandler(input, { ...visConfig, percentageCol: 'Count', - dimensions: { ...visConfig.dimensions, splitColumn: split }, + splitColumn: split, }); expect(output.direction).toEqual('column'); diff --git a/src/plugins/vis_type_table/public/utils/table_vis_response_handler.ts b/src/plugins/vis_type_table/public/utils/table_vis_response_handler.ts index 69521c20cddfed..8769e811c6f6c8 100644 --- a/src/plugins/vis_type_table/public/utils/table_vis_response_handler.ts +++ b/src/plugins/vis_type_table/public/utils/table_vis_response_handler.ts @@ -20,12 +20,12 @@ export function tableVisResponseHandler(input: Datatable, visConfig: TableVisCon let table: TableContext | undefined; let direction: TableVisData['direction']; - const split = visConfig.dimensions.splitColumn || visConfig.dimensions.splitRow; + const split = visConfig.splitColumn || visConfig.splitRow; if (split) { - direction = visConfig.dimensions.splitRow ? 'row' : 'column'; - const splitColumnIndex = split[0].accessor; - const splitColumnFormatter = getFormatService().deserialize(split[0].format); + direction = visConfig.splitRow ? 'row' : 'column'; + const splitColumnIndex = split.accessor as number; + const splitColumnFormatter = getFormatService().deserialize(split.format); const splitColumn = input.columns[splitColumnIndex]; const splitMap: { [key: string]: number } = {}; let splitIndex = 0; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts index 6aac6891ae0e82..a760b47bd32efc 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts @@ -42,6 +42,12 @@ jest.mock('mapbox-gl', () => ({ getZoom: () => 3, addControl: jest.fn(), addLayer: jest.fn(), + dragRotate: { + disable: jest.fn(), + }, + touchZoomRotate: { + disableRotation: jest.fn(), + }, })), MapboxOptions: jest.fn(), NavigationControl: jest.fn(), diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts index ca936cb49c7e0b..b1ec79e6b8310e 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts @@ -144,6 +144,12 @@ export class VegaMapView extends VegaBaseView { if (this.shouldShowZoomControl) { mapBoxInstance.addControl(new NavigationControl({ showCompass: false }), 'top-left'); } + + // disable map rotation using right click + drag + mapBoxInstance.dragRotate.disable(); + + // disable map rotation using touch rotation gesture + mapBoxInstance.touchZoomRotate.disableRotation(); } private initLayers(mapBoxInstance: Map, vegaView: View) { diff --git a/src/plugins/visualizations/public/expression_functions/vis_dimension.ts b/src/plugins/visualizations/public/expression_functions/vis_dimension.ts index 3a22d959b22510..cc29ee6ea39871 100644 --- a/src/plugins/visualizations/public/expression_functions/vis_dimension.ts +++ b/src/plugins/visualizations/public/expression_functions/vis_dimension.ts @@ -20,13 +20,13 @@ interface Arguments { formatParams?: string; } -type ExpressionValueVisDimension = ExpressionValueBoxed< +export type ExpressionValueVisDimension = ExpressionValueBoxed< 'vis_dimension', { accessor: number | DatatableColumn; format: { id?: string; - params: unknown; + params: Record; }; } >; diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index 1ec6276135e80d..e5b1ba73d9d1c0 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -43,3 +43,4 @@ export { export { VisualizationListItem, VisualizationStage } from './vis_types/vis_type_alias_registry'; export { VISUALIZE_ENABLE_LABS_SETTING } from '../common/constants'; export { SavedVisState, VisParams } from '../common'; +export { ExpressionValueVisDimension } from './expression_functions/vis_dimension'; diff --git a/test/accessibility/services/a11y/analyze_with_axe.js b/test/accessibility/services/a11y/analyze_with_axe.js index 3d1e257235f559..4bd29dbab7efc3 100644 --- a/test/accessibility/services/a11y/analyze_with_axe.js +++ b/test/accessibility/services/a11y/analyze_with_axe.js @@ -31,8 +31,19 @@ export function analyzeWithAxe(context, options, callback) { selector: '[data-test-subj="comboBoxSearchInput"] *', }, { + // EUI bug: https://github.com/elastic/eui/issues/4474 id: 'aria-required-parent', - selector: '[class=*"euiDataGridRowCell"][role="gridcell"] ', + selector: '[class=*"euiDataGridRowCell"][role="gridcell"]', + }, + { + // 3rd-party library; button has aria-describedby + id: 'button-name', + selector: '[data-rbd-drag-handle-draggable-id]', + }, + { + // EUI bug: https://github.com/elastic/eui/issues/4536 + id: 'duplicate-id', + selector: '.euiSuperDatePicker *', }, ], }); diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index beacfe30351a4b..5f05d825dd0f45 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -296,7 +296,7 @@ export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrP } public async sizeUpEditor() { - const resizerPanel = await testSubjects.find('splitPanelResizer'); + const resizerPanel = await testSubjects.find('euiResizableButton'); // Drag panel 100 px left await browser.dragAndDrop({ location: resizerPanel }, { location: { x: -100, y: 0 } }); } diff --git a/test/functional/services/dashboard/panel_actions.ts b/test/functional/services/dashboard/panel_actions.ts index 041051398262e9..0101d2b2a19165 100644 --- a/test/functional/services/dashboard/panel_actions.ts +++ b/test/functional/services/dashboard/panel_actions.ts @@ -96,7 +96,7 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft async clickExpandPanelToggle() { log.debug(`clickExpandPanelToggle`); - this.openContextMenu(); + await this.openContextMenu(); const isActionVisible = await testSubjects.exists(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ); if (!isActionVisible) await this.clickContextMenuMoreItem(); await testSubjects.click(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ); diff --git a/test/functional/services/toasts.ts b/test/functional/services/toasts.ts index b9db0a1ee9b7b8..aeaf79e75574a1 100644 --- a/test/functional/services/toasts.ts +++ b/test/functional/services/toasts.ts @@ -45,10 +45,21 @@ export function ToastsProvider({ getService }: FtrProviderContext) { public async dismissAllToasts() { const list = await this.getGlobalToastList(); const toasts = await list.findAllByCssSelector(`.euiToast`); + + if (toasts.length === 0) return; + for (const toast of toasts) { await toast.moveMouseTo(); - const dismissButton = await testSubjects.findDescendant('toastCloseButton', toast); - await dismissButton.click(); + + if (await testSubjects.descendantExists('toastCloseButton', toast)) { + try { + const dismissButton = await testSubjects.findDescendant('toastCloseButton', toast); + await dismissButton.click(); + } catch (err) { + // ignore errors + // toasts are finnicky because they can dismiss themselves right before you close them + } + } } } diff --git a/test/plugin_functional/plugins/core_history_block/kibana.json b/test/plugin_functional/plugins/core_history_block/kibana.json new file mode 100644 index 00000000000000..6d2dda2b13225c --- /dev/null +++ b/test/plugin_functional/plugins/core_history_block/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "coreHistoryBlock", + "version": "0.0.1", + "kibanaVersion": "kibana", + "server": false, + "ui": true, + "requiredBundles": ["kibanaReact"] +} diff --git a/test/plugin_functional/plugins/core_history_block/package.json b/test/plugin_functional/plugins/core_history_block/package.json new file mode 100644 index 00000000000000..f5590e33e6ac01 --- /dev/null +++ b/test/plugin_functional/plugins/core_history_block/package.json @@ -0,0 +1,14 @@ +{ + "name": "core_history_block", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/core_history_block", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "SSPL-1.0 OR Elastic License 2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../node_modules/.bin/tsc" + } +} \ No newline at end of file diff --git a/test/plugin_functional/plugins/core_history_block/public/app.tsx b/test/plugin_functional/plugins/core_history_block/public/app.tsx new file mode 100644 index 00000000000000..28866426f281b1 --- /dev/null +++ b/test/plugin_functional/plugins/core_history_block/public/app.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Router, Switch, Route, Prompt } from 'react-router-dom'; +import type { AppMountParameters, IBasePath, ApplicationStart } from 'kibana/public'; +import { RedirectAppLinks } from '../../../../../src/plugins/kibana_react/public'; + +const HomePage = ({ + basePath, + application, +}: { + basePath: IBasePath; + application: ApplicationStart; +}) => ( + +); + +const FooPage = ({ + basePath, + application, +}: { + basePath: IBasePath; + application: ApplicationStart; +}) => ( + +); + +interface AppOptions { + basePath: IBasePath; + application: ApplicationStart; +} + +export const renderApp = ( + { basePath, application }: AppOptions, + { element, history }: AppMountParameters +) => { + ReactDOM.render( + + + + + + + + + + , + element + ); + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/test/plugin_functional/plugins/core_history_block/public/index.ts b/test/plugin_functional/plugins/core_history_block/public/index.ts new file mode 100644 index 00000000000000..deec3d61a0d647 --- /dev/null +++ b/test/plugin_functional/plugins/core_history_block/public/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. + */ + +import { PluginInitializer } from 'kibana/public'; +import { CoreAppLinkPlugin, CoreAppLinkPluginSetup, CoreAppLinkPluginStart } from './plugin'; + +export const plugin: PluginInitializer = () => + new CoreAppLinkPlugin(); diff --git a/test/plugin_functional/plugins/core_history_block/public/plugin.ts b/test/plugin_functional/plugins/core_history_block/public/plugin.ts new file mode 100644 index 00000000000000..3483d8dfee513d --- /dev/null +++ b/test/plugin_functional/plugins/core_history_block/public/plugin.ts @@ -0,0 +1,40 @@ +/* + * 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 { Plugin, CoreSetup, AppMountParameters } from 'kibana/public'; +import { renderApp } from './app'; + +export class CoreAppLinkPlugin implements Plugin { + public setup(core: CoreSetup, deps: {}) { + core.application.register({ + id: 'core_history_block', + title: 'History block test', + mount: async (params: AppMountParameters) => { + const [{ application }] = await core.getStartServices(); + return renderApp( + { + basePath: core.http.basePath, + application, + }, + params + ); + }, + }); + + return {}; + } + + public start() { + return {}; + } + + public stop() {} +} + +export type CoreAppLinkPluginSetup = ReturnType; +export type CoreAppLinkPluginStart = ReturnType; diff --git a/test/plugin_functional/plugins/core_history_block/tsconfig.json b/test/plugin_functional/plugins/core_history_block/tsconfig.json new file mode 100644 index 00000000000000..ffd2cd261ab23b --- /dev/null +++ b/test/plugin_functional/plugins/core_history_block/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": ["index.ts", "public/**/*.ts", "public/**/*.tsx", "../../../../typings/**/*"], + "exclude": [], + "references": [ + { "path": "../../../../src/core/tsconfig.json" }, + { "path": "../../../../src/plugins/kibana_react/tsconfig.json" } + ] +} diff --git a/test/plugin_functional/test_suites/core_plugins/history_block.ts b/test/plugin_functional/test_suites/core_plugins/history_block.ts new file mode 100644 index 00000000000000..61eea8be2d204e --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/history_block.ts @@ -0,0 +1,66 @@ +/* + * 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 expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common']); + const browser = getService('browser'); + const testSubjects = getService('testSubjects'); + + describe('application using `ScopedHistory.block`', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('core_history_block'); + }); + + describe('when navigating to another app', () => { + it('prevents navigation if user click cancel on the confirmation dialog', async () => { + await testSubjects.click('applink-external-test'); + + await testSubjects.existOrFail('navigationBlockConfirmModal'); + await PageObjects.common.clickCancelOnModal(false); + expect(await browser.getCurrentUrl()).to.contain('/app/core_history_block'); + }); + it('allows navigation if user click confirm on the confirmation dialog', async () => { + await testSubjects.click('applink-external-test'); + + await testSubjects.existOrFail('navigationBlockConfirmModal'); + await PageObjects.common.clickConfirmOnModal(); + expect(await browser.getCurrentUrl()).to.contain('/app/home'); + }); + }); + + describe('when navigating to the same app', () => { + it('prevents navigation if user click cancel on the confirmation dialog', async () => { + await testSubjects.click('applink-intra-test'); + + await testSubjects.existOrFail('navigationBlockConfirmModal'); + await PageObjects.common.clickCancelOnModal(false); + expect(await browser.getCurrentUrl()).to.contain('/app/core_history_block'); + expect(await browser.getCurrentUrl()).not.to.contain('/foo'); + }); + it('allows navigation if user click confirm on the confirmation dialog', async () => { + await testSubjects.click('applink-intra-test'); + + await testSubjects.existOrFail('navigationBlockConfirmModal'); + await PageObjects.common.clickConfirmOnModal(); + expect(await browser.getCurrentUrl()).to.contain('/app/core_history_block/foo'); + }); + it('allows navigating back without prompt once the block handler has been disposed', async () => { + await testSubjects.click('applink-intra-test'); + await PageObjects.common.clickConfirmOnModal(); + expect(await browser.getCurrentUrl()).to.contain('/app/core_history_block/foo'); + + await testSubjects.click('applink-intra-test'); + expect(await browser.getCurrentUrl()).to.contain('/app/core_history_block'); + expect(await browser.getCurrentUrl()).not.to.contain('/foo'); + }); + }); + }); +} diff --git a/test/plugin_functional/test_suites/core_plugins/index.ts b/test/plugin_functional/test_suites/core_plugins/index.ts index 0770bd9774dca8..3f26b317b81edc 100644 --- a/test/plugin_functional/test_suites/core_plugins/index.ts +++ b/test/plugin_functional/test_suites/core_plugins/index.ts @@ -20,5 +20,6 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) { loadTestFile(require.resolve('./application_status')); loadTestFile(require.resolve('./rendering')); loadTestFile(require.resolve('./chrome_help_menu_links')); + loadTestFile(require.resolve('./history_block')); }); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsFetcher.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsFetcher.tsx index 5433ef66d4f993..a71f299ab296c8 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsFetcher.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsFetcher.tsx @@ -32,8 +32,7 @@ export function ServiceStatsFetcher({ serviceAnomalyStats, }: ServiceStatsFetcherProps) { const { - urlParams: { start, end }, - uiFilters, + urlParams: { environment, start, end }, } = useUrlParams(); const { @@ -46,12 +45,12 @@ export function ServiceStatsFetcher({ endpoint: 'GET /api/apm/service-map/service/{serviceName}', params: { path: { serviceName }, - query: { start, end, uiFilters: JSON.stringify(uiFilters) }, + query: { environment, start, end }, }, }); } }, - [serviceName, start, end, uiFilters], + [environment, serviceName, start, end], { preservePreviousData: false, } diff --git a/x-pack/plugins/apm/public/context/annotations/annotations_context.tsx b/x-pack/plugins/apm/public/context/annotations/annotations_context.tsx index 435be2cc7133cf..54a1d3a59eb209 100644 --- a/x-pack/plugins/apm/public/context/annotations/annotations_context.tsx +++ b/x-pack/plugins/apm/public/context/annotations/annotations_context.tsx @@ -23,9 +23,8 @@ export function AnnotationsContextProvider({ children: React.ReactNode; }) { const { serviceName } = useParams<{ serviceName?: string }>(); - const { urlParams, uiFilters } = useUrlParams(); - const { start, end } = urlParams; - const { environment } = uiFilters; + const { urlParams } = useUrlParams(); + const { environment, start, end } = urlParams; const { data = INITIAL_STATE } = useFetcher( (callApmApi) => { diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts index e384b15685dad4..367fbc6810a7f2 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts @@ -56,7 +56,7 @@ export function getServiceMapServiceNodeInfo({ searchAggregatedTransactions, }: Options & { serviceName: string }) { return withApmSpan('get_service_map_node_stats', async () => { - const { start, end, uiFilters } = setup; + const { start, end } = setup; const filter: ESFilter[] = [ { term: { [SERVICE_NAME]: serviceName } }, @@ -66,7 +66,7 @@ export function getServiceMapServiceNodeInfo({ const minutes = Math.abs((end - start) / (1000 * 60)); const taskParams = { - environment: uiFilters.environment, + environment, filter, searchAggregatedTransactions, minutes, diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index 65c7b245958f32..6a05431c5677ac 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -12,7 +12,7 @@ import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceMap } from '../lib/service_map/get_service_map'; import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info'; import { createRoute } from './create_route'; -import { environmentRt, rangeRt, uiFiltersRt } from './default_api_types'; +import { environmentRt, rangeRt } from './default_api_types'; import { notifyFeatureUsage } from '../feature'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { isActivePlatinumLicense } from '../../common/license_check'; @@ -67,7 +67,7 @@ export const serviceMapServiceNodeRoute = createRoute({ path: t.type({ serviceName: t.string, }), - query: t.intersection([rangeRt, uiFiltersRt]), + query: t.intersection([environmentRt, rangeRt]), }), options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { @@ -81,6 +81,7 @@ export const serviceMapServiceNodeRoute = createRoute({ const { path: { serviceName }, + query: { environment }, } = context.params; const searchAggregatedTransactions = await getSearchAggregatedTransactions( @@ -88,6 +89,7 @@ export const serviceMapServiceNodeRoute = createRoute({ ); return getServiceMapServiceNodeInfo({ + environment, setup, serviceName, searchAggregatedTransactions, diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 29f3494433befe..056135b34cf9f3 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -92,6 +92,7 @@ export class DataEnhancedPlugin createConnectedSearchSessionIndicator({ sessionService: plugins.data.search.session, application: core.application, + basePath: core.http.basePath, timeFilter: plugins.data.query.timefilter.timefilter, storage: this.storage, disableSaveAfterSessionCompletesTimeout: moment diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx index 0aef27310e0906..c96d821641dd61 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx @@ -27,6 +27,7 @@ import { createSearchUsageCollectorMock } from '../../../../../../../src/plugins const coreStart = coreMock.createStart(); const application = coreStart.application; +const basePath = coreStart.http.basePath; const dataStart = dataPluginMock.createStartContract(); const sessionService = dataStart.search.session as jest.Mocked; let storage: Storage; @@ -63,6 +64,7 @@ test("shouldn't show indicator in case no active search session", async () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); const { getByTestId, container } = render( @@ -91,6 +93,7 @@ test("shouldn't show indicator in case app hasn't opt-in", async () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); const { getByTestId, container } = render( @@ -121,6 +124,7 @@ test('should show indicator in case there is an active search session', async () storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); const { getByTestId } = render( @@ -146,6 +150,7 @@ test('should be disabled in case uiConfig says so ', async () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); render( @@ -169,6 +174,7 @@ test('should be disabled in case not enough permissions', async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + basePath, }); render( @@ -195,6 +201,7 @@ test('should be disabled during auto-refresh', async () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); render( @@ -233,6 +240,7 @@ describe('Completed inactivity', () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); render( @@ -294,6 +302,7 @@ describe('tour steps', () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); const rendered = render( @@ -335,6 +344,7 @@ describe('tour steps', () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); const rendered = render( @@ -370,6 +380,7 @@ describe('tour steps', () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); const rendered = render( @@ -397,6 +408,7 @@ describe('tour steps', () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); const rendered = render( diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx index 7c70a270bd30a2..7e2c9c063daa4b 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx @@ -18,7 +18,7 @@ import { SearchUsageCollector, } from '../../../../../../../src/plugins/data/public'; import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public'; -import { ApplicationStart } from '../../../../../../../src/core/public'; +import { ApplicationStart, IBasePath } from '../../../../../../../src/core/public'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; import { useSearchSessionTour } from './search_session_tour'; @@ -26,6 +26,7 @@ export interface SearchSessionIndicatorDeps { sessionService: ISessionService; timeFilter: TimefilterContract; application: ApplicationStart; + basePath: IBasePath; storage: IStorageWrapper; /** * Controls for how long we allow to save a session, @@ -42,7 +43,9 @@ export const createConnectedSearchSessionIndicator = ({ storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }: SearchSessionIndicatorDeps): React.FC => { + const searchSessionsManagementUrl = basePath.prepend('/app/management/kibana/search_sessions'); const isAutoRefreshEnabled = () => !timeFilter.getRefreshInterval().pause; const isAutoRefreshEnabled$ = timeFilter .getRefreshIntervalUpdate$() @@ -185,6 +188,7 @@ export const createConnectedSearchSessionIndicator = ({ onCancel={onCancel} onOpened={onOpened} onViewSearchSessions={onViewSearchSessions} + viewSearchSessionsLink={searchSessionsManagementUrl} /> ); 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 f43063d45a47c9..9e3f1e1c3cf264 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss @@ -45,6 +45,9 @@ .lnsDragDrop-isDropTarget { @include lnsDroppable; @include lnsDroppableActive; + > * { + pointer-events: none; + } } .lnsDragDrop-isActiveGroup { @@ -81,6 +84,8 @@ .lnsDragDrop__container { position: relative; overflow: visible !important; // sass-lint:disable-line no-important + width: 100%; + height: 100%; } .lnsDragDrop__reorderableDrop { diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx index f2a2fda730388d..2fc5efaa28b835 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx @@ -14,7 +14,7 @@ import { ReorderProvider, DragDropIdentifier, DraggingIdentifier, - DropTargets, + DropIdentifier, } from './providers'; import { act } from 'react-dom/test-utils'; import { DropType } from '../types'; @@ -32,6 +32,7 @@ describe('DragDrop', () => { setDragging: jest.fn(), setActiveDropTarget: jest.fn(), activeDropTarget: undefined, + dropTargetsByOrder: undefined, keyboardMode: false, setKeyboardMode: () => {}, setA11yMessage: jest.fn(), @@ -255,11 +256,10 @@ describe('DragDrop', () => { dragging = { id: '1', humanData: { label: 'Label1' } }; }} setActiveDropTarget={setActiveDropTarget} - activeDropTarget={ - ({ activeDropTarget: value } as unknown) as DragContextState['activeDropTarget'] - } + activeDropTarget={value as DragContextState['activeDropTarget']} keyboardMode={false} setKeyboardMode={(keyboardMode) => true} + dropTargetsByOrder={undefined} registerDropTarget={jest.fn()} > { dragging: { ...items[0].value, ghost: { children:
, style: {} } }, setActiveDropTarget, setA11yMessage, - activeDropTarget: { - activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, - dropTargetsByOrder: { - '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, - '2,0,2,0': { ...items[2].value, onDrop, dropType: 'replace_compatible' }, - }, + activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, + dropTargetsByOrder: { + '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, + '2,0,2,0': { ...items[2].value, onDrop, dropType: 'replace_compatible' }, }, keyboardMode: true, }} @@ -463,11 +461,9 @@ describe('DragDrop', () => { dragging: { ...items[0].value, ghost: { children:
Hello
, style: {} } }, setActiveDropTarget, setA11yMessage, - activeDropTarget: { - activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, - dropTargetsByOrder: { - '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, - }, + activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, + dropTargetsByOrder: { + '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, }, keyboardMode: true, }} @@ -525,11 +521,12 @@ describe('DragDrop', () => { keyboardMode = mode; }), setActiveDropTarget: (target?: DragDropIdentifier) => { - activeDropTarget = { activeDropTarget: target } as DropTargets; + activeDropTarget = target as DropIdentifier; }, activeDropTarget, setA11yMessage, registerDropTarget, + dropTargetsByOrder: undefined, }; const dragDropSharedProps = { @@ -665,13 +662,11 @@ describe('DragDrop', () => { const component = mountComponent({ dragging: { ...items[0] }, keyboardMode: true, - activeDropTarget: { - activeDropTarget: undefined, - dropTargetsByOrder: { - '2,0,0': undefined, - '2,0,1': { ...items[1], onDrop, dropType: 'reorder' }, - '2,0,2': { ...items[2], onDrop, dropType: 'reorder' }, - }, + activeDropTarget: undefined, + dropTargetsByOrder: { + '2,0,0': undefined, + '2,0,1': { ...items[1], onDrop, dropType: 'reorder' }, + '2,0,2': { ...items[2], onDrop, dropType: 'reorder' }, }, setActiveDropTarget, setA11yMessage, @@ -693,15 +688,12 @@ describe('DragDrop', () => { test(`Keyboard navigation: user can drop element to an activeDropTarget`, () => { const component = mountComponent({ dragging: { ...items[0] }, - activeDropTarget: { - activeDropTarget: { ...items[2], dropType: 'reorder', onDrop }, - dropTargetsByOrder: { - '2,0,0': { ...items[0], onDrop, dropType: 'reorder' }, - '2,0,1': { ...items[1], onDrop, dropType: 'reorder' }, - '2,0,2': { ...items[2], onDrop, dropType: 'reorder' }, - }, + activeDropTarget: { ...items[2], dropType: 'reorder', onDrop }, + dropTargetsByOrder: { + '2,0,0': { ...items[0], onDrop, dropType: 'reorder' }, + '2,0,1': { ...items[1], onDrop, dropType: 'reorder' }, + '2,0,2': { ...items[2], onDrop, dropType: 'reorder' }, }, - keyboardMode: true, }); const keyboardHandler = component @@ -747,13 +739,11 @@ describe('DragDrop', () => { const component = mountComponent({ dragging: { ...items[0] }, keyboardMode: true, - activeDropTarget: { - activeDropTarget: undefined, - dropTargetsByOrder: { - '2,0,0': undefined, - '2,0,1': { ...items[1], onDrop, dropType: 'reorder' }, - '2,0,2': { ...items[2], onDrop, dropType: 'reorder' }, - }, + activeDropTarget: undefined, + dropTargetsByOrder: { + '2,0,0': undefined, + '2,0,1': { ...items[1], onDrop, dropType: 'reorder' }, + '2,0,2': { ...items[2], onDrop, dropType: 'reorder' }, }, setA11yMessage, }); @@ -799,15 +789,13 @@ describe('DragDrop', () => { {...defaultContext} keyboardMode={true} activeDropTarget={{ - activeDropTarget: { - ...items[1], - onDrop, - dropType: 'reorder', - }, - dropTargetsByOrder: { - '2,0,1,0': undefined, - '2,0,1,1': { ...items[1], onDrop, dropType: 'reorder' }, - }, + ...items[1], + onDrop, + dropType: 'reorder', + }} + dropTargetsByOrder={{ + '2,0,1,0': undefined, + '2,0,1,1': { ...items[1], onDrop, dropType: 'reorder' }, }} dragging={{ ...items[0] }} setActiveDropTarget={setActiveDropTarget} 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 6c6a65ab421b33..618a7accb9b2b1 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -19,8 +19,8 @@ import { ReorderContext, ReorderState, DropHandler, + announce, } from './providers'; -import { announce } from './announcements'; import { trackUiEvent } from '../lens_ui_telemetry'; import { DropType } from '../types'; @@ -99,13 +99,15 @@ interface BaseProps { * The props for a draggable instance of that component. */ interface DragInnerProps extends BaseProps { - isDragging: boolean; - keyboardMode: boolean; setKeyboardMode: DragContextState['setKeyboardMode']; setDragging: DragContextState['setDragging']; setActiveDropTarget: DragContextState['setActiveDropTarget']; setA11yMessage: DragContextState['setA11yMessage']; - activeDropTarget: DragContextState['activeDropTarget']; + activeDraggingProps?: { + keyboardMode: DragContextState['keyboardMode']; + activeDropTarget: DragContextState['activeDropTarget']; + dropTargetsByOrder: DragContextState['dropTargetsByOrder']; + }; onDragStart?: ( target?: | DroppableEvent['currentTarget'] @@ -121,6 +123,7 @@ interface DragInnerProps extends BaseProps { */ interface DropInnerProps extends BaseProps { dragging: DragContextState['dragging']; + keyboardMode: DragContextState['keyboardMode']; setKeyboardMode: DragContextState['setKeyboardMode']; setDragging: DragContextState['setDragging']; setActiveDropTarget: DragContextState['setActiveDropTarget']; @@ -136,8 +139,9 @@ export const DragDrop = (props: BaseProps) => { const { dragging, setDragging, - registerDropTarget, keyboardMode, + registerDropTarget, + dropTargetsByOrder, setKeyboardMode, activeDropTarget, setActiveDropTarget, @@ -147,34 +151,31 @@ export const DragDrop = (props: BaseProps) => { const { value, draggable, dropType, reorderableGroup } = props; const isDragging = !!(draggable && value.id === dragging?.id); + const activeDraggingProps = isDragging + ? { + keyboardMode, + activeDropTarget, + dropTargetsByOrder, + } + : undefined; + if (draggable && !dropType) { const dragProps = { ...props, - isDragging, - keyboardMode: isDragging ? keyboardMode : false, // optimization to not rerender all dragging components - activeDropTarget: isDragging ? activeDropTarget : undefined, // optimization to not rerender all dragging components + activeDraggingProps, setKeyboardMode, setDragging, setActiveDropTarget, setA11yMessage, }; if (reorderableGroup && reorderableGroup.length > 1) { - return ( - - ); + return ; } else { - return ; + return ; } } - const isActiveDropTarget = Boolean( - activeDropTarget?.activeDropTarget && activeDropTarget.activeDropTarget.id === value.id - ); + const isActiveDropTarget = Boolean(activeDropTarget?.id === value.id); const dropProps = { ...props, keyboardMode, @@ -210,9 +211,7 @@ const DragInner = memo(function DragInner({ setKeyboardMode, setActiveDropTarget, order, - keyboardMode, - isDragging, - activeDropTarget, + activeDraggingProps, dragType, onDragStart, onDragEnd, @@ -220,6 +219,10 @@ const DragInner = memo(function DragInner({ ariaDescribedBy, setA11yMessage, }: DragInnerProps) { + const keyboardMode = activeDraggingProps?.keyboardMode; + const activeDropTarget = activeDraggingProps?.activeDropTarget; + const dropTargetsByOrder = activeDraggingProps?.dropTargetsByOrder; + const dragStart = ( e: DroppableEvent | React.KeyboardEvent, keyboardModeOn?: boolean @@ -273,9 +276,9 @@ const DragInner = memo(function DragInner({ } }; const dropToActiveDropTarget = () => { - if (isDragging && activeDropTarget?.activeDropTarget) { + if (activeDropTarget) { trackUiEvent('drop_total'); - const { dropType, humanData, onDrop: onTargetDrop } = activeDropTarget.activeDropTarget; + const { dropType, humanData, onDrop: onTargetDrop } = activeDropTarget; setTimeout(() => setA11yMessage(announce.dropped(value.humanData, humanData, dropType))); onTargetDrop(value, dropType); } @@ -287,6 +290,7 @@ const DragInner = memo(function DragInner({ } const nextTarget = nextValidDropTarget( + dropTargetsByOrder, activeDropTarget, [order.join(',')], (el) => el?.dropType !== 'reorder', @@ -301,11 +305,10 @@ const DragInner = memo(function DragInner({ ); }; const shouldShowGhostImageInstead = - isDragging && dragType === 'move' && keyboardMode && - activeDropTarget?.activeDropTarget && - activeDropTarget?.activeDropTarget.dropType !== 'reorder'; + activeDropTarget && + activeDropTarget.dropType !== 'reorder'; return (
{ - if (isDragging) { + if (activeDraggingProps) { dragEnd(); } }} @@ -331,13 +334,13 @@ const DragInner = memo(function DragInner({ dropToActiveDropTarget(); } - if (isDragging) { + if (activeDraggingProps) { dragEnd(); } else { dragStart(e, true); } } else if (key === keys.ESCAPE) { - if (isDragging) { + if (activeDraggingProps) { e.stopPropagation(); e.preventDefault(); dragEnd(); @@ -357,7 +360,8 @@ const DragInner = memo(function DragInner({ 'data-test-subj': dataTestSubj || 'lnsDragDrop', className: classNames(children.props.className, 'lnsDragDrop', 'lnsDragDrop-isDraggable', { 'lnsDragDrop-isHidden': - (isDragging && dragType === 'move' && !keyboardMode) || shouldShowGhostImageInstead, + (activeDraggingProps && dragType === 'move' && !keyboardMode) || + shouldShowGhostImageInstead, }), draggable: true, onDragEnd: dragEnd, @@ -384,19 +388,20 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { isActiveDropTarget, registerDropTarget, setActiveDropTarget, + keyboardMode, setKeyboardMode, setDragging, setA11yMessage, } = props; useShallowCompareEffect(() => { - if (dropType && value && onDrop) { + if (dropType && onDrop && keyboardMode) { registerDropTarget(order, { ...value, onDrop, dropType }); return () => { registerDropTarget(order, undefined); }; } - }, [order, value, registerDropTarget, dropType]); + }, [order, value, registerDropTarget, dropType, keyboardMode]); const classesOnEnter = getAdditionalClassesOnEnter?.(dropType); const classesOnDroppable = getAdditionalClassesOnDroppable?.(dropType); @@ -481,17 +486,19 @@ const ReorderableDrag = memo(function ReorderableDrag( const { value, setActiveDropTarget, - keyboardMode, - isDragging, - activeDropTarget, + activeDraggingProps, reorderableGroup, setA11yMessage, } = props; + const keyboardMode = activeDraggingProps?.keyboardMode; + const activeDropTarget = activeDraggingProps?.activeDropTarget; + const dropTargetsByOrder = activeDraggingProps?.dropTargetsByOrder; + const isDragging = !!activeDraggingProps; + const isFocusInGroup = keyboardMode ? isDragging && - (!activeDropTarget?.activeDropTarget || - reorderableGroup.some((i) => i.id === activeDropTarget?.activeDropTarget?.id)) + (!activeDropTarget || reorderableGroup.some((i) => i.id === activeDropTarget?.id)) : isDragging; useEffect(() => { @@ -530,10 +537,8 @@ const ReorderableDrag = memo(function ReorderableDrag( e.stopPropagation(); e.preventDefault(); let activeDropTargetIndex = reorderableGroup.findIndex((i) => i.id === value.id); - if (activeDropTarget?.activeDropTarget) { - const index = reorderableGroup.findIndex( - (i) => i.id === activeDropTarget.activeDropTarget?.id - ); + if (activeDropTarget) { + const index = reorderableGroup.findIndex((i) => i.id === activeDropTarget?.id); if (index !== -1) activeDropTargetIndex = index; } if (e.key === keys.ARROW_LEFT || e.key === keys.ARROW_RIGHT) { @@ -542,6 +547,7 @@ const ReorderableDrag = memo(function ReorderableDrag( } else if (keys.ARROW_DOWN === e.key) { if (activeDropTargetIndex < reorderableGroup.length - 1) { const nextTarget = nextValidDropTarget( + dropTargetsByOrder, activeDropTarget, [props.order.join(',')], (el) => el?.dropType === 'reorder' @@ -551,6 +557,7 @@ const ReorderableDrag = memo(function ReorderableDrag( } else if (keys.ARROW_UP === e.key) { if (activeDropTargetIndex > 0) { const nextTarget = nextValidDropTarget( + dropTargetsByOrder, activeDropTarget, [props.order.join(',')], (el) => el?.dropType === 'reorder', diff --git a/x-pack/plugins/lens/public/drag_drop/announcements.tsx b/x-pack/plugins/lens/public/drag_drop/providers/announcements.tsx similarity index 98% rename from x-pack/plugins/lens/public/drag_drop/announcements.tsx rename to x-pack/plugins/lens/public/drag_drop/providers/announcements.tsx index 3c65008f8f38b7..3bd1d5693005c4 100644 --- a/x-pack/plugins/lens/public/drag_drop/announcements.tsx +++ b/x-pack/plugins/lens/public/drag_drop/providers/announcements.tsx @@ -6,13 +6,8 @@ */ import { i18n } from '@kbn/i18n'; -import { DropType } from '../types'; -export interface HumanData { - label: string; - groupLabel?: string; - position?: number; - nextLabel?: string; -} +import { DropType } from '../../types'; +import { HumanData } from '.'; type AnnouncementFunction = (draggedElement: HumanData, dropElement: HumanData) => string; diff --git a/x-pack/plugins/lens/public/drag_drop/providers/index.tsx b/x-pack/plugins/lens/public/drag_drop/providers/index.tsx new file mode 100644 index 00000000000000..4262b65c858879 --- /dev/null +++ b/x-pack/plugins/lens/public/drag_drop/providers/index.tsx @@ -0,0 +1,11 @@ +/* + * 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 * from './providers'; +export * from './reorder_provider'; +export * from './types'; +export * from './announcements'; diff --git a/x-pack/plugins/lens/public/drag_drop/providers.test.tsx b/x-pack/plugins/lens/public/drag_drop/providers/providers.test.tsx similarity index 94% rename from x-pack/plugins/lens/public/drag_drop/providers.test.tsx rename to x-pack/plugins/lens/public/drag_drop/providers/providers.test.tsx index a46b7f6f953148..a8312cc927451b 100644 --- a/x-pack/plugins/lens/public/drag_drop/providers.test.tsx +++ b/x-pack/plugins/lens/public/drag_drop/providers/providers.test.tsx @@ -7,7 +7,7 @@ import React, { useContext } from 'react'; import { mount } from 'enzyme'; -import { RootDragDropProvider, DragContext } from './providers'; +import { RootDragDropProvider, DragContext } from '.'; jest.useFakeTimers(); diff --git a/x-pack/plugins/lens/public/drag_drop/providers.tsx b/x-pack/plugins/lens/public/drag_drop/providers/providers.tsx similarity index 53% rename from x-pack/plugins/lens/public/drag_drop/providers.tsx rename to x-pack/plugins/lens/public/drag_drop/providers/providers.tsx index deb9bf6cb17aec..6a78bc1b46ddfd 100644 --- a/x-pack/plugins/lens/public/drag_drop/providers.tsx +++ b/x-pack/plugins/lens/public/drag_drop/providers/providers.tsx @@ -6,70 +6,15 @@ */ import React, { useState, useMemo } from 'react'; -import classNames from 'classnames'; import { EuiScreenReaderOnly, EuiPortal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { HumanData } from './announcements'; -import { DropType } from '../types'; - -/** - * A function that handles a drop event. - */ -export type DropHandler = (dropped: DragDropIdentifier, dropType?: DropType) => void; - -export type DragDropIdentifier = Record & { - id: string; - /** - * The data for accessibility, consists of required label and not required groupLabel and position in group - */ - humanData: HumanData; -}; - -export type DraggingIdentifier = DragDropIdentifier & { - ghost?: { - children: React.ReactElement; - style: React.CSSProperties; - }; -}; - -export type DropIdentifier = DragDropIdentifier & { - dropType: DropType; - onDrop: DropHandler; -}; - -export interface DropTargets { - activeDropTarget?: DropIdentifier; - dropTargetsByOrder: Record; -} -/** - * The shape of the drag / drop context. - */ -export interface DragContextState { - /** - * The item being dragged or undefined. - */ - dragging?: DraggingIdentifier; - - /** - * keyboard mode - */ - keyboardMode: boolean; - /** - * keyboard mode - */ - setKeyboardMode: (mode: boolean) => void; - /** - * Set the item being dragged. - */ - setDragging: (dragging?: DraggingIdentifier) => void; - - activeDropTarget?: DropTargets; - - setActiveDropTarget: (newTarget?: DropIdentifier) => void; - - setA11yMessage: (message: string) => void; - registerDropTarget: (order: number[], dropTarget?: DropIdentifier) => void; -} +import { + DropIdentifier, + DraggingIdentifier, + DragDropIdentifier, + RegisteredDropTargets, + DragContextState, +} from './types'; /** * The drag / drop context singleton, used like so: @@ -84,51 +29,18 @@ export const DragContext = React.createContext({ activeDropTarget: undefined, setActiveDropTarget: () => {}, setA11yMessage: () => {}, + dropTargetsByOrder: undefined, registerDropTarget: () => {}, }); /** * The argument to DragDropProvider. */ -export interface ProviderProps { - /** - * keyboard mode - */ - keyboardMode: boolean; - /** - * keyboard mode - */ - setKeyboardMode: (mode: boolean) => void; - /** - * Set the item being dragged. - */ - /** - * The item being dragged. If unspecified, the provider will - * behave as if it is the root provider. - */ - dragging?: DraggingIdentifier; - - /** - * Sets the item being dragged. If unspecified, the provider - * will behave as if it is the root provider. - */ - setDragging: (dragging?: DraggingIdentifier) => void; - - activeDropTarget?: { - activeDropTarget?: DropIdentifier; - dropTargetsByOrder: Record; - }; - - setActiveDropTarget: (newTarget?: DropIdentifier) => void; - - registerDropTarget: (order: number[], dropTarget?: DropIdentifier) => void; - +export interface ProviderProps extends DragContextState { /** * The React children. */ children: React.ReactNode; - - setA11yMessage: (message: string) => void; } /** @@ -144,13 +56,11 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode } }); const [keyboardModeState, setKeyboardModeState] = useState(false); const [a11yMessageState, setA11yMessageState] = useState(''); - const [activeDropTargetState, setActiveDropTargetState] = useState<{ - activeDropTarget?: DropIdentifier; - dropTargetsByOrder: Record; - }>({ - activeDropTarget: undefined, - dropTargetsByOrder: {}, - }); + const [activeDropTargetState, setActiveDropTargetState] = useState( + undefined + ); + + const [dropTargetsByOrderState, setDropTargetsByOrderState] = useState({}); const setDragging = useMemo( () => (dragging?: DraggingIdentifier) => setDraggingState({ dragging }), @@ -162,24 +72,20 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode } ]); const setActiveDropTarget = useMemo( - () => (activeDropTarget?: DropIdentifier) => - setActiveDropTargetState((s) => ({ ...s, activeDropTarget })), + () => (activeDropTarget?: DropIdentifier) => setActiveDropTargetState(activeDropTarget), [setActiveDropTargetState] ); const registerDropTarget = useMemo( () => (order: number[], dropTarget?: DropIdentifier) => { - return setActiveDropTargetState((s) => { + return setDropTargetsByOrderState((s) => { return { ...s, - dropTargetsByOrder: { - ...s.dropTargetsByOrder, - [order.join(',')]: dropTarget, - }, + [order.join(',')]: dropTarget, }; }); }, - [setActiveDropTargetState] + [setDropTargetsByOrderState] ); return ( @@ -193,6 +99,7 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode } activeDropTarget={activeDropTargetState} setActiveDropTarget={setActiveDropTarget} registerDropTarget={registerDropTarget} + dropTargetsByOrder={dropTargetsByOrderState} > {children} @@ -220,16 +127,17 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode } } export function nextValidDropTarget( - activeDropTarget: DropTargets | undefined, + dropTargetsByOrder: RegisteredDropTargets, + activeDropTarget: DropIdentifier | undefined, draggingOrder: [string], filterElements: (el: DragDropIdentifier) => boolean = () => true, reverse = false ) { - if (!activeDropTarget) { + if (!dropTargetsByOrder) { return; } - const filteredTargets = [...Object.entries(activeDropTarget.dropTargetsByOrder)].filter( + const filteredTargets = Object.entries(dropTargetsByOrder).filter( ([, dropTarget]) => dropTarget && filterElements(dropTarget) ); @@ -242,7 +150,7 @@ export function nextValidDropTarget( }); let currentActiveDropIndex = nextDropTargets.findIndex( - ([_, dropTarget]) => dropTarget?.id === activeDropTarget?.activeDropTarget?.id + ([_, dropTarget]) => dropTarget?.id === activeDropTarget?.id ); if (currentActiveDropIndex === -1) { @@ -274,6 +182,7 @@ export function ChildDragDropProvider({ setActiveDropTarget, setA11yMessage, registerDropTarget, + dropTargetsByOrder, children, }: ProviderProps) { const value = useMemo( @@ -285,6 +194,7 @@ export function ChildDragDropProvider({ activeDropTarget, setActiveDropTarget, setA11yMessage, + dropTargetsByOrder, registerDropTarget, }), [ @@ -295,84 +205,9 @@ export function ChildDragDropProvider({ setKeyboardMode, keyboardMode, setA11yMessage, + dropTargetsByOrder, registerDropTarget, ] ); return {children}; } - -export interface ReorderState { - /** - * Ids of the elements that are translated up or down - */ - reorderedItems: Array<{ id: string; height?: number }>; - - /** - * Direction of the move of dragged element in the reordered list - */ - direction: '-' | '+'; - /** - * height of the dragged element - */ - draggingHeight: number; - /** - * indicates that user is in keyboard mode - */ - isReorderOn: boolean; - /** - * reorder group needed for screen reader aria-described-by attribute - */ - groupId: string; -} - -type SetReorderStateDispatch = (prevState: ReorderState) => ReorderState; - -export interface ReorderContextState { - reorderState: ReorderState; - setReorderState: (dispatch: SetReorderStateDispatch) => void; -} - -export const ReorderContext = React.createContext({ - reorderState: { - reorderedItems: [], - direction: '-', - draggingHeight: 40, - isReorderOn: false, - groupId: '', - }, - setReorderState: () => () => {}, -}); - -export function ReorderProvider({ - id, - children, - className, -}: { - id: string; - children: React.ReactNode; - className?: string; -}) { - const [state, setState] = useState({ - reorderedItems: [], - direction: '-', - draggingHeight: 40, - isReorderOn: false, - groupId: id, - }); - - const setReorderState = useMemo(() => (dispatch: SetReorderStateDispatch) => setState(dispatch), [ - setState, - ]); - return ( -
1, - })} - > - - {children} - -
- ); -} diff --git a/x-pack/plugins/lens/public/drag_drop/providers/reorder_provider.tsx b/x-pack/plugins/lens/public/drag_drop/providers/reorder_provider.tsx new file mode 100644 index 00000000000000..77620ea1315135 --- /dev/null +++ b/x-pack/plugins/lens/public/drag_drop/providers/reorder_provider.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useMemo } from 'react'; +import classNames from 'classnames'; + +export interface ReorderState { + /** + * Ids of the elements that are translated up or down + */ + reorderedItems: Array<{ id: string; height?: number }>; + + /** + * Direction of the move of dragged element in the reordered list + */ + direction: '-' | '+'; + /** + * height of the dragged element + */ + draggingHeight: number; + /** + * indicates that user is in keyboard mode + */ + isReorderOn: boolean; + /** + * reorder group needed for screen reader aria-described-by attribute + */ + groupId: string; +} + +type SetReorderStateDispatch = (prevState: ReorderState) => ReorderState; + +export interface ReorderContextState { + reorderState: ReorderState; + setReorderState: (dispatch: SetReorderStateDispatch) => void; +} + +export const ReorderContext = React.createContext({ + reorderState: { + reorderedItems: [], + direction: '-', + draggingHeight: 40, + isReorderOn: false, + groupId: '', + }, + setReorderState: () => () => {}, +}); + +export function ReorderProvider({ + id, + children, + className, +}: { + id: string; + children: React.ReactNode; + className?: string; +}) { + const [state, setState] = useState({ + reorderedItems: [], + direction: '-', + draggingHeight: 40, + isReorderOn: false, + groupId: id, + }); + + const setReorderState = useMemo(() => (dispatch: SetReorderStateDispatch) => setState(dispatch), [ + setState, + ]); + return ( +
1, + })} + > + + {children} + +
+ ); +} diff --git a/x-pack/plugins/lens/public/drag_drop/providers/types.tsx b/x-pack/plugins/lens/public/drag_drop/providers/types.tsx new file mode 100644 index 00000000000000..11f460a400dcdc --- /dev/null +++ b/x-pack/plugins/lens/public/drag_drop/providers/types.tsx @@ -0,0 +1,75 @@ +/* + * 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 { DropType } from '../../types'; + +export interface HumanData { + label: string; + groupLabel?: string; + position?: number; + nextLabel?: string; +} + +export type DragDropIdentifier = Record & { + id: string; + /** + * The data for accessibility, consists of required label and not required groupLabel and position in group + */ + humanData: HumanData; +}; + +export type DraggingIdentifier = DragDropIdentifier & { + ghost?: { + children: React.ReactElement; + style: React.CSSProperties; + }; +}; + +export type DropIdentifier = DragDropIdentifier & { + dropType: DropType; + onDrop: DropHandler; +}; + +/** + * A function that handles a drop event. + */ +export type DropHandler = (dropped: DragDropIdentifier, dropType?: DropType) => void; + +export type RegisteredDropTargets = Record | undefined; + +/** + * The shape of the drag / drop context. + */ + +export interface DragContextState { + /** + * The item being dragged or undefined. + */ + dragging?: DraggingIdentifier; + + /** + * keyboard mode + */ + keyboardMode: boolean; + /** + * keyboard mode + */ + setKeyboardMode: (mode: boolean) => void; + /** + * Set the item being dragged. + */ + setDragging: (dragging?: DraggingIdentifier) => void; + + activeDropTarget?: DropIdentifier; + + dropTargetsByOrder: RegisteredDropTargets; + + setActiveDropTarget: (newTarget?: DropIdentifier) => void; + + setA11yMessage: (message: string) => void; + registerDropTarget: (order: number[], dropTarget?: DropIdentifier) => void; +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx index 1cbd41fff2a8fb..04ab1318a12e06 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx @@ -5,8 +5,9 @@ * 2.0. */ -import React, { useMemo, useCallback } from 'react'; -import { DragDrop, DragDropIdentifier, DragContextState } from '../../../drag_drop'; +import React, { useMemo, useCallback, useContext } from 'react'; +import { DragDrop, DragDropIdentifier, DragContext } from '../../../drag_drop'; + import { Datasource, VisualizationDimensionGroupConfig, @@ -41,12 +42,10 @@ export function DraggableDimensionButton({ group, onDrop, children, - dragDropContext, layerDatasourceDropProps, layerDatasource, registerNewButtonRef, }: { - dragDropContext: DragContextState; layerId: string; groupIndex: number; layerIndex: number; @@ -64,8 +63,11 @@ export function DraggableDimensionButton({ columnId: string; registerNewButtonRef: (id: string, instance: HTMLDivElement | null) => void; }) { + const { dragging } = useContext(DragContext); + const dropProps = layerDatasource.getDropProps({ ...layerDatasourceDropProps, + dragging, columnId, filterOperations: group.filterOperations, groupId: group.groupId, @@ -105,6 +107,11 @@ export function DraggableDimensionButton({ columnId, ]); + const handleOnDrop = React.useCallback( + (droppedItem, selectedDropType) => onDrop(droppedItem, value, selectedDropType), + [value, onDrop] + ); + return (
1 ? reorderableGroup : undefined} value={value} - onDrop={(drag: DragDropIdentifier, selectedDropType?: DropType) => - onDrop(drag, value, selectedDropType) - } + onDrop={handleOnDrop} > {children} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx index c9d0a7b002870c..664e24b9898363 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useMemo, useState, useEffect, useContext } from 'react'; import { EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { generateId } from '../../../id_generator'; -import { DragDrop, DragDropIdentifier } from '../../../drag_drop'; +import { DragDrop, DragDropIdentifier, DragContext } from '../../../drag_drop'; + import { Datasource, VisualizationDimensionGroupConfig, DropType } from '../../../types'; import { LayerDatasourceDropProps } from './types'; @@ -47,6 +48,8 @@ export function EmptyDimensionButton({ layerDatasource: Datasource; layerDatasourceDropProps: LayerDatasourceDropProps; }) { + const { dragging } = useContext(DragContext); + const itemIndex = group.accessors.length; const [newColumnId, setNewColumnId] = useState(generateId()); @@ -56,6 +59,7 @@ export function EmptyDimensionButton({ const dropProps = layerDatasource.getDropProps({ ...layerDatasourceDropProps, + dragging, columnId: newColumnId, filterOperations: group.filterOperations, groupId: group.groupId, @@ -81,14 +85,18 @@ export function EmptyDimensionButton({ [dropType, newColumnId, group.groupId, layerId, group.groupLabel, itemIndex, nextLabel] ); + const handleOnDrop = React.useCallback( + (droppedItem, selectedDropType) => onDrop(droppedItem, value, selectedDropType), + [value, onDrop] + ); + return (
onDrop(droppedItem, value, selectedDropType)} + onDrop={handleOnDrop} dropType={dropType} >
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index 619147987cdd55..52726afcffe8da 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -28,6 +28,7 @@ const defaultContext = { setDragging: jest.fn(), setActiveDropTarget: () => {}, activeDropTarget: undefined, + dropTargetsByOrder: undefined, keyboardMode: false, setKeyboardMode: () => {}, setA11yMessage: jest.fn(), @@ -464,9 +465,7 @@ describe('LayerPanel', () => { expect(mockDatasource.getDropProps).toHaveBeenCalledWith( expect.objectContaining({ - dragDropContext: expect.objectContaining({ - dragging: draggingField, - }), + dragging: draggingField, }) ); @@ -474,9 +473,7 @@ describe('LayerPanel', () => { expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ - dragDropContext: expect.objectContaining({ - dragging: draggingField, - }), + droppedItem: draggingField, }) ); }); @@ -582,9 +579,7 @@ describe('LayerPanel', () => { expect(mockDatasource.getDropProps).toHaveBeenCalledWith( expect.objectContaining({ - dragDropContext: expect.objectContaining({ - dragging: draggingOperation, - }), + dragging: draggingOperation, }) ); @@ -593,9 +588,7 @@ describe('LayerPanel', () => { expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'b', - dragDropContext: expect.objectContaining({ - dragging: draggingOperation, - }), + droppedItem: draggingOperation, }) ); @@ -604,9 +597,7 @@ describe('LayerPanel', () => { expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'newid', - dragDropContext: expect.objectContaining({ - dragging: draggingOperation, - }), + droppedItem: draggingOperation, }) ); }); 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 5d84f826ab988c..59b64de3697452 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 @@ -7,17 +7,12 @@ import './layer_panel.scss'; -import React, { useContext, useState, useEffect, useMemo, useCallback } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { NativeRenderer } from '../../../native_renderer'; import { StateSetter, Visualization, DraggedOperation, DropType } from '../../../types'; -import { - DragContext, - DragDropIdentifier, - ChildDragDropProvider, - ReorderProvider, -} from '../../../drag_drop'; +import { DragDropIdentifier, ReorderProvider } from '../../../drag_drop'; import { LayerSettings } from './layer_settings'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { LayerPanelProps, ActiveDimensionState } from './types'; @@ -49,7 +44,6 @@ export function LayerPanel( registerNewLayerRef: (layerId: string, instance: HTMLDivElement | null) => void; } ) { - const dragDropContext = useContext(DragContext); const [activeDimension, setActiveDimension] = useState( initialActiveDimensionState ); @@ -78,7 +72,6 @@ export function LayerPanel( const layerVisualizationConfigProps = { layerId, - dragDropContext, state: props.visualizationState, frame: props.framePublicAPI, dateRange: props.framePublicAPI.dateRange, @@ -91,13 +84,12 @@ export function LayerPanel( const layerDatasourceDropProps = useMemo( () => ({ layerId, - dragDropContext, state: layerDatasourceState, setState: (newState: unknown) => { updateDatasource(datasourceId, newState); }, }), - [layerId, dragDropContext, layerDatasourceState, datasourceId, updateDatasource] + [layerId, layerDatasourceState, datasourceId, updateDatasource] ); const layerDatasource = props.datasourceMap[datasourceId]; @@ -116,7 +108,6 @@ export function LayerPanel( const columnLabelMap = layerDatasource.uniqueLabels(layerDatasourceConfigProps.state); const { setDimension, removeDimension } = activeVisualization; - const layerDatasourceOnDrop = layerDatasource.onDrop; const allAccessors = groups.flatMap((group) => group.accessors.map((accessor) => accessor.columnId) @@ -128,6 +119,8 @@ export function LayerPanel( registerNewRef: registerNewButtonRef, } = useFocusUpdate(allAccessors); + const layerDatasourceOnDrop = layerDatasource.onDrop; + const onDrop = useMemo(() => { return ( droppedItem: DragDropIdentifier, @@ -194,275 +187,272 @@ export function LayerPanel( ]); return ( - -
- - - - - +
+ + + + + - {layerDatasource && ( - - { - const newState = - typeof updater === 'function' ? updater(layerDatasourceState) : updater; - // Look for removed columns - const nextPublicAPI = layerDatasource.getPublicAPI({ - state: newState, + {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, }); - 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); - }, - }} - /> - - )} - - - + 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); - }} - > - - -
-
- ); + {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.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({ + + {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: activeId, - prevState: props.visualizationState, - }) - : activeVisualization.setDimension({ + columnId: id, + prevState: layerDatasourceState, + }), + activeVisualization.removeDimension({ layerId, - groupId: activeGroup.groupId, - columnId: activeId, + columnId: id, prevState: props.visualizationState, }) - ); - } else { - props.updateDatasource(datasourceId, newState); - } - setActiveDimension({ - ...activeDimension, - isNew: false, - }); - }, + ); + removeButtonRef(id); + }} + > + + +
+
+ ); + })} +
+ {group.supportsMoreColumns ? ( + { + setActiveDimension({ + activeGroup: group, + activeId: id, + isNew: true, + }); }} + onDrop={onDrop} /> - )} - {activeGroup && - activeId && - !activeDimension.isNew && - activeVisualization.renderDimensionEditor && - activeGroup?.enableDimensionEditor && ( -
- -
- )} + ) : 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/config_panel/types.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts index 22e28292b8da7b..37b2198cfd51f9 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts @@ -13,7 +13,6 @@ import { DatasourceDimensionEditorProps, VisualizationDimensionGroupConfig, } from '../../../types'; -import { DragContextState } from '../../../drag_drop'; export interface ConfigPanelWrapperProps { activeDatasourceId: string; visualizationState: unknown; @@ -51,7 +50,6 @@ export interface LayerPanelProps { export interface LayerDatasourceDropProps { layerId: string; - dragDropContext: DragContextState; state: unknown; setState: (newState: unknown) => void; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index 92a2f0c5d03fcc..218ceb82060807 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -6,7 +6,7 @@ */ import './chart_switch.scss'; -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, memo } from 'react'; import { EuiIcon, EuiPopover, @@ -79,7 +79,7 @@ function VisualizationSummary(props: Props) { ); } -export function ChartSwitch(props: Props) { +export const ChartSwitch = memo(function ChartSwitch(props: Props) { const [flyoutOpen, setFlyoutOpen] = useState(false); const commitSelection = (selection: VisualizationSelection) => { @@ -305,7 +305,7 @@ export function ChartSwitch(props: Props) { ); return
{popover}
; -} +}); function getTopSuggestion( props: Props, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index 48aa56efdb3cc2..ab718a99843c87 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -794,6 +794,7 @@ describe('workspace_panel', () => { setKeyboardMode={() => {}} setA11yMessage={() => {}} registerDropTarget={jest.fn()} + dropTargetsByOrder={undefined} > dragDropContext.dragging && getSuggestionForField(dragDropContext.dragging), + [dragDropContext.dragging, getSuggestionForField] + ); + + return ( + + ); +}); + +// Exported for testing purposes only. +export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ activeDatasourceId, activeVisualizationId, visualizationMap, @@ -102,13 +126,10 @@ export const WorkspacePanel = React.memo(function WorkspacePanel({ ExpressionRenderer: ExpressionRendererComponent, title, visualizeTriggerFieldContext, - getSuggestionForField, -}: WorkspacePanelProps) { - const dragDropContext = useContext(DragContext); - - const suggestionForDraggedField = - dragDropContext.dragging && getSuggestionForField(dragDropContext.dragging); - + suggestionForDraggedField, +}: Omit & { + suggestionForDraggedField: Suggestion | undefined; +}) { const [localState, setLocalState] = useState({ expressionBuildError: undefined, expandError: false, @@ -173,6 +194,8 @@ export const WorkspacePanel = React.memo(function WorkspacePanel({ ] ); + const expressionExists = Boolean(expression); + const onEvent = useCallback( (event: ExpressionRendererEvent) => { if (!plugins.uiActions) { @@ -202,23 +225,23 @@ export const WorkspacePanel = React.memo(function WorkspacePanel({ useEffect(() => { // reset expression error if component attempts to run it again - if (expression && localState.expressionBuildError) { + if (expressionExists && localState.expressionBuildError) { setLocalState((s) => ({ ...s, expressionBuildError: undefined, })); } - }, [expression, localState.expressionBuildError]); + }, [expressionExists, localState.expressionBuildError]); - function onDrop() { + const onDrop = useCallback(() => { if (suggestionForDraggedField) { trackUiEvent('drop_onto_workspace'); - trackUiEvent(expression ? 'drop_non_empty' : 'drop_empty'); + trackUiEvent(expressionExists ? 'drop_non_empty' : 'drop_empty'); switchToSuggestion(dispatch, suggestionForDraggedField, 'SWITCH_VISUALIZATION'); } - } + }, [suggestionForDraggedField, expressionExists, dispatch]); - function renderEmptyWorkspace() { + const renderEmptyWorkspace = () => { return (

- {expression === null + {!expressionExists ? i18n.translate('xpack.lens.editorFrame.emptyWorkspace', { defaultMessage: 'Drop some fields here to start', }) @@ -239,7 +262,7 @@ export const WorkspacePanel = React.memo(function WorkspacePanel({

- {expression === null && ( + {!expressionExists && ( <>

{i18n.translate('xpack.lens.editorFrame.emptyWorkspaceHeading', { @@ -263,9 +286,9 @@ export const WorkspacePanel = React.memo(function WorkspacePanel({ )} ); - } + }; - function renderVisualization() { + const renderVisualization = () => { // we don't want to render the emptyWorkspace on visualizing field from Discover // as it is specific for the drag and drop functionality and can confuse the users if (expression === null && !visualizeTriggerFieldContext) { @@ -283,7 +306,7 @@ export const WorkspacePanel = React.memo(function WorkspacePanel({ ExpressionRendererComponent={ExpressionRendererComponent} /> ); - } + }; return ( -

+ {renderVisualization()} {Boolean(suggestionForDraggedField) && expression !== null && renderEmptyWorkspace()} -
+ ); 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 0ace88b3d3ab75..3949c7deb53b4a 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 @@ -10,6 +10,7 @@ position: relative; // For positioning the dnd overlay min-height: $euiSizeXXL * 10; overflow: visible; + border: none; .lnsWorkspacePanelWrapper__pageContentBody { @include euiScrollBar; @@ -29,34 +30,12 @@ } .lnsWorkspacePanel__dragDrop { - // Disable the coloring of the DnD for this element as we'll - // Color the whole panel instead - background-color: transparent !important; // sass-lint:disable-line no-important - border: none !important; // sass-lint:disable-line no-important width: 100%; height: 100%; -} - -.lnsExpressionRenderer { - .lnsDragDrop-isDropTarget & { - transition: filter $euiAnimSpeedNormal ease-in-out, opacity $euiAnimSpeedNormal ease-in-out; - filter: blur($euiSizeXS); - opacity: .25; - } -} - -.lnsWorkspacePanel__emptyContent { - position: absolute; - left: 0; - right: 0; - bottom: 0; - top: 0; - display: flex; - justify-content: center; - align-items: center; - transition: background-color $euiAnimSpeedFast ease-in-out; + border: $euiBorderThin; + border-radius: $euiBorderRadius; - .lnsDragDrop-isDropTarget & { + &.lnsDragDrop-isDropTarget { @include lnsDroppable; @include lnsDroppableActive; @@ -64,9 +43,15 @@ transition: filter $euiAnimSpeedFast ease-in-out; filter: blur(5px); } + + .lnsExpressionRenderer { + transition: filter $euiAnimSpeedNormal ease-in-out, opacity $euiAnimSpeedNormal ease-in-out; + filter: blur($euiSizeXS); + opacity: .25; + } } - .lnsDragDrop-isActiveDropTarget & { + &.lnsDragDrop-isActiveDropTarget { @include lnsDroppableActiveHover; .lnsDropIllustration__hand { @@ -75,6 +60,18 @@ } } +.lnsWorkspacePanel__emptyContent { + position: absolute; + left: 0; + right: 0; + bottom: 0; + top: 0; + display: flex; + justify-content: center; + align-items: center; + transition: background-color $euiAnimSpeedFast ease-in-out; +} + .lnsWorkspacePanelWrapper__toolbar { margin-bottom: 0; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx index 081f3bf5722e27..85f7601d8fb292 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx @@ -9,13 +9,7 @@ import './workspace_panel_wrapper.scss'; import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import { - EuiPageContent, - EuiPageContentBody, - EuiFlexGroup, - EuiFlexItem, - EuiScreenReaderOnly, -} from '@elastic/eui'; +import { EuiPageContent, EuiFlexGroup, EuiFlexItem, EuiScreenReaderOnly } from '@elastic/eui'; import { Datasource, FramePublicAPI, Visualization } from '../../../types'; import { NativeRenderer } from '../../../native_renderer'; import { Action } from '../state_management'; @@ -130,9 +124,7 @@ export function WorkspacePanelWrapper({ })} - - {children} - + {children} ); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts index 17f069b8831e7b..12df14f81cb67c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts @@ -7,8 +7,6 @@ import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; import { IndexPatternDimensionEditorProps } from './dimension_panel'; import { onDrop, getDropProps } from './droppable'; -import { DragContextState } from '../../drag_drop'; -import { createMockedDragDropContext } from '../mocks'; import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { IndexPatternPrivateState } from '../types'; @@ -98,7 +96,6 @@ describe('IndexPatternDimensionEditorPanel', () => { let state: IndexPatternPrivateState; let setState: jest.Mock; let defaultProps: IndexPatternDimensionEditorProps; - let dragDropContext: DragContextState; beforeEach(() => { state = { @@ -140,8 +137,6 @@ describe('IndexPatternDimensionEditorPanel', () => { setState = jest.fn(); - dragDropContext = createMockedDragDropContext(); - defaultProps = { state, setState, @@ -174,24 +169,28 @@ describe('IndexPatternDimensionEditorPanel', () => { }); const groupId = 'a'; + describe('getDropProps', () => { it('returns undefined if no drag is happening', () => { - expect(getDropProps({ ...defaultProps, groupId, dragDropContext })).toBe(undefined); + const dragging = { + name: 'bar', + id: 'bar', + humanData: { label: 'Label' }, + }; + expect(getDropProps({ ...defaultProps, groupId, dragging })).toBe(undefined); }); it('returns undefined if the dragged item has no field', () => { + const dragging = { + name: 'bar', + id: 'bar', + humanData: { label: 'Label' }, + }; expect( getDropProps({ ...defaultProps, groupId, - dragDropContext: { - ...dragDropContext, - dragging: { - name: 'bar', - id: 'bar', - humanData: { label: 'Label' }, - }, - }, + dragging, }) ).toBe(undefined); }); @@ -201,14 +200,11 @@ describe('IndexPatternDimensionEditorPanel', () => { getDropProps({ ...defaultProps, groupId, - dragDropContext: { - ...dragDropContext, - dragging: { - indexPatternId: 'foo', - field: { type: 'string', name: 'mystring', aggregatable: true }, - id: 'mystring', - humanData: { label: 'Label' }, - }, + dragging: { + indexPatternId: 'foo', + field: { type: 'string', name: 'mystring', aggregatable: true }, + id: 'mystring', + humanData: { label: 'Label' }, }, filterOperations: () => false, }) @@ -220,10 +216,7 @@ describe('IndexPatternDimensionEditorPanel', () => { getDropProps({ ...defaultProps, groupId, - dragDropContext: { - ...dragDropContext, - dragging: draggingField, - }, + dragging: draggingField, filterOperations: (op: OperationMetadata) => op.dataType === 'number', }) ).toEqual({ dropType: 'field_replace', nextLabel: 'Intervals' }); @@ -234,14 +227,11 @@ describe('IndexPatternDimensionEditorPanel', () => { getDropProps({ ...defaultProps, groupId, - dragDropContext: { - ...dragDropContext, - dragging: { - field: { type: 'number', name: 'bar', aggregatable: true }, - indexPatternId: 'foo2', - id: 'bar', - humanData: { label: 'Label' }, - }, + dragging: { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo2', + id: 'bar', + humanData: { label: 'Label' }, }, filterOperations: (op: OperationMetadata) => op.dataType === 'number', }) @@ -253,21 +243,18 @@ describe('IndexPatternDimensionEditorPanel', () => { getDropProps({ ...defaultProps, groupId, - dragDropContext: { - ...dragDropContext, - dragging: { - field: { - name: 'timestamp', - displayName: 'timestampLabel', - type: 'date', - aggregatable: true, - searchable: true, - exists: true, - }, - indexPatternId: 'foo', - id: 'bar', - humanData: { label: 'Label' }, + dragging: { + field: { + name: 'timestamp', + displayName: 'timestampLabel', + type: 'date', + aggregatable: true, + searchable: true, + exists: true, }, + indexPatternId: 'foo', + id: 'bar', + humanData: { label: 'Label' }, }, }) ).toBe(undefined); @@ -278,15 +265,12 @@ describe('IndexPatternDimensionEditorPanel', () => { getDropProps({ ...defaultProps, groupId, - dragDropContext: { - ...dragDropContext, - dragging: { - columnId: 'col1', - groupId: 'b', - layerId: 'first', - id: 'col1', - humanData: { label: 'Label' }, - }, + dragging: { + columnId: 'col1', + groupId: 'b', + layerId: 'first', + id: 'col1', + humanData: { label: 'Label' }, }, columnId: 'col2', }) @@ -321,16 +305,14 @@ describe('IndexPatternDimensionEditorPanel', () => { getDropProps({ ...defaultProps, groupId, - dragDropContext: { - ...dragDropContext, - dragging: { - columnId: 'col1', - groupId: 'b', - layerId: 'first', - id: 'col1', - humanData: { label: 'Label' }, - }, + dragging: { + columnId: 'col1', + groupId: 'b', + layerId: 'first', + id: 'col1', + humanData: { label: 'Label' }, }, + columnId: 'col2', }) ).toEqual(undefined); @@ -360,15 +342,12 @@ describe('IndexPatternDimensionEditorPanel', () => { getDropProps({ ...defaultProps, groupId, - dragDropContext: { - ...dragDropContext, - dragging: { - columnId: 'col1', - groupId: 'b', - layerId: 'first', - id: 'col1', - humanData: { label: 'Label' }, - }, + dragging: { + columnId: 'col1', + groupId: 'b', + layerId: 'first', + id: 'col1', + humanData: { label: 'Label' }, }, columnId: 'col2', filterOperations: (op: OperationMetadata) => op.isBucketed === false, @@ -380,10 +359,6 @@ describe('IndexPatternDimensionEditorPanel', () => { it('appends the dropped column when a field is dropped', () => { onDrop({ ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: draggingField, - }, droppedItem: draggingField, dropType: 'field_replace', columnId: 'col2', @@ -412,10 +387,6 @@ describe('IndexPatternDimensionEditorPanel', () => { it('selects the specific operation that was valid on drop', () => { onDrop({ ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: draggingField, - }, droppedItem: draggingField, columnId: 'col2', filterOperations: (op: OperationMetadata) => op.isBucketed, @@ -444,10 +415,6 @@ describe('IndexPatternDimensionEditorPanel', () => { it('updates a column when a field is dropped', () => { onDrop({ ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: draggingField, - }, droppedItem: draggingField, filterOperations: (op: OperationMetadata) => op.dataType === 'number', dropType: 'field_replace', @@ -470,18 +437,8 @@ describe('IndexPatternDimensionEditorPanel', () => { }); it('keeps the operation when dropping a different compatible field', () => { - const dragging = { - field: { name: 'memory', type: 'number', aggregatable: true }, - indexPatternId: 'foo', - id: '1', - humanData: { label: 'Label' }, - }; onDrop({ ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, - }, droppedItem: { field: { name: 'memory', type: 'number', aggregatable: true }, indexPatternId: 'foo', @@ -538,10 +495,6 @@ describe('IndexPatternDimensionEditorPanel', () => { onDrop({ ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, - }, droppedItem: dragging, columnId: 'col2', dropType: 'move_compatible', @@ -598,10 +551,6 @@ describe('IndexPatternDimensionEditorPanel', () => { onDrop({ ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: defaultDragging, - }, droppedItem: defaultDragging, state: testState, dropType: 'replace_compatible', @@ -667,10 +616,6 @@ describe('IndexPatternDimensionEditorPanel', () => { onDrop({ ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: metricDragging, - }, droppedItem: metricDragging, state: testState, dropType: 'duplicate_in_group', @@ -703,10 +648,6 @@ describe('IndexPatternDimensionEditorPanel', () => { onDrop({ ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: bucketDragging, - }, droppedItem: bucketDragging, state: testState, dropType: 'duplicate_in_group', @@ -768,10 +709,7 @@ describe('IndexPatternDimensionEditorPanel', () => { const defaultReorderDropParams = { ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, - }, + dragging, droppedItem: dragging, state: testState, filterOperations: (op: OperationMetadata) => op.dataType === 'number', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts index be791b3c7f7cec..a7d4774d8aa3d7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts @@ -23,6 +23,7 @@ import { mergeLayer } from '../state_helpers'; import { hasField, isDraggedField } from '../utils'; import { IndexPatternPrivateState, DraggedField } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; +import { DragContextState } from '../../drag_drop/providers'; type DropHandlerProps = DatasourceDimensionDropHandlerProps & { droppedItem: T; @@ -31,9 +32,12 @@ type DropHandlerProps = DatasourceDimensionDropHandlerProps & { groupId: string } + props: DatasourceDimensionDropProps & { + dragging: DragContextState['dragging']; + groupId: string; + } ): { dropType: DropType; nextLabel?: string } | undefined { - const { dragging } = props.dragDropContext; + const { dragging } = props; if (!dragging) { return; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index b8b5eb4c1e6f86..cd7cfc6e8a1b22 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -82,7 +82,6 @@ export function getIndexPatternDatasource({ data: DataPublicPluginStart; charts: ChartsPluginSetup; }) { - const savedObjectsClient = core.savedObjects.client; const uiSettings = core.uiSettings; const onIndexPatternLoadError = (err: Error) => core.notifications.toasts.addError(err, { @@ -93,6 +92,21 @@ export function getIndexPatternDatasource({ const indexPatternsService = data.indexPatterns; + const handleChangeIndexPattern = ( + id: string, + state: IndexPatternPrivateState, + setState: StateSetter + ) => { + changeIndexPattern({ + id, + state, + setState, + onError: onIndexPatternLoadError, + storage, + indexPatternsService, + }); + }; + // Not stateful. State is persisted to the frame const indexPatternDatasource: Datasource = { id: 'indexpattern', @@ -106,7 +120,6 @@ export function getIndexPatternDatasource({ return loadInitialState({ persistedState, references, - savedObjectsClient: await savedObjectsClient, defaultIndexPatternId: core.uiSettings.get('defaultIndex'), storage, indexPatternsService, @@ -171,20 +184,7 @@ export function getIndexPatternDatasource({ render( - ) => { - changeIndexPattern({ - id, - state, - setState, - onError: onIndexPatternLoadError, - storage, - indexPatternsService, - }); - }} + changeIndexPattern={handleChangeIndexPattern} data={data} charts={charts} {...props} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index 3a96b4cadd03bf..68947c35581389 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { HttpHandler, SavedObjectsClientContract } from 'kibana/public'; +import { HttpHandler } from 'kibana/public'; import _ from 'lodash'; import { loadInitialState, @@ -183,23 +183,24 @@ const sampleIndexPatterns = { '2': indexPattern2, }; -function mockClient() { - return ({ - find: jest.fn(async () => ({ - savedObjects: [ - { id: '1', attributes: { title: sampleIndexPatterns[1].title } }, - { id: '2', attributes: { title: sampleIndexPatterns[2].title } }, - ], - })), - } as unknown) as Pick; -} - function mockIndexPatternsService() { return ({ get: jest.fn(async (id: '1' | '2') => { return { ...sampleIndexPatternsFromService[id], metaFields: [] }; }), - } as unknown) as Pick; + getIdsWithTitle: jest.fn(async () => { + return [ + { + id: sampleIndexPatterns[1].id, + title: sampleIndexPatterns[1].title, + }, + { + id: sampleIndexPatterns[2].id, + title: sampleIndexPatterns[2].title, + }, + ]; + }), + } as unknown) as Pick; } describe('loader', () => { @@ -212,7 +213,8 @@ describe('loader', () => { get: jest.fn(() => Promise.reject('mockIndexPatternService.get should not have been called') ), - } as unknown) as Pick, + getIdsWithTitle: jest.fn(), + } as unknown) as Pick, }); expect(cache).toEqual(sampleIndexPatterns); @@ -281,7 +283,11 @@ describe('loader', () => { }, ], })), - } as unknown) as Pick, + getIdsWithTitle: jest.fn(async () => ({ + id: 'foo', + title: 'Foo index', + })), + } as unknown) as Pick, }); expect(cache.foo.getFieldByName('bytes')!.aggregationRestrictions).toEqual({ @@ -333,7 +339,11 @@ describe('loader', () => { }, ], })), - } as unknown) as Pick, + getIdsWithTitle: jest.fn(async () => ({ + id: 'foo', + title: 'Foo index', + })), + } as unknown) as Pick, }); expect(cache.foo.getFieldByName('timestamp')!.meta).toEqual(true); @@ -344,7 +354,6 @@ describe('loader', () => { it('should load a default state', async () => { const storage = createMockStorage(); const state = await loadInitialState({ - savedObjectsClient: mockClient(), indexPatternsService: mockIndexPatternsService(), storage, options: { isFullEditor: true }, @@ -368,10 +377,9 @@ describe('loader', () => { it('should load a default state without loading the indexPatterns when embedded', async () => { const storage = createMockStorage(); - const savedObjectsClient = mockClient(); + const indexPatternsService = mockIndexPatternsService(); const state = await loadInitialState({ - savedObjectsClient, - indexPatternsService: mockIndexPatternsService(), + indexPatternsService, storage, options: { isFullEditor: false }, }); @@ -384,14 +392,12 @@ describe('loader', () => { }); expect(storage.set).not.toHaveBeenCalled(); - - expect(savedObjectsClient.find).not.toHaveBeenCalled(); + expect(indexPatternsService.getIdsWithTitle).not.toHaveBeenCalled(); }); it('should load a default state when lastUsedIndexPatternId is not found in indexPatternRefs', async () => { const storage = createMockStorage({ indexPatternId: 'c' }); const state = await loadInitialState({ - savedObjectsClient: mockClient(), indexPatternsService: mockIndexPatternsService(), storage, options: { isFullEditor: true }, @@ -415,7 +421,6 @@ describe('loader', () => { it('should load lastUsedIndexPatternId if in localStorage', async () => { const state = await loadInitialState({ - savedObjectsClient: mockClient(), indexPatternsService: mockIndexPatternsService(), storage: createMockStorage({ indexPatternId: '2' }), options: { isFullEditor: true }, @@ -438,7 +443,6 @@ describe('loader', () => { const storage = createMockStorage(); const state = await loadInitialState({ defaultIndexPatternId: '2', - savedObjectsClient: mockClient(), indexPatternsService: mockIndexPatternsService(), storage, options: { isFullEditor: true }, @@ -463,7 +467,6 @@ describe('loader', () => { it('should use the indexPatternId of the visualize trigger field, if provided', async () => { const storage = createMockStorage(); const state = await loadInitialState({ - savedObjectsClient: mockClient(), indexPatternsService: mockIndexPatternsService(), storage, initialContext: { @@ -524,7 +527,6 @@ describe('loader', () => { { name: 'indexpattern-datasource-layer-layerb', id: '2', type: 'index-pattern' }, { name: 'another-reference', id: 'c', type: 'index-pattern' }, ], - savedObjectsClient: mockClient(), indexPatternsService: mockIndexPatternsService(), storage, options: { isFullEditor: true }, @@ -681,6 +683,7 @@ describe('loader', () => { get: jest.fn(async () => { throw err; }), + getIdsWithTitle: jest.fn(), }, onError, storage, @@ -808,6 +811,7 @@ describe('loader', () => { get: jest.fn(async () => { throw err; }), + getIdsWithTitle: jest.fn(), }, onError, storage, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index f4aa976699e3f3..92b0e27c3d1a72 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -7,7 +7,7 @@ import _ from 'lodash'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; -import { SavedObjectsClientContract, HttpSetup, SavedObjectReference } from 'kibana/public'; +import { HttpSetup, SavedObjectReference } from 'kibana/public'; import { InitializationOptions, StateSetter } from '../types'; import { IndexPattern, @@ -30,8 +30,7 @@ import { readFromStorage, writeToStorage } from '../settings_storage'; import { getFieldByNameFactory } from './pure_helpers'; type SetState = StateSetter; -type SavedObjectsClient = Pick; -type IndexPatternsService = Pick; +type IndexPatternsService = Pick; type ErrorHandler = (err: Error) => void; export async function loadIndexPatterns({ @@ -186,7 +185,6 @@ export function injectReferences( export async function loadInitialState({ persistedState, references, - savedObjectsClient, defaultIndexPatternId, storage, indexPatternsService, @@ -195,7 +193,6 @@ export async function loadInitialState({ }: { persistedState?: IndexPatternPersistedState; references?: SavedObjectReference[]; - savedObjectsClient: SavedObjectsClient; defaultIndexPatternId?: string; storage: IStorageWrapper; indexPatternsService: IndexPatternsService; @@ -203,7 +200,7 @@ export async function loadInitialState({ options?: InitializationOptions; }): Promise { const { isFullEditor } = options ?? {}; - const indexPatternRefs = await (isFullEditor ? loadIndexPatternRefs(savedObjectsClient) : []); + const indexPatternRefs = await (isFullEditor ? loadIndexPatternRefs(indexPatternsService) : []); const lastUsedIndexPatternId = getLastUsedIndexPatternId(storage, indexPatternRefs); const state = @@ -334,22 +331,13 @@ export async function changeLayerIndexPattern({ } async function loadIndexPatternRefs( - savedObjectsClient: SavedObjectsClient + indexPatternsService: IndexPatternsService ): Promise { - const result = await savedObjectsClient.find<{ title: string }>({ - type: 'index-pattern', - fields: ['title'], - perPage: 10000, - }); + const indexPatterns = await indexPatternsService.getIdsWithTitle(); - return result.savedObjects - .map((o) => ({ - id: String(o.id), - title: (o.attributes as { title: string }).title, - })) - .sort((a, b) => { - return a.title.localeCompare(b.title); - }); + return indexPatterns.sort((a, b) => { + return a.title.localeCompare(b.title); + }); } export async function syncExistingFields({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts index 06560bb0fa2443..e71b26b9d4cd9b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts @@ -253,6 +253,7 @@ export function createMockedDragDropContext(): jest.Mocked { keyboardMode: false, setKeyboardMode: jest.fn(), setA11yMessage: jest.fn(), + dropTargetsByOrder: undefined, registerDropTarget: jest.fn(), }; } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 419354117eda2e..6ac2d98994be34 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -190,7 +190,10 @@ export interface Datasource { renderDimensionEditor: (domElement: Element, props: DatasourceDimensionEditorProps) => void; renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps) => void; getDropProps: ( - props: DatasourceDimensionDropProps & { groupId: string } + props: DatasourceDimensionDropProps & { + groupId: string; + dragging: DragContextState['dragging']; + } ) => { dropType: DropType; nextLabel?: string } | undefined; onDrop: (props: DatasourceDimensionDropHandlerProps) => false | true | { deleted: string }; updateStateOnCloseDimension?: (props: { @@ -278,9 +281,7 @@ export type DatasourceDimensionEditorProps = DatasourceDimensionPro dimensionGroups: VisualizationDimensionGroupConfig[]; }; -export type DatasourceDimensionTriggerProps = DatasourceDimensionProps & { - dragDropContext: DragContextState; -}; +export type DatasourceDimensionTriggerProps = DatasourceDimensionProps; export interface DatasourceLayerPanelProps { layerId: string; @@ -310,7 +311,6 @@ export type DatasourceDimensionDropProps = SharedDimensionProps & { columnId: string; state: T; setState: StateSetter; - dragDropContext: DragContextState; }; export type DatasourceDimensionDropHandlerProps = DatasourceDimensionDropProps & { diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_nested_entries_array.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_nested_entries_array.ts index 0b5b49c2715d43..722f5dd600eecf 100644 --- a/x-pack/plugins/lists/common/schemas/types/non_empty_nested_entries_array.ts +++ b/x-pack/plugins/lists/common/schemas/types/non_empty_nested_entries_array.ts @@ -12,7 +12,8 @@ import { entriesMatchAny } from './entry_match_any'; import { entriesMatch } from './entry_match'; import { entriesExists } from './entry_exists'; -export const nestedEntriesArray = t.array(t.union([entriesMatch, entriesMatchAny, entriesExists])); +export const nestedEntryItem = t.union([entriesMatch, entriesMatchAny, entriesExists]); +export const nestedEntriesArray = t.array(nestedEntryItem); export type NestedEntriesArray = t.TypeOf; /** diff --git a/x-pack/plugins/lists/common/shared_exports.ts b/x-pack/plugins/lists/common/shared_exports.ts index 6dcda5d1f8c24d..4c4ee19d29bcd1 100644 --- a/x-pack/plugins/lists/common/shared_exports.ts +++ b/x-pack/plugins/lists/common/shared_exports.ts @@ -36,6 +36,7 @@ export { listSchema, entry, entriesNested, + nestedEntryItem, entriesMatch, entriesMatchAny, entriesExists, diff --git a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css index 35590df90fbb90..86183694330e28 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css +++ b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css @@ -55,7 +55,7 @@ discover-app .discover-table-footer { /* hide unusable controls * !important is required to override resizable panel inline display */ -.visEditor__content .visEditor--default > :not(.visEditor__visualization) { +.visEditor__content .visEditor--default > :not(.visEditor__visualization__wrapper) { display: none !important; } diff --git a/x-pack/plugins/reporting/server/lib/layouts/print.css b/x-pack/plugins/reporting/server/lib/layouts/print.css index c47c8de50f9e1c..fa963ac72ab41c 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/print.css +++ b/x-pack/plugins/reporting/server/lib/layouts/print.css @@ -54,7 +54,7 @@ discover-app .discover-table-footer { /* hide unusable controls * !important is required to override resizable panel inline display */ -.visEditor__content .visEditor--default > :not(.visEditor__visualization) { +.visEditor__content .visEditor--default > :not(.visEditor__visualization__wrapper) { display: none !important; } /** THIS IS FOR TSVB UNTIL REFACTOR **/ diff --git a/x-pack/plugins/security_solution/common/shared_imports.ts b/x-pack/plugins/security_solution/common/shared_imports.ts index 988f0ad0c125d4..94afbb948bf42f 100644 --- a/x-pack/plugins/security_solution/common/shared_imports.ts +++ b/x-pack/plugins/security_solution/common/shared_imports.ts @@ -35,6 +35,7 @@ export { listSchema, entry, entriesNested, + nestedEntryItem, entriesMatch, entriesMatchAny, entriesExists, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index b09ad60b239de8..fdf7594a550a2e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -326,6 +326,52 @@ describe('Exception helpers', () => { expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); + test('it removes the "nested" entry entries with "value" of empty string', () => { + const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; + const mockEmptyException: EntryNested = { + field: 'host.name', + type: OperatorTypeEnum.NESTED, + entries: [getEntryMatchMock(), { ...getEntryMatchMock(), value: '' }], + }; + const output: Array< + ExceptionListItemSchema | CreateExceptionListItemSchema + > = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); + + expect(output).toEqual([ + { + ...getExceptionListItemSchemaMock(), + entries: [ + ...getExceptionListItemSchemaMock().entries, + { ...mockEmptyException, entries: [getEntryMatchMock()] }, + ], + }, + ]); + }); + + test('it removes the "nested" entry item if all its entries are invalid', () => { + const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; + const mockEmptyException: EntryNested = { + field: 'host.name', + type: OperatorTypeEnum.NESTED, + entries: [{ ...getEntryMatchMock(), value: '' }], + }; + const output: Array< + ExceptionListItemSchema | CreateExceptionListItemSchema + > = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); + + expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); + }); + test('it removes `temporaryId` from items', () => { const { meta, ...rest } = getNewExceptionItem({ listId: '123', 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 507fd51a90486b..13ee06e8cbac9c 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 @@ -32,6 +32,7 @@ import { comment, entry, entriesNested, + nestedEntryItem, createExceptionListItemSchema, exceptionListItemSchema, UpdateExceptionListItemSchema, @@ -173,16 +174,31 @@ export const filterExceptionItems = ( ): Array => { return exceptions.reduce>( (acc, exception) => { - const entries = exception.entries.filter((t) => { - const [validatedEntry] = validate(t, entry); - const [validatedNestedEntry] = validate(t, entriesNested); + const entries = exception.entries.reduce((nestedAcc, singleEntry) => { + if (singleEntry.type === 'nested') { + const nestedEntriesArray = singleEntry.entries.filter((singleNestedEntry) => { + const [validatedNestedEntry] = validate(singleNestedEntry, nestedEntryItem); + return validatedNestedEntry != null; + }); + + const [validatedNestedEntry] = validate( + { ...singleEntry, entries: nestedEntriesArray }, + entriesNested + ); + + if (validatedNestedEntry != null) { + return [...nestedAcc, validatedNestedEntry]; + } + return nestedAcc; + } else { + const [validatedEntry] = validate(singleEntry, entry); - if (validatedEntry != null || validatedNestedEntry != null) { - return true; + if (validatedEntry != null) { + return [...nestedAcc, validatedEntry]; + } + return nestedAcc; } - - return false; - }); + }, []); const item = { ...exception, entries }; @@ -401,7 +417,7 @@ export const getCodeSignatureValue = ( return codeSignature.map((signature) => { return { subjectName: signature.subject_name ?? '', - trusted: signature.trusted ?? '', + trusted: signature.trusted.toString() ?? '', }; }); } else { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx index a4812a6372abcc..4e330f7c0bd077 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx @@ -49,6 +49,7 @@ const DescriptionListContainer = styled(EuiDescriptionList)` } &.euiDescriptionList--column .euiDescriptionList__description { width: 70%; + overflow-wrap: anywhere; } `; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table_helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table_helpers.tsx index ab45475fa8e84f..cc9ba225cac0ec 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table_helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table_helpers.tsx @@ -29,7 +29,7 @@ export const buildColumns = ( { field: 'name', name: i18n.COLUMN_FILE_NAME, - truncateText: true, + truncateText: false, }, { field: 'type', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index cdc9e183f52106..03d9f45abb9699 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -21806,7 +21806,6 @@ "xpack.uptime.ml.enableAnomalyDetectionPanel.manageMLJobDescription.noteText": "注:ジョブが結果の計算を開始するまでに少し時間がかかる場合があります。", "xpack.uptime.ml.enableAnomalyDetectionPanel.startTrial": "無料の 14 日トライアルを開始", "xpack.uptime.ml.enableAnomalyDetectionPanel.startTrialDesc": "期間異常検知機能を利用するには、Elastic Platinum ライセンスが必要です。", - "xpack.uptime.monitorCharts.durationChart.leftAxis.title": "期間(ミリ秒)", "xpack.uptime.monitorCharts.monitorDuration.titleLabel": "監視期間", "xpack.uptime.monitorCharts.monitorDuration.titleLabelWithAnomaly": "監視期間 (異常: {noOfAnomalies})", "xpack.uptime.monitorDetails.ml.confirmAlertDeleteMessage": "異常のアラートを削除しますか?", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8c5bbb9ce515ef..4e71ba0d61f3a0 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -21856,7 +21856,6 @@ "xpack.uptime.ml.enableAnomalyDetectionPanel.manageMLJobDescription.noteText": "注意:可能要过几分钟后,作业才会开始计算结果。", "xpack.uptime.ml.enableAnomalyDetectionPanel.startTrial": "开始为期 14 天的免费试用", "xpack.uptime.ml.enableAnomalyDetectionPanel.startTrialDesc": "要访问持续时间异常检测,必须订阅 Elastic 白金级许可证。", - "xpack.uptime.monitorCharts.durationChart.leftAxis.title": "持续时间(毫秒)", "xpack.uptime.monitorCharts.monitorDuration.titleLabel": "监测持续时间", "xpack.uptime.monitorCharts.monitorDuration.titleLabelWithAnomaly": "监测持续时间(异常:{noOfAnomalies})", "xpack.uptime.monitorDetails.ml.confirmAlertDeleteMessage": "确定要删除异常告警?", diff --git a/x-pack/plugins/uptime/public/components/common/charts/duration_chart.tsx b/x-pack/plugins/uptime/public/components/common/charts/duration_chart.tsx index 68b9ab0435aaac..1f1e80838b6580 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/duration_chart.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/duration_chart.tsx @@ -18,6 +18,7 @@ import { BrushEndListener, LegendItemListener, } from '@elastic/charts'; +import { useSelector } from 'react-redux'; import { getChartDateLabel } from '../../../lib/helper'; import { LocationDurationLine } from '../../../../common/types'; import { DurationLineSeriesList } from './duration_line_series_list'; @@ -29,6 +30,9 @@ import { DurationAnomaliesBar } from './duration_line_bar_list'; import { AnomalyRecords } from '../../../state/actions'; import { UptimeThemeContext } from '../../../contexts'; import { MONITOR_CHART_HEIGHT } from '../../monitor'; +import { monitorStatusSelector } from '../../../state/selectors'; +import { microToMilli, microToSec } from '../../../lib/formatting'; +import { MS_LABEL, SECONDS_LABEL } from '../translations'; interface DurationChartProps { /** @@ -87,6 +91,8 @@ export const DurationChartComponent = ({ } }; + const monitor = useSelector(monitorStatusSelector); + return ( {hasLines ? ( @@ -112,10 +118,17 @@ export const DurationChartComponent = ({ position={Position.Left} tickFormat={(d) => getTickFormat(d)} title={i18n.translate('xpack.uptime.monitorCharts.durationChart.leftAxis.title', { - defaultMessage: 'Duration in ms', + defaultMessage: 'Duration in {unit}', + values: { unit: monitor?.monitor.type === 'browser' ? SECONDS_LABEL : MS_LABEL }, })} + labelFormat={(d) => + monitor?.monitor.type === 'browser' ? `${microToSec(d)}` : `${microToMilli(d)}` + } + /> + - ) : ( diff --git a/x-pack/plugins/uptime/public/components/common/charts/duration_line_series_list.tsx b/x-pack/plugins/uptime/public/components/common/charts/duration_line_series_list.tsx index a630076b442d7d..a13696a6379b31 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/duration_line_series_list.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/duration_line_series_list.tsx @@ -8,19 +8,21 @@ import React from 'react'; import { LineSeries, CurveType, Fit } from '@elastic/charts'; import { LocationDurationLine } from '../../../../common/types'; -import { convertMicrosecondsToMilliseconds as microsToMillis } from '../../../lib/helper'; +import { microToMilli, microToSec } from '../../../lib/formatting'; +import { MS_LABEL, SEC_LABEL } from '../translations'; interface Props { + monitorType: string; lines: LocationDurationLine[]; } -export const DurationLineSeriesList = ({ lines }: Props) => ( +export const DurationLineSeriesList = ({ monitorType, lines }: Props) => ( <> {lines.map(({ name, line }) => ( [x, microsToMillis(y || null)])} + data={line.map(({ x, y }) => [x, y || null])} id={`loc-avg-${name}`} key={`loc-line-${name}`} name={name} @@ -30,6 +32,11 @@ export const DurationLineSeriesList = ({ lines }: Props) => ( yScaleToDataExtent={false} yScaleType="linear" fit={Fit.Linear} + tickFormat={(d) => + monitorType === 'browser' + ? `${microToSec(d)} ${SEC_LABEL}` + : `${microToMilli(d)} ${MS_LABEL}` + } /> ))} diff --git a/x-pack/plugins/uptime/public/components/common/translations.ts b/x-pack/plugins/uptime/public/components/common/translations.ts index f067b17c048e61..db772d8bed9d73 100644 --- a/x-pack/plugins/uptime/public/components/common/translations.ts +++ b/x-pack/plugins/uptime/public/components/common/translations.ts @@ -36,3 +36,15 @@ export const STATUS_FAILED_LABEL = i18n.translate( defaultMessage: 'Failed', } ); + +export const SECONDS_LABEL = i18n.translate('xpack.uptime.seconds.label', { + defaultMessage: 'seconds', +}); + +export const SEC_LABEL = i18n.translate('xpack.uptime.seconds.shortForm.label', { + defaultMessage: 'sec', +}); + +export const MS_LABEL = i18n.translate('xpack.uptime.millisecond.abbreviation.label', { + defaultMessage: 'ms', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx index 71c22b44068674..cfb92dd31190e8 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx @@ -73,7 +73,7 @@ export const PingTimestamp = ({ timestamp, ping }: Props) => { } }, [data]); - const imgSrc = stepImages[stepNumber] || data?.src; + const imgSrc = stepImages?.[stepNumber - 1] ?? data?.src; const captionContent = formatCaptionContent(stepNumber, data?.maxSteps); @@ -85,6 +85,7 @@ export const PingTimestamp = ({ timestamp, ping }: Props) => { setStepNumber={setStepNumber} stepNumber={stepNumber} timestamp={timestamp} + isLoading={status === FETCH_STATUS.LOADING || status === FETCH_STATUS.PENDING} /> ); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.test.tsx index 80ed3ca46b8aa8..a33e5870932799 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.test.tsx @@ -21,6 +21,7 @@ describe('StepImageCaption', () => { setStepNumber: jest.fn(), stepNumber: 2, timestamp: '2020-11-26T15:28:56.896Z', + isLoading: false, }; }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx index 018ef85062ecc6..fe9709a02b684e 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx @@ -19,6 +19,7 @@ export interface StepImageCaptionProps { setStepNumber: React.Dispatch>; stepNumber: number; timestamp: string; + isLoading: boolean; } const ImageCaption = euiStyled.div` @@ -35,6 +36,7 @@ export const StepImageCaption: React.FC = ({ setStepNumber, stepNumber, timestamp, + isLoading, }) => { return ( @@ -49,6 +51,7 @@ export const StepImageCaption: React.FC = ({ }} iconType="arrowLeft" aria-label={prevAriaLabel} + isLoading={isLoading} > {prevAriaLabel} @@ -65,6 +68,7 @@ export const StepImageCaption: React.FC = ({ iconType="arrowRight" iconSide="right" aria-label={nextAriaLabel} + isLoading={isLoading} > {nextAriaLabel} diff --git a/x-pack/plugins/uptime/public/lib/formatting.ts b/x-pack/plugins/uptime/public/lib/formatting.ts new file mode 100644 index 00000000000000..9bf0cac82dc09c --- /dev/null +++ b/x-pack/plugins/uptime/public/lib/formatting.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// one second = 1 million micros +const ONE_SECOND_AS_MICROS = 1000000; +const ONE_SECOND_AS_MILLI = 1000; +const ONE_MILLI_AS_MICRO = 1000; + +export function milliToSec(ms: number) { + return ms / ONE_SECOND_AS_MILLI; +} + +export function microToSec(micro: number) { + return (micro / ONE_SECOND_AS_MICROS).toFixed(0); +} + +export function microToMilli(micro: number) { + return (micro / ONE_MILLI_AS_MICRO).toFixed(0); +} diff --git a/x-pack/plugins/uptime/server/lib/requests/__snapshots__/get_monitor_charts.test.ts.snap b/x-pack/plugins/uptime/server/lib/requests/__snapshots__/get_monitor_charts.test.ts.snap index 6ab55c2afdddab..dd47945d9eaba2 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__snapshots__/get_monitor_charts.test.ts.snap +++ b/x-pack/plugins/uptime/server/lib/requests/__snapshots__/get_monitor_charts.test.ts.snap @@ -17,7 +17,7 @@ Array [ }, "terms": Object { "field": "observer.geo.name", - "missing": "N/A", + "missing": "Unnamed-location", }, }, }, diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_duration.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_duration.ts index deaec46281e4ef..ef1c66364422aa 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_monitor_duration.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_duration.ts @@ -7,7 +7,7 @@ import { UMElasticsearchQueryFn } from '../adapters'; import { LocationDurationLine, MonitorDurationResult } from '../../../common/types'; -import { QUERY } from '../../../common/constants'; +import { QUERY, UNNAMED_LOCATION } from '../../../common/constants'; export interface GetMonitorChartsParams { /** @member monitorId ID value for the selected monitor */ @@ -46,7 +46,7 @@ export const getMonitorDurationChart: UMElasticsearchQueryFn< location: { terms: { field: 'observer.geo.name', - missing: 'N/A', + missing: UNNAMED_LOCATION, }, aggs: { duration: { stats: { field: 'monitor.duration.us' } }, diff --git a/x-pack/test/accessibility/apps/dashboard_edit_panel.ts b/x-pack/test/accessibility/apps/dashboard_edit_panel.ts index 90b3c4ef4d4909..c318c2d1c26a0e 100644 --- a/x-pack/test/accessibility/apps/dashboard_edit_panel.ts +++ b/x-pack/test/accessibility/apps/dashboard_edit_panel.ts @@ -18,8 +18,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['security', 'common']); const toasts = getService('toasts'); - // Failing: See https://github.com/elastic/kibana/issues/91592 - describe.skip('Dashboard Edit Panel', () => { + const PANEL_TITLE = 'Visualization PieChart'; + + describe('Dashboard Edit Panel', () => { before(async () => { await esArchiver.load('dashboard/drilldowns'); await esArchiver.loadIfNeeded('logstash_functional'); @@ -33,100 +34,68 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esArchiver.unload('dashboard/drilldowns'); }); - // embeddable edit panel - it(' A11y test on dashboard edit panel menu options', async () => { - await testSubjects.click('embeddablePanelToggleMenuIcon'); + it('can open menu', async () => { + await dashboardPanelActions.openContextMenu(); await a11y.testAppSnapshot(); }); - // https://github.com/elastic/kibana/issues/77931 - it.skip('A11y test for edit visualization and save', async () => { - await testSubjects.click('embeddablePanelToggleMenuIcon'); - await testSubjects.click('embeddablePanelAction-editPanel'); - await testSubjects.click('visualizesaveAndReturnButton'); + it('can clone panel', async () => { + await dashboardPanelActions.clonePanelByTitle(PANEL_TITLE); await a11y.testAppSnapshot(); + await toasts.dismissAllToasts(); + await dashboardPanelActions.removePanelByTitle(`${PANEL_TITLE} (copy)`); }); - // clone panel - it(' A11y test on dashboard embeddable clone panel', async () => { - await testSubjects.click('embeddablePanelAction-clonePanel'); + it('can customize panel', async () => { + await dashboardPanelActions.customizePanel(); await a11y.testAppSnapshot(); - await toasts.dismissAllToasts(); - await dashboardPanelActions.removePanelByTitle('Visualization PieChart (copy)'); }); - // edit panel title - it(' A11y test on dashboard embeddable edit dashboard title', async () => { - await testSubjects.click('embeddablePanelToggleMenuIcon'); - await testSubjects.click('embeddablePanelAction-ACTION_CUSTOMIZE_PANEL'); - await a11y.testAppSnapshot(); - await testSubjects.click('customizePanelHideTitle'); + it('can hide panel title', async () => { + await dashboardPanelActions.clickHidePanelTitleToggle(); await a11y.testAppSnapshot(); await testSubjects.click('saveNewTitleButton'); }); - // create drilldown - it('A11y test on dashboard embeddable open flyout and drilldown', async () => { + it('can drilldown', async () => { await testSubjects.click('embeddablePanelToggleMenuIcon'); await testSubjects.click('embeddablePanelAction-OPEN_FLYOUT_ADD_DRILLDOWN'); await a11y.testAppSnapshot(); await testSubjects.click('flyoutCloseButton'); }); - // clicking on more button - it('A11y test on dashboard embeddable more button', async () => { - await testSubjects.click('embeddablePanelToggleMenuIcon'); - await testSubjects.click('embeddablePanelMore-mainMenu'); + it('can view more actions', async () => { + await dashboardPanelActions.openContextMenuMorePanel(); await a11y.testAppSnapshot(); }); - // https://github.com/elastic/kibana/issues/77422 - it.skip('A11y test on dashboard embeddable custom time range', async () => { - await testSubjects.click('embeddablePanelToggleMenuIcon'); + it('can create a custom time range', async () => { + await dashboardPanelActions.openContextMenuMorePanel(); await testSubjects.click('embeddablePanelAction-CUSTOM_TIME_RANGE'); await a11y.testAppSnapshot(); + await testSubjects.click('addPerPanelTimeRangeButton'); }); - // flow will change whenever the custom time range a11y issue gets fixed. - // Will need to click on gear icon and then click on more. - - // inspector panel - it('A11y test on dashboard embeddable open inspector', async () => { - await testSubjects.click('embeddablePanelAction-openInspector'); + it('can open inspector', async () => { + await dashboardPanelActions.openInspector(); await a11y.testAppSnapshot(); await testSubjects.click('euiFlyoutCloseButton'); }); - // fullscreen - it('A11y test on dashboard embeddable fullscreen', async () => { - await testSubjects.click('embeddablePanelToggleMenuIcon'); - await testSubjects.click('embeddablePanelMore-mainMenu'); - await testSubjects.click('embeddablePanelAction-togglePanel'); - await a11y.testAppSnapshot(); - }); - - // minimize fullscreen panel - it('A11y test on dashboard embeddable fullscreen minimize ', async () => { - await testSubjects.click('embeddablePanelToggleMenuIcon'); - await testSubjects.click('embeddablePanelMore-mainMenu'); - await testSubjects.click('embeddablePanelAction-togglePanel'); + it('can go fullscreen', async () => { + await dashboardPanelActions.clickExpandPanelToggle(); await a11y.testAppSnapshot(); + await dashboardPanelActions.clickExpandPanelToggle(); }); - // replace panel - it('A11y test on dashboard embeddable replace panel', async () => { - await testSubjects.click('embeddablePanelToggleMenuIcon'); - await testSubjects.click('embeddablePanelMore-mainMenu'); - await testSubjects.click('embeddablePanelAction-replacePanel'); + it('can replace panel', async () => { + await dashboardPanelActions.replacePanelByTitle(); await a11y.testAppSnapshot(); await testSubjects.click('euiFlyoutCloseButton'); }); - // delete from dashboard - it('A11y test on dashboard embeddable delete panel', async () => { - await testSubjects.click('embeddablePanelToggleMenuIcon'); - await testSubjects.click('embeddablePanelMore-mainMenu'); - await testSubjects.click('embeddablePanelAction-deletePanel'); + it('can delete panel', async () => { + await dashboardPanelActions.removePanel(); await a11y.testAppSnapshot(); }); }); diff --git a/x-pack/test/accessibility/apps/uptime.ts b/x-pack/test/accessibility/apps/uptime.ts index 874ede0b13ee98..9c48e7d82788f0 100644 --- a/x-pack/test/accessibility/apps/uptime.ts +++ b/x-pack/test/accessibility/apps/uptime.ts @@ -17,10 +17,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const uptimeService = getService('uptime'); const esArchiver = getService('esArchiver'); const es = getService('es'); + const toasts = getService('toasts'); - // FLAKY: https://github.com/elastic/kibana/issues/90555 - // Failing: See https://github.com/elastic/kibana/issues/90555 - describe.skip('uptime', () => { + describe('uptime', () => { before(async () => { await esArchiver.load('uptime/blank'); await makeChecks(es, A11Y_TEST_MONITOR_ID, 150, 1, 1000, { @@ -61,7 +60,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('overview alert popover controls', async () => { await uptimeService.overview.openAlertsPopover(); + await toasts.dismissAllToasts(); await a11y.testAppSnapshot(); + }); + + it('overview alert popover controls nested content', async () => { await uptimeService.overview.navigateToNestedPopover(); await a11y.testAppSnapshot(); }); diff --git a/x-pack/test/apm_api_integration/tests/index.ts b/x-pack/test/apm_api_integration/tests/index.ts index 3884b3ae750a5c..c030ffb347c860 100644 --- a/x-pack/test/apm_api_integration/tests/index.ts +++ b/x-pack/test/apm_api_integration/tests/index.ts @@ -15,7 +15,8 @@ export default function apmApiIntegrationTests(providerContext: FtrProviderConte this.tags('ciGroup1'); loadTestFile(require.resolve('./alerts/chart_preview')); - loadTestFile(require.resolve('./correlations/slow_transactions')); + // Flaky, see https://github.com/elastic/kibana/issues/91673 + // loadTestFile(require.resolve('./correlations/slow_transactions')); loadTestFile(require.resolve('./csm/csm_services')); loadTestFile(require.resolve('./csm/has_rum_data')); diff --git a/x-pack/test/apm_api_integration/tests/service_maps/service_maps.ts b/x-pack/test/apm_api_integration/tests/service_maps/service_maps.ts index 5adbafc07e187f..f452514cb5172b 100644 --- a/x-pack/test/apm_api_integration/tests/service_maps/service_maps.ts +++ b/x-pack/test/apm_api_integration/tests/service_maps/service_maps.ts @@ -49,7 +49,6 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext) const q = querystring.stringify({ start: metadata.start, end: metadata.end, - uiFilters: encodeURIComponent('{}'), }); const response = await supertest.get(`/api/apm/service-map/service/opbeans-node?${q}`); diff --git a/x-pack/test/functional/es_archives/saved_objects_management/spaces_integration/mappings.json b/x-pack/test/functional/es_archives/saved_objects_management/spaces_integration/mappings.json index 84ea7d0aaca9e1..617c1f3b505663 100644 --- a/x-pack/test/functional/es_archives/saved_objects_management/spaces_integration/mappings.json +++ b/x-pack/test/functional/es_archives/saved_objects_management/spaces_integration/mappings.json @@ -1,473 +1,27 @@ { "type": "index", "value": { - "index": ".kibana", - "settings": { - "index": { - "number_of_shards": "1", - "auto_expand_replicas": "0-1", - "number_of_replicas": "0" - } + "aliases": { + ".kibana_8.0.0": {}, + ".kibana": {} }, + "index": ".kibana_8.0.0_001", "mappings": { - "dynamic": "strict", + "dynamic": "false", "properties": { - "apm-telemetry": { - "properties": { - "has_any_services": { - "type": "boolean" - }, - "services_per_agent": { - "properties": { - "go": { - "type": "long", - "null_value": 0 - }, - "java": { - "type": "long", - "null_value": 0 - }, - "js-base": { - "type": "long", - "null_value": 0 - }, - "nodejs": { - "type": "long", - "null_value": 0 - }, - "python": { - "type": "long", - "null_value": 0 - }, - "ruby": { - "type": "long", - "null_value": 0 - } - } - } - } - }, - "canvas-workpad": { - "dynamic": "false", - "properties": { - "@created": { - "type": "date" - }, - "@timestamp": { - "type": "date" - }, - "id": { - "type": "text", - "index": false - }, - "name": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword" - } - } - } - } - }, - "config": { - "dynamic": "true", - "properties": { - "accessibility:disableAnimations": { - "type": "boolean" - }, - "buildNum": { - "type": "keyword" - }, - "dateFormat:tz": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "defaultIndex": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "telemetry:optIn": { - "type": "boolean" - } - } - }, - "dashboard": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "optionsJSON": { - "type": "text" - }, - "panelsJSON": { - "type": "text" - }, - "refreshInterval": { - "properties": { - "display": { - "type": "keyword" - }, - "pause": { - "type": "boolean" - }, - "section": { - "type": "integer" - }, - "value": { - "type": "integer" - } - } - }, - "timeFrom": { - "type": "keyword" - }, - "timeRestore": { - "type": "boolean" - }, - "timeTo": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "map": { - "properties": { - "bounds": { - "type": "geo_shape", - "tree": "quadtree" - }, - "description": { - "type": "text" - }, - "layerListJSON": { - "type": "text" - }, - "mapStateJSON": { - "type": "text" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "graph-workspace": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "numLinks": { - "type": "integer" - }, - "numVertices": { - "type": "integer" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "wsState": { - "type": "text" - } - } - }, - "index-pattern": { - "properties": { - "fieldFormatMap": { - "type": "text" - }, - "fields": { - "type": "text" - }, - "intervalName": { - "type": "keyword" - }, - "notExpandable": { - "type": "boolean" - }, - "sourceFilters": { - "type": "text" - }, - "timeFieldName": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "type": { - "type": "keyword" - }, - "typeMeta": { - "type": "keyword" - } - } - }, - "kql-telemetry": { - "properties": { - "optInCount": { - "type": "long" - }, - "optOutCount": { - "type": "long" - } - } - }, + "type": { "type": "keyword" }, "migrationVersion": { "dynamic": "true", - "properties": { - "index-pattern": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "space": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - }, - "namespace": { - "type": "keyword" - }, - "search": { - "properties": { - "columns": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "sort": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "server": { - "properties": { - "uuid": { - "type": "keyword" - } - } - }, - "space": { - "properties": { - "_reserved": { - "type": "boolean" - }, - "color": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "disabledFeatures": { - "type": "keyword" - }, - "initials": { - "type": "keyword" - }, - "name": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 2048 - } - } - } - } - }, - "spaceId": { - "type": "keyword" - }, - "telemetry": { - "properties": { - "enabled": { - "type": "boolean" - } - } - }, - "timelion-sheet": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "timelion_chart_height": { - "type": "integer" - }, - "timelion_columns": { - "type": "integer" - }, - "timelion_interval": { - "type": "keyword" - }, - "timelion_other_interval": { - "type": "keyword" - }, - "timelion_rows": { - "type": "integer" - }, - "timelion_sheet": { - "type": "text" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "type": { - "type": "keyword" - }, - "updated_at": { - "type": "date" - }, - "url": { - "properties": { - "accessCount": { - "type": "long" - }, - "accessDate": { - "type": "date" - }, - "createDate": { - "type": "date" - }, - "url": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 2048 - } - } - } - } - }, - "references": { - "type": "nested", - "properties": { - "name": { - "type": "keyword" - }, - "type": { - "type": "keyword" - }, - "id": { - "type": "keyword" - } - } - }, - "visualization": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "savedSearchId": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "visState": { - "type": "text" - } - } + "type": "object" } } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } } } } diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 50c4fb305a6d0c..9e50f96296a942 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -1,377 +1,22 @@ { "type": "index", "value": { - "aliases": {}, - "index": ".kibana", + "aliases": { + ".kibana_8.0.0": {}, + ".kibana": {} + }, + "index": ".kibana_8.0.0_001", "mappings": { - "dynamic": "strict", + "dynamic": "false", "properties": { - "config": { - "dynamic": "true", - "properties": { - "buildNum": { - "type": "keyword" - }, - "defaultIndex": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "dashboard": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "optionsJSON": { - "type": "text" - }, - "panelsJSON": { - "type": "text" - }, - "refreshInterval": { - "properties": { - "display": { - "type": "keyword" - }, - "pause": { - "type": "boolean" - }, - "section": { - "type": "integer" - }, - "value": { - "type": "integer" - } - } - }, - "timeFrom": { - "type": "keyword" - }, - "timeRestore": { - "type": "boolean" - }, - "timeTo": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "globaltype": { - "properties": { - "title": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "graph-workspace": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "numLinks": { - "type": "integer" - }, - "numVertices": { - "type": "integer" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "wsState": { - "type": "text" - } - } - }, - "index-pattern": { - "properties": { - "fieldFormatMap": { - "type": "text" - }, - "fields": { - "type": "text" - }, - "intervalName": { - "type": "keyword" - }, - "notExpandable": { - "type": "boolean" - }, - "sourceFilters": { - "type": "text" - }, - "timeFieldName": { - "type": "keyword" - }, - "title": { - "type": "text" - } - } - }, - "isolatedtype": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "savedSearchId": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "visState": { - "type": "text" - } - } - }, - "legacy-url-alias": { - "properties": { - "targetNamespace": { - "type": "keyword" - }, - "targetType": { - "type": "keyword" - }, - "targetId": { - "type": "keyword" - }, - "lastResolved": { - "type": "date" - }, - "resolveCounter": { - "type": "integer" - }, - "disabled": { - "type": "boolean" - } - } - }, - "namespace": { - "type": "keyword" - }, - "namespaces": { - "type": "keyword" - }, - "originId": { - "type": "keyword" - }, - "resolvetype": { - "properties": { - "title": { - "type": "text" - } - } - }, - "search": { - "properties": { - "columns": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "sort": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "server": { - "properties": { - "uuid": { - "type": "keyword" - } - } - }, - "sharedtype": { - "properties": { - "title": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 2048 - } - } - } - } - }, - "sharecapabletype": { - "properties": { - "title": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 2048 - } - } - } - } - }, "space": { - "properties": { - "_reserved": { - "type": "boolean" - }, - "color": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "disabledFeatures": { - "type": "keyword" - }, - "initials": { - "type": "keyword" - }, - "name": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "timelion-sheet": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "timelion_chart_height": { - "type": "integer" - }, - "timelion_columns": { - "type": "integer" - }, - "timelion_interval": { - "type": "keyword" - }, - "timelion_other_interval": { - "type": "keyword" - }, - "timelion_rows": { - "type": "integer" - }, - "timelion_sheet": { - "type": "text" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "type": { - "type": "keyword" + "dynamic": false, + "type": "object" }, - "updated_at": { - "type": "date" - }, - "url": { - "properties": { - "accessCount": { - "type": "long" - }, - "accessDate": { - "type": "date" - }, - "createDate": { - "type": "date" - }, - "url": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } + "type": { "type": "keyword" }, + "migrationVersion": { + "dynamic": "true", + "type": "object" } } }, diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index a2f8088ce04360..4d83411bc48407 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -2,345 +2,12 @@ "type": "index", "value": { "aliases": { + ".kibana_8.0.0": {}, + ".kibana": {} }, - "index": ".kibana", + "index": ".kibana_8.0.0_001", "mappings": { - "dynamic": "strict", - "properties": { - "config": { - "dynamic": "true", - "properties": { - "buildNum": { - "type": "keyword" - }, - "defaultIndex": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "references": { - "type": "nested", - "properties": { - "name": { - "type": "keyword" - }, - "type": { - "type": "keyword" - }, - "id": { - "type": "keyword" - } - } - }, - "dashboard": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "optionsJSON": { - "type": "text" - }, - "panelsJSON": { - "type": "text" - }, - "refreshInterval": { - "properties": { - "display": { - "type": "keyword" - }, - "pause": { - "type": "boolean" - }, - "section": { - "type": "integer" - }, - "value": { - "type": "integer" - } - } - }, - "timeFrom": { - "type": "keyword" - }, - "timeRestore": { - "type": "boolean" - }, - "timeTo": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "file-upload-telemetry": { - "properties": { - "filesUploadedTotalCount": { - "type": "long" - } - } - }, - "graph-workspace": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "numLinks": { - "type": "integer" - }, - "numVertices": { - "type": "integer" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "wsState": { - "type": "text" - } - } - }, - "index-pattern": { - "properties": { - "fieldFormatMap": { - "type": "text" - }, - "fields": { - "type": "text" - }, - "intervalName": { - "type": "keyword" - }, - "notExpandable": { - "type": "boolean" - }, - "sourceFilters": { - "type": "text" - }, - "timeFieldName": { - "type": "keyword" - }, - "title": { - "type": "text" - } - } - }, - "namespace": { - "type": "keyword" - }, - "namespaces": { - "type": "keyword" - }, - "originId": { - "type": "keyword" - }, - "search": { - "properties": { - "columns": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "sort": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "server": { - "properties": { - "uuid": { - "type": "keyword" - } - } - }, - "space": { - "properties": { - "_reserved": { - "type": "boolean" - }, - "color": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "disabledFeatures": { - "type": "keyword" - }, - "initials": { - "type": "keyword" - }, - "name": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "timelion-sheet": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "timelion_chart_height": { - "type": "integer" - }, - "timelion_columns": { - "type": "integer" - }, - "timelion_interval": { - "type": "keyword" - }, - "timelion_other_interval": { - "type": "keyword" - }, - "timelion_rows": { - "type": "integer" - }, - "timelion_sheet": { - "type": "text" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "type": { - "type": "keyword" - }, - "updated_at": { - "type": "date" - }, - "url": { - "properties": { - "accessCount": { - "type": "long" - }, - "accessDate": { - "type": "date" - }, - "createDate": { - "type": "date" - }, - "url": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "visualization": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "savedSearchId": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "visState": { - "type": "text" - } - } - }, - "sharedtype": { - "properties": { - "title": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 2048 - } - } - } - } - } - } + "dynamic": "false" }, "settings": { "index": {