diff --git a/src/core/server/i18n/i18n_service.test.mocks.ts b/src/core/server/i18n/i18n_service.test.mocks.ts index 23f97a1404fff..d35141ecb111f 100644 --- a/src/core/server/i18n/i18n_service.test.mocks.ts +++ b/src/core/server/i18n/i18n_service.test.mocks.ts @@ -26,3 +26,8 @@ export const initTranslationsMock = jest.fn(); jest.doMock('./init_translations', () => ({ initTranslations: initTranslationsMock, })); + +export const registerRoutesMock = jest.fn(); +jest.doMock('./routes', () => ({ + registerRoutes: registerRoutesMock, +})); diff --git a/src/core/server/i18n/i18n_service.test.ts b/src/core/server/i18n/i18n_service.test.ts index 87de39a92ab26..e9deb96ccf88b 100644 --- a/src/core/server/i18n/i18n_service.test.ts +++ b/src/core/server/i18n/i18n_service.test.ts @@ -17,13 +17,18 @@ * under the License. */ -import { getKibanaTranslationFilesMock, initTranslationsMock } from './i18n_service.test.mocks'; +import { + getKibanaTranslationFilesMock, + initTranslationsMock, + registerRoutesMock, +} from './i18n_service.test.mocks'; import { BehaviorSubject } from 'rxjs'; import { I18nService } from './i18n_service'; import { configServiceMock } from '../config/mocks'; import { mockCoreContext } from '../core_context.mock'; +import { httpServiceMock } from '../http/http_service.mock'; const getConfigService = (locale = 'en') => { const configService = configServiceMock.create(); @@ -41,6 +46,7 @@ const getConfigService = (locale = 'en') => { describe('I18nService', () => { let service: I18nService; let configService: ReturnType; + let http: ReturnType; beforeEach(() => { jest.clearAllMocks(); @@ -48,6 +54,8 @@ describe('I18nService', () => { const coreContext = mockCoreContext.create({ configService }); service = new I18nService(coreContext); + + http = httpServiceMock.createInternalSetupContract(); }); describe('#setup', () => { @@ -55,7 +63,7 @@ describe('I18nService', () => { getKibanaTranslationFilesMock.mockResolvedValue([]); const pluginPaths = ['/pathA', '/pathB']; - await service.setup({ pluginPaths }); + await service.setup({ pluginPaths, http }); expect(getKibanaTranslationFilesMock).toHaveBeenCalledTimes(1); expect(getKibanaTranslationFilesMock).toHaveBeenCalledWith('en', pluginPaths); @@ -65,17 +73,27 @@ describe('I18nService', () => { const translationFiles = ['/path/to/file', 'path/to/another/file']; getKibanaTranslationFilesMock.mockResolvedValue(translationFiles); - await service.setup({ pluginPaths: [] }); + await service.setup({ pluginPaths: [], http }); expect(initTranslationsMock).toHaveBeenCalledTimes(1); expect(initTranslationsMock).toHaveBeenCalledWith('en', translationFiles); }); + it('calls `registerRoutesMock` with the correct parameters', async () => { + await service.setup({ pluginPaths: [], http }); + + expect(registerRoutesMock).toHaveBeenCalledTimes(1); + expect(registerRoutesMock).toHaveBeenCalledWith({ + locale: 'en', + router: expect.any(Object), + }); + }); + it('returns accessors for locale and translation files', async () => { const translationFiles = ['/path/to/file', 'path/to/another/file']; getKibanaTranslationFilesMock.mockResolvedValue(translationFiles); - const { getLocale, getTranslationFiles } = await service.setup({ pluginPaths: [] }); + const { getLocale, getTranslationFiles } = await service.setup({ pluginPaths: [], http }); expect(getLocale()).toEqual('en'); expect(getTranslationFiles()).toEqual(translationFiles); diff --git a/src/core/server/i18n/i18n_service.ts b/src/core/server/i18n/i18n_service.ts index fd32dd7fdd6ef..4a609ca5e2aea 100644 --- a/src/core/server/i18n/i18n_service.ts +++ b/src/core/server/i18n/i18n_service.ts @@ -21,11 +21,14 @@ import { take } from 'rxjs/operators'; import { Logger } from '../logging'; import { IConfigService } from '../config'; import { CoreContext } from '../core_context'; +import { InternalHttpServiceSetup } from '../http'; import { config as i18nConfigDef, I18nConfigType } from './i18n_config'; import { getKibanaTranslationFiles } from './get_kibana_translation_files'; import { initTranslations } from './init_translations'; +import { registerRoutes } from './routes'; interface SetupDeps { + http: InternalHttpServiceSetup; pluginPaths: string[]; } @@ -53,7 +56,7 @@ export class I18nService { this.configService = coreContext.configService; } - public async setup({ pluginPaths }: SetupDeps): Promise { + public async setup({ pluginPaths, http }: SetupDeps): Promise { const i18nConfig = await this.configService .atPath(i18nConfigDef.path) .pipe(take(1)) @@ -67,6 +70,9 @@ export class I18nService { this.log.debug(`Using translation files: [${translationFiles.join(', ')}]`); await initTranslations(locale, translationFiles); + const router = http.createRouter(''); + registerRoutes({ router, locale }); + return { getLocale: () => locale, getTranslationFiles: () => translationFiles, diff --git a/src/core/server/i18n/routes/index.ts b/src/core/server/i18n/routes/index.ts new file mode 100644 index 0000000000000..b0cce67b0aa4d --- /dev/null +++ b/src/core/server/i18n/routes/index.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IRouter } from '../../http'; +import { registerTranslationsRoute } from './translations'; + +export const registerRoutes = ({ router, locale }: { router: IRouter; locale: string }) => { + registerTranslationsRoute(router, locale); +}; diff --git a/src/core/server/i18n/routes/translations.ts b/src/core/server/i18n/routes/translations.ts new file mode 100644 index 0000000000000..c5cc9525d54aa --- /dev/null +++ b/src/core/server/i18n/routes/translations.ts @@ -0,0 +1,69 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'crypto'; +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +interface TranslationCache { + translations: string; + hash: string; +} + +export const registerTranslationsRoute = (router: IRouter, locale: string) => { + let translationCache: TranslationCache; + + router.get( + { + path: '/translations/{locale}.json', + validate: { + params: schema.object({ + locale: schema.string(), + }), + }, + options: { + authRequired: false, + }, + }, + (ctx, req, res) => { + if (req.params.locale.toLowerCase() !== locale.toLowerCase()) { + return res.notFound({ + body: `Unknown locale: ${req.params.locale}`, + }); + } + if (!translationCache) { + const translations = JSON.stringify(i18n.getTranslation()); + const hash = createHash('sha1').update(translations).digest('hex'); + translationCache = { + translations, + hash, + }; + } + return res.ok({ + headers: { + 'content-type': 'application/json', + 'cache-control': 'must-revalidate', + etag: translationCache.hash, + }, + body: translationCache.translations, + }); + } + ); +}; diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 55ed88e55a9f5..0f7e8cced999c 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -131,9 +131,6 @@ export class Server { await ensureValidConfiguration(this.configService, legacyConfigSetup); } - // setup i18n prior to any other service, to have translations ready - const i18nServiceSetup = await this.i18n.setup({ pluginPaths }); - const contextServiceSetup = this.context.setup({ // We inject a fake "legacy plugin" with dependencies on every plugin so that legacy plugins: // 1) Can access context from any KP plugin @@ -149,6 +146,9 @@ export class Server { context: contextServiceSetup, }); + // setup i18n prior to any other service, to have translations ready + const i18nServiceSetup = await this.i18n.setup({ http: httpSetup, pluginPaths }); + const capabilitiesSetup = this.capabilities.setup({ http: httpSetup }); const elasticsearchServiceSetup = await this.elasticsearch.setup({ diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 153725fc48e7b..36c742dc40403 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -22,6 +22,7 @@ export const storybookAliases = { canvas: 'x-pack/plugins/canvas/storybook', codeeditor: 'src/plugins/kibana_react/public/code_editor/.storybook', dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook', + data_enhanced: 'x-pack/plugins/data_enhanced/.storybook', embeddable: 'src/plugins/embeddable/.storybook', infra: 'x-pack/plugins/infra/.storybook', security_solution: 'x-pack/plugins/security_solution/.storybook', diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index a02c2fca14c18..b8e80300957ba 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -17,9 +17,7 @@ * under the License. */ -import { createHash } from 'crypto'; import Boom from '@hapi/boom'; -import { i18n } from '@kbn/i18n'; import * as UiSharedDeps from '@kbn/ui-shared-deps'; import { KibanaRequest } from '../../../core/server'; import { AppBootstrap } from './bootstrap'; @@ -37,36 +35,6 @@ import { getApmConfig } from '../apm'; * @param {KbnServer['config']} config */ export function uiRenderMixin(kbnServer, server, config) { - const translationsCache = { translations: null, hash: null }; - server.route({ - path: '/translations/{locale}.json', - method: 'GET', - config: { auth: false }, - handler(request, h) { - // Kibana server loads translations only for a single locale - // that is specified in `i18n.locale` config value. - const { locale } = request.params; - if (i18n.getLocale() !== locale.toLowerCase()) { - throw Boom.notFound(`Unknown locale: ${locale}`); - } - - // Stringifying thousands of labels and calculating hash on the resulting - // string can be expensive so it makes sense to do it once and cache. - if (translationsCache.translations == null) { - translationsCache.translations = JSON.stringify(i18n.getTranslation()); - translationsCache.hash = createHash('sha1') - .update(translationsCache.translations) - .digest('hex'); - } - - return h - .response(translationsCache.translations) - .header('cache-control', 'must-revalidate') - .header('content-type', 'application/json') - .etag(translationsCache.hash); - }, - }); - const authEnabled = !!server.auth.settings.default; server.route({ path: '/bootstrap.js', diff --git a/src/plugins/advanced_settings/public/management_app/lib/get_category_name.ts b/src/plugins/advanced_settings/public/management_app/lib/get_category_name.ts index 31df6875c97d9..c8fbe8009c9bb 100644 --- a/src/plugins/advanced_settings/public/management_app/lib/get_category_name.ts +++ b/src/plugins/advanced_settings/public/management_app/lib/get_category_name.ts @@ -25,6 +25,12 @@ const names: Record = { general: i18n.translate('advancedSettings.categoryNames.generalLabel', { defaultMessage: 'General', }), + machineLearning: i18n.translate('advancedSettings.categoryNames.machineLearningLabel', { + defaultMessage: 'Machine Learning', + }), + observability: i18n.translate('advancedSettings.categoryNames.observabilityLabel', { + defaultMessage: 'Observability', + }), timelion: i18n.translate('advancedSettings.categoryNames.timelionLabel', { defaultMessage: 'Timelion', }), diff --git a/src/plugins/data/common/search/session/mocks.ts b/src/plugins/data/common/search/session/mocks.ts index 2b64bbbd27565..370faaa640c56 100644 --- a/src/plugins/data/common/search/session/mocks.ts +++ b/src/plugins/data/common/search/session/mocks.ts @@ -17,6 +17,7 @@ * under the License. */ +import { BehaviorSubject } from 'rxjs'; import { ISessionService } from './types'; export function getSessionServiceMock(): jest.Mocked { @@ -25,6 +26,6 @@ export function getSessionServiceMock(): jest.Mocked { start: jest.fn(), restore: jest.fn(), getSessionId: jest.fn(), - getSession$: jest.fn(), + getSession$: jest.fn(() => new BehaviorSubject(undefined).asObservable()), }; } diff --git a/src/plugins/vis_type_vislib/public/vislib/_vislib_vis_type.scss b/src/plugins/vis_type_vislib/public/vislib/_vislib_vis_type.scss index 843bb9d3f03eb..9e737fc87e895 100644 --- a/src/plugins/vis_type_vislib/public/vislib/_vislib_vis_type.scss +++ b/src/plugins/vis_type_vislib/public/vislib/_vislib_vis_type.scss @@ -29,3 +29,15 @@ min-height: 0; min-width: 0; } + +.vislib__wrapper { + position: relative; +} + +.vislib__container { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} diff --git a/test/api_integration/apis/core/compression.ts b/test/api_integration/apis/core/compression.ts new file mode 100644 index 0000000000000..d7184e28ca3a4 --- /dev/null +++ b/test/api_integration/apis/core/compression.ts @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('compression', () => { + it(`uses compression when there isn't a referer`, async () => { + await supertest + .get('/app/kibana') + .set('accept-encoding', 'gzip') + .then((response) => { + expect(response.header).to.have.property('content-encoding', 'gzip'); + }); + }); + + it(`uses compression when there is a whitelisted referer`, async () => { + await supertest + .get('/app/kibana') + .set('accept-encoding', 'gzip') + .set('referer', 'https://some-host.com') + .then((response) => { + expect(response.header).to.have.property('content-encoding', 'gzip'); + }); + }); + + it(`doesn't use compression when there is a non-whitelisted referer`, async () => { + await supertest + .get('/app/kibana') + .set('accept-encoding', 'gzip') + .set('referer', 'https://other.some-host.com') + .then((response) => { + expect(response.header).not.to.have.property('content-encoding'); + }); + }); + }); +} diff --git a/test/api_integration/apis/core/index.js b/test/api_integration/apis/core/index.js deleted file mode 100644 index ab9bb8d33c2dc..0000000000000 --- a/test/api_integration/apis/core/index.js +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import expect from '@kbn/expect'; - -export default function ({ getService }) { - const supertest = getService('supertest'); - - describe('core', () => { - describe('compression', () => { - it(`uses compression when there isn't a referer`, async () => { - await supertest - .get('/app/kibana') - .set('accept-encoding', 'gzip') - .then((response) => { - expect(response.headers).to.have.property('content-encoding', 'gzip'); - }); - }); - - it(`uses compression when there is a whitelisted referer`, async () => { - await supertest - .get('/app/kibana') - .set('accept-encoding', 'gzip') - .set('referer', 'https://some-host.com') - .then((response) => { - expect(response.headers).to.have.property('content-encoding', 'gzip'); - }); - }); - - it(`doesn't use compression when there is a non-whitelisted referer`, async () => { - await supertest - .get('/app/kibana') - .set('accept-encoding', 'gzip') - .set('referer', 'https://other.some-host.com') - .then((response) => { - expect(response.headers).not.to.have.property('content-encoding'); - }); - }); - }); - }); -} diff --git a/test/api_integration/apis/core/index.ts b/test/api_integration/apis/core/index.ts new file mode 100644 index 0000000000000..6a1d7db769df5 --- /dev/null +++ b/test/api_integration/apis/core/index.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('core', () => { + loadTestFile(require.resolve('./compression')); + loadTestFile(require.resolve('./translations')); + }); +} diff --git a/test/api_integration/apis/core/translations.ts b/test/api_integration/apis/core/translations.ts new file mode 100644 index 0000000000000..865d3d070f39a --- /dev/null +++ b/test/api_integration/apis/core/translations.ts @@ -0,0 +1,42 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('translations', () => { + it(`returns the translations with the correct headers`, async () => { + await supertest.get('/translations/en.json').then((response) => { + expect(response.body.locale).to.eql('en'); + + expect(response.header).to.have.property('content-type', 'application/json; charset=utf-8'); + expect(response.header).to.have.property('cache-control', 'must-revalidate'); + expect(response.header).to.have.property('etag'); + }); + }); + + it(`returns a 404 when not using the correct locale`, async () => { + await supertest.get('/translations/foo.json').then((response) => { + expect(response.status).to.eql(404); + }); + }); + }); +} diff --git a/x-pack/plugins/apm/jest.config.js b/x-pack/plugins/apm/jest.config.js index 5be8ad141ffd0..ffd3a39e8afd1 100644 --- a/x-pack/plugins/apm/jest.config.js +++ b/x-pack/plugins/apm/jest.config.js @@ -29,7 +29,7 @@ module.exports = { roots: [`${rootDir}/common`, `${rootDir}/public`, `${rootDir}/server`], collectCoverage: true, collectCoverageFrom: [ - ...jestConfig.collectCoverageFrom, + ...(jestConfig.collectCoverageFrom ?? []), '**/*.{js,mjs,jsx,ts,tsx}', '!**/*.stories.{js,mjs,ts,tsx}', '!**/dev_docs/**', diff --git a/x-pack/plugins/apm/server/ui_settings.ts b/x-pack/plugins/apm/server/ui_settings.ts index fe5b11d89d716..4932d9f79a383 100644 --- a/x-pack/plugins/apm/server/ui_settings.ts +++ b/x-pack/plugins/apm/server/ui_settings.ts @@ -17,7 +17,7 @@ import { */ export const uiSettings: Record> = { [enableCorrelations]: { - category: ['Observability'], + category: ['observability'], name: i18n.translate('xpack.apm.enableCorrelationsExperimentName', { defaultMessage: 'APM Correlations', }), @@ -32,7 +32,7 @@ export const uiSettings: Record> = { schema: schema.boolean(), }, [enableServiceOverview]: { - category: ['Observability'], + category: ['observability'], name: i18n.translate('xpack.apm.enableServiceOverviewExperimentName', { defaultMessage: 'APM Service overview', }), diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index f2f8f659f3a2c..dc647d288ec65 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -33,7 +33,7 @@ export function getActionType({ }: GetActionTypeParams): CaseActionType { return { id: CASE_ACTION_TYPE_ID, - minimumLicenseRequired: 'gold', + minimumLicenseRequired: 'basic', name: i18n.NAME, validate: { config: CaseConfigurationSchema, diff --git a/x-pack/plugins/data_enhanced/.storybook/main.js b/x-pack/plugins/data_enhanced/.storybook/main.js new file mode 100644 index 0000000000000..1818aa44a9399 --- /dev/null +++ b/x-pack/plugins/data_enhanced/.storybook/main.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/x-pack/plugins/data_enhanced/config.ts b/x-pack/plugins/data_enhanced/config.ts new file mode 100644 index 0000000000000..9838f0959ef19 --- /dev/null +++ b/x-pack/plugins/data_enhanced/config.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const configSchema = schema.object({ + search: schema.object({ + sendToBackground: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), + }), +}); + +export type ConfigSchema = TypeOf; diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json index 5ded0f8f0dec3..bc7c8410d3df1 100644 --- a/x-pack/plugins/data_enhanced/kibana.json +++ b/x-pack/plugins/data_enhanced/kibana.json @@ -12,5 +12,5 @@ "optionalPlugins": ["kibanaUtils", "usageCollection"], "server": true, "ui": true, - "requiredBundles": ["kibanaUtils"] + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/x-pack/plugins/data_enhanced/public/index.ts b/x-pack/plugins/data_enhanced/public/index.ts index 22ac0c9883966..7fe34e21fde5c 100644 --- a/x-pack/plugins/data_enhanced/public/index.ts +++ b/x-pack/plugins/data_enhanced/public/index.ts @@ -4,9 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { PluginInitializerContext } from 'kibana/public'; import { DataEnhancedPlugin, DataEnhancedSetup, DataEnhancedStart } from './plugin'; +import { ConfigSchema } from '../config'; -export const plugin = () => new DataEnhancedPlugin(); +export const plugin = (initializerContext: PluginInitializerContext) => + new DataEnhancedPlugin(initializerContext); export { DataEnhancedSetup, DataEnhancedStart }; diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 43ad4a9ed9b8b..948858a5ed4c1 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -4,12 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import React from 'react'; +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; + import { setAutocompleteService } from './services'; import { setupKqlQuerySuggestionProvider, KUERY_LANGUAGE_NAME } from './autocomplete'; - import { EnhancedSearchInterceptor } from './search/search_interceptor'; +import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; +import { createConnectedBackgroundSessionIndicator } from './search'; +import { ConfigSchema } from '../config'; export interface DataEnhancedSetupDependencies { data: DataPublicPluginSetup; @@ -25,6 +29,8 @@ export class DataEnhancedPlugin implements Plugin { private enhancedSearchInterceptor!: EnhancedSearchInterceptor; + constructor(private initializerContext: PluginInitializerContext) {} + public setup( core: CoreSetup, { data }: DataEnhancedSetupDependencies @@ -52,6 +58,18 @@ export class DataEnhancedPlugin public start(core: CoreStart, plugins: DataEnhancedStartDependencies) { setAutocompleteService(plugins.data.autocomplete); + + if (this.initializerContext.config.get().search.sendToBackground.enabled) { + core.chrome.setBreadcrumbsAppendExtension({ + content: toMountPoint( + React.createElement( + createConnectedBackgroundSessionIndicator({ + sessionService: plugins.data.search.session, + }) + ) + ), + }); + } } public stop() { diff --git a/x-pack/plugins/data_enhanced/public/search/index.ts b/x-pack/plugins/data_enhanced/public/search/index.ts new file mode 100644 index 0000000000000..1a33812ca8566 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './ui'; diff --git a/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/background_session_indicator.scss b/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/background_session_indicator.scss new file mode 100644 index 0000000000000..2d13d320ae78b --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/background_session_indicator.scss @@ -0,0 +1,23 @@ +.backgroundSessionIndicator { + padding: 0 $euiSizeXS; +} + +@include euiBreakpoint('xs', 's') { + .backgroundSessionIndicator__popoverContainer.euiFlexGroup--responsive .euiFlexItem { + margin-bottom: $euiSizeXS !important; + } +} + +.backgroundSessionIndicator__verticalDivider { + @include euiBreakpoint('xs', 's') { + margin-left: $euiSizeXS; + padding-left: $euiSizeXS; + } + + @include euiBreakpoint('m', 'l', 'xl') { + border-left: $euiBorderThin; + align-self: stretch; + margin-left: $euiSizeS; + padding-left: $euiSizeS; + } +} diff --git a/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/background_session_indicator.stories.tsx b/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/background_session_indicator.stories.tsx new file mode 100644 index 0000000000000..9cef76c62279c --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/background_session_indicator.stories.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { BackgroundSessionIndicator } from './background_session_indicator'; +import { BackgroundSessionViewState } from '../connected_background_session_indicator'; + +storiesOf('components/BackgroundSessionIndicator', module).add('default', () => ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +)); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/background_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/background_session_indicator.test.tsx new file mode 100644 index 0000000000000..5b7ab2e4f9b1f --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/background_session_indicator.test.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { ReactNode } from 'react'; +import { screen, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BackgroundSessionIndicator } from './background_session_indicator'; +import { BackgroundSessionViewState } from '../connected_background_session_indicator'; +import { IntlProvider } from 'react-intl'; + +function Container({ children }: { children?: ReactNode }) { + return {children}; +} + +test('Loading state', async () => { + const onCancel = jest.fn(); + render( + + + + ); + + await userEvent.click(screen.getByLabelText('Loading results')); + await userEvent.click(screen.getByText('Cancel')); + + expect(onCancel).toBeCalled(); +}); + +test('Completed state', async () => { + const onSave = jest.fn(); + render( + + + + ); + + await userEvent.click(screen.getByLabelText('Results loaded')); + await userEvent.click(screen.getByText('Save')); + + expect(onSave).toBeCalled(); +}); + +test('Loading in the background state', async () => { + const onCancel = jest.fn(); + render( + + + + ); + + await userEvent.click(screen.getByLabelText('Loading results in the background')); + await userEvent.click(screen.getByText('Cancel')); + + expect(onCancel).toBeCalled(); +}); + +test('BackgroundCompleted state', async () => { + const onViewSession = jest.fn(); + render( + + + + ); + + await userEvent.click(screen.getByLabelText('Results loaded in the background')); + await userEvent.click(screen.getByText('View background sessions')); + + expect(onViewSession).toBeCalled(); +}); + +test('Restored state', async () => { + const onRefresh = jest.fn(); + render( + + + + ); + + await userEvent.click(screen.getByLabelText('Results no longer current')); + await userEvent.click(screen.getByText('Refresh')); + + expect(onRefresh).toBeCalled(); +}); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/background_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/background_session_indicator.tsx new file mode 100644 index 0000000000000..b55bd6b655371 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/background_session_indicator.tsx @@ -0,0 +1,286 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiButtonEmpty, + EuiButtonEmptyProps, + EuiButtonIcon, + EuiButtonIconProps, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPopover, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { BackgroundSessionViewState } from '../connected_background_session_indicator'; +import './background_session_indicator.scss'; + +export interface BackgroundSessionIndicatorProps { + state: BackgroundSessionViewState; + onContinueInBackground?: () => void; + onCancel?: () => void; + onViewBackgroundSessions?: () => void; + onSaveResults?: () => void; + onRefresh?: () => void; +} + +type ActionButtonProps = BackgroundSessionIndicatorProps & { buttonProps: EuiButtonEmptyProps }; + +const CancelButton = ({ onCancel = () => {}, buttonProps = {} }: ActionButtonProps) => ( + + + +); + +const ContinueInBackgroundButton = ({ + onContinueInBackground = () => {}, + buttonProps = {}, +}: ActionButtonProps) => ( + + + +); + +const ViewBackgroundSessionsButton = ({ + onViewBackgroundSessions = () => {}, + buttonProps = {}, +}: ActionButtonProps) => ( + // TODO: make this a link + + + +); + +const RefreshButton = ({ onRefresh = () => {}, buttonProps = {} }: ActionButtonProps) => ( + + + +); + +const SaveButton = ({ onSaveResults = () => {}, buttonProps = {} }: ActionButtonProps) => ( + + + +); + +const backgroundSessionIndicatorViewStateToProps: { + [state in BackgroundSessionViewState]: { + button: Pick & { tooltipText: string }; + popover: { + text: string; + primaryAction?: React.ComponentType; + secondaryAction?: React.ComponentType; + }; + }; +} = { + [BackgroundSessionViewState.Loading]: { + button: { + color: 'subdued', + iconType: 'clock', + 'aria-label': i18n.translate( + 'xpack.data.backgroundSessionIndicator.loadingResultsIconAriaLabel', + { defaultMessage: 'Loading results' } + ), + tooltipText: i18n.translate( + 'xpack.data.backgroundSessionIndicator.loadingResultsIconTooltipText', + { defaultMessage: 'Loading results' } + ), + }, + popover: { + text: i18n.translate('xpack.data.backgroundSessionIndicator.loadingResultsText', { + defaultMessage: 'Loading', + }), + primaryAction: CancelButton, + secondaryAction: ContinueInBackgroundButton, + }, + }, + [BackgroundSessionViewState.Completed]: { + button: { + color: 'subdued', + iconType: 'checkInCircleFilled', + 'aria-label': i18n.translate( + 'xpack.data.backgroundSessionIndicator.resultsLoadedIconAriaLabel', + { + defaultMessage: 'Results loaded', + } + ), + tooltipText: i18n.translate( + 'xpack.data.backgroundSessionIndicator.resultsLoadedIconTooltipText', + { + defaultMessage: 'Results loaded', + } + ), + }, + popover: { + text: i18n.translate('xpack.data.backgroundSessionIndicator.resultsLoadedText', { + defaultMessage: 'Results loaded', + }), + primaryAction: SaveButton, + secondaryAction: ViewBackgroundSessionsButton, + }, + }, + [BackgroundSessionViewState.BackgroundLoading]: { + button: { + iconType: EuiLoadingSpinner, + 'aria-label': i18n.translate( + 'xpack.data.backgroundSessionIndicator.loadingInTheBackgroundIconAriaLabel', + { + defaultMessage: 'Loading results in the background', + } + ), + tooltipText: i18n.translate( + 'xpack.data.backgroundSessionIndicator.loadingInTheBackgroundIconTooltipText', + { + defaultMessage: 'Loading results in the background', + } + ), + }, + popover: { + text: i18n.translate('xpack.data.backgroundSessionIndicator.loadingInTheBackgroundText', { + defaultMessage: 'Loading in the background', + }), + primaryAction: CancelButton, + secondaryAction: ViewBackgroundSessionsButton, + }, + }, + [BackgroundSessionViewState.BackgroundCompleted]: { + button: { + color: 'success', + iconType: 'checkInCircleFilled', + 'aria-label': i18n.translate( + 'xpack.data.backgroundSessionIndicator.resultLoadedInTheBackgroundIconAraText', + { + defaultMessage: 'Results loaded in the background', + } + ), + tooltipText: i18n.translate( + 'xpack.data.backgroundSessionIndicator.resultLoadedInTheBackgroundIconTooltipText', + { + defaultMessage: 'Results loaded in the background', + } + ), + }, + popover: { + text: i18n.translate( + 'xpack.data.backgroundSessionIndicator.resultLoadedInTheBackgroundText', + { + defaultMessage: 'Results loaded', + } + ), + primaryAction: ViewBackgroundSessionsButton, + }, + }, + [BackgroundSessionViewState.Restored]: { + button: { + color: 'warning', + iconType: 'refresh', + 'aria-label': i18n.translate( + 'xpack.data.backgroundSessionIndicator.restoredResultsIconAriaLabel', + { + defaultMessage: 'Results no longer current', + } + ), + tooltipText: i18n.translate( + 'xpack.data.backgroundSessionIndicator.restoredResultsTooltipText', + { + defaultMessage: 'Results no longer current', + } + ), + }, + popover: { + text: i18n.translate('xpack.data.backgroundSessionIndicator.restoredText', { + defaultMessage: 'Results no longer current', + }), + primaryAction: RefreshButton, + secondaryAction: ViewBackgroundSessionsButton, + }, + }, +}; + +const VerticalDivider: React.FC = () => ( +
+); + +export const BackgroundSessionIndicator: React.FC = (props) => { + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const onButtonClick = () => setIsPopoverOpen((isOpen) => !isOpen); + const closePopover = () => setIsPopoverOpen(false); + + const { button, popover } = backgroundSessionIndicatorViewStateToProps[props.state]; + + return ( + + + + } + > + + + +

{popover.text}

+
+
+ + + {popover.primaryAction && ( + + + + )} + {popover.primaryAction && popover.secondaryAction && } + {popover.secondaryAction && ( + + + + )} + + +
+
+ ); +}; + +// React.lazy() needs default: +// eslint-disable-next-line import/no-default-export +export default BackgroundSessionIndicator; diff --git a/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/index.tsx b/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/index.tsx new file mode 100644 index 0000000000000..55c8c453dd5d2 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/ui/background_session_indicator/index.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiDelayRender, EuiLoadingSpinner } from '@elastic/eui'; +import React from 'react'; +import type { BackgroundSessionIndicatorProps } from './background_session_indicator'; +export type { BackgroundSessionIndicatorProps }; + +const Fallback = () => ( + + + +); + +const LazyBackgroundSessionIndicator = React.lazy(() => import('./background_session_indicator')); +export const BackgroundSessionIndicator = (props: BackgroundSessionIndicatorProps) => ( + }> + + +); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/background_session_view_state.ts b/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/background_session_view_state.ts new file mode 100644 index 0000000000000..b75c2a536f624 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/background_session_view_state.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export enum BackgroundSessionViewState { + /** + * Pending search request has not been sent to the background yet + */ + Loading = 'loading', + + /** + * No action was taken and the page completed loading without background session creation. + */ + Completed = 'completed', + + /** + * Search request was sent to the background. + * The page is loading in background. + */ + BackgroundLoading = 'backgroundLoading', + + /** + * Page load completed with background session created. + */ + BackgroundCompleted = 'backgroundCompleted', + + /** + * Revisiting the page after background completion + */ + Restored = 'restored', +} diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/connected_background_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/connected_background_session_indicator.test.tsx new file mode 100644 index 0000000000000..b21081e10bbe1 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/connected_background_session_indicator.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { createConnectedBackgroundSessionIndicator } from './connected_background_session_indicator'; +import { BehaviorSubject } from 'rxjs'; +import { ISessionService } from '../../../../../../../src/plugins/data/public'; + +const sessionService = dataPluginMock.createStartContract().search.session as jest.Mocked< + ISessionService +>; + +test("shouldn't show indicator in case no active search session", async () => { + const BackgroundSessionIndicator = createConnectedBackgroundSessionIndicator({ sessionService }); + const { getByTestId, container } = render(); + + // make sure `backgroundSessionIndicator` isn't appearing after some time (lazy-loading) + await expect( + waitFor(() => getByTestId('backgroundSessionIndicator'), { timeout: 100 }) + ).rejects.toThrow(); + expect(container).toMatchInlineSnapshot(`
`); +}); + +test('should show indicator in case there is an active search session', async () => { + const session$ = new BehaviorSubject('session_id'); + sessionService.getSession$.mockImplementation(() => session$); + sessionService.getSessionId.mockImplementation(() => session$.getValue()); + const BackgroundSessionIndicator = createConnectedBackgroundSessionIndicator({ sessionService }); + const { getByTestId } = render(); + + await waitFor(() => getByTestId('backgroundSessionIndicator')); +}); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/connected_background_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/connected_background_session_indicator.tsx new file mode 100644 index 0000000000000..d097a1aecb66a --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/connected_background_session_indicator.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { distinctUntilChanged, map } from 'rxjs/operators'; +import { BackgroundSessionIndicator } from '../background_session_indicator'; +import { ISessionService } from '../../../../../../../src/plugins/data/public/'; +import { BackgroundSessionViewState } from './background_session_view_state'; + +export interface BackgroundSessionIndicatorDeps { + sessionService: ISessionService; +} + +export const createConnectedBackgroundSessionIndicator = ({ + sessionService, +}: BackgroundSessionIndicatorDeps): React.FC => { + const sessionId$ = sessionService.getSession$(); + const hasActiveSession$ = sessionId$.pipe( + map((sessionId) => !!sessionId), + distinctUntilChanged() + ); + + return () => { + const isSession = useObservable(hasActiveSession$, !!sessionService.getSessionId()); + if (!isSession) return null; + return ; + }; +}; diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/index.ts b/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/index.ts new file mode 100644 index 0000000000000..adbb6edbbfcf3 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/index.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + BackgroundSessionIndicatorDeps, + createConnectedBackgroundSessionIndicator, +} from './connected_background_session_indicator'; +export { BackgroundSessionViewState } from './background_session_view_state'; diff --git a/x-pack/plugins/data_enhanced/public/search/ui/index.ts b/x-pack/plugins/data_enhanced/public/search/ui/index.ts new file mode 100644 index 0000000000000..04201325eb5db --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/ui/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './connected_background_session_indicator'; diff --git a/x-pack/plugins/data_enhanced/server/index.ts b/x-pack/plugins/data_enhanced/server/index.ts index a0edd2e26ebef..c3907b3b67439 100644 --- a/x-pack/plugins/data_enhanced/server/index.ts +++ b/x-pack/plugins/data_enhanced/server/index.ts @@ -4,10 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext } from 'kibana/server'; +import { PluginConfigDescriptor, PluginInitializerContext } from 'kibana/server'; import { EnhancedDataServerPlugin } from './plugin'; +import { configSchema, ConfigSchema } from '../config'; -export function plugin(initializerContext: PluginInitializerContext) { +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + search: true, + }, + schema: configSchema, +}; + +export function plugin(initializerContext: PluginInitializerContext) { return new EnhancedDataServerPlugin(initializerContext); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx index b023a9a5a3ec5..b9d9d6306b9ae 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx @@ -32,9 +32,13 @@ export const FilterPopover = ({ const [isPopoverOpen, setIsPopoverOpen] = useState(false); const inputRef = React.useRef(); - const setPopoverOpen = (isOpen: boolean) => { - setIsPopoverOpen(isOpen); - setIsOpenByCreation(isOpen); + const closePopover = () => { + if (isOpenByCreation) { + setIsOpenByCreation(false); + } + if (isPopoverOpen) { + setIsPopoverOpen(false); + } }; const setFilterLabel = (label: string) => setFilter({ ...filter, label }); @@ -57,14 +61,14 @@ export const FilterPopover = ({ panelClassName="lnsIndexPatternDimensionEditor__filtersEditor" isOpen={isOpenByCreation || isPopoverOpen} ownFocus - closePopover={() => { - setPopoverOpen(false); - }} + closePopover={() => closePopover()} button={