From 5bfba1b014db6e6b23f25c36209201b07ca54380 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Fri, 13 Aug 2021 10:00:48 -0400 Subject: [PATCH 01/56] [App Search] Added a SitemapsTable to the Crawler view (#108405) --- .../components/sitemaps_table.test.tsx | 185 ++++++++++++++++++ .../crawler/components/sitemaps_table.tsx | 124 ++++++++++++ .../crawler/crawler_single_domain.tsx | 5 + .../crawler_single_domain_logic.test.ts | 30 +++ .../crawler/crawler_single_domain_logic.ts | 6 +- .../app_search/crawler_sitemaps.test.ts | 134 +++++++++++++ .../routes/app_search/crawler_sitemaps.ts | 77 ++++++++ .../server/routes/app_search/index.ts | 2 + 8 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/sitemaps_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/sitemaps_table.tsx create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/sitemaps_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/sitemaps_table.test.tsx new file mode 100644 index 0000000000000..8d7aa83cd2ec6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/sitemaps_table.test.tsx @@ -0,0 +1,185 @@ +/* + * 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 { mockFlashMessageHelpers, setMockActions } from '../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiFieldText } from '@elastic/eui'; + +import { GenericEndpointInlineEditableTable } from '../../../../shared/tables/generic_endpoint_inline_editable_table'; + +import { mountWithIntl } from '../../../../test_helpers'; + +import { SitemapsTable } from './sitemaps_table'; + +describe('SitemapsTable', () => { + const { clearFlashMessages, flashSuccessToast } = mockFlashMessageHelpers; + const engineName = 'my-engine'; + const sitemaps = [ + { id: '1', url: 'http://www.example.com/sitemap.xml' }, + { id: '2', url: 'http://www.example.com/whatever/sitemaps.xml' }, + ]; + const domain = { + createdOn: '2018-01-01T00:00:00.000Z', + documentCount: 10, + id: '6113e1407a2f2e6f42489794', + url: 'https://www.elastic.co', + crawlRules: [], + entryPoints: [], + sitemaps, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(GenericEndpointInlineEditableTable).exists()).toBe(true); + }); + + describe('the first and only column in the table', () => { + it('shows the url of a sitemap', () => { + const sitemap = { id: '1', url: 'http://www.example.com/sitemap.xml' }; + + const wrapper = shallow( + + ); + + const columns = wrapper.find(GenericEndpointInlineEditableTable).prop('columns'); + const column = shallow(
{columns[0].render(sitemap)}
); + expect(column.html()).toContain('http://www.example.com/sitemap.xml'); + }); + + it('can show the url of a sitemap as editable', () => { + const sitemap = { id: '1', url: 'http://www.example.com/sitemap.xml' }; + const onChange = jest.fn(); + + const wrapper = shallow( + + ); + + const columns = wrapper.find(GenericEndpointInlineEditableTable).prop('columns'); + const column = shallow( +
+ {columns[0].editingRender(sitemap, onChange, { isInvalid: false, isLoading: false })} +
+ ); + + const textField = column.find(EuiFieldText); + expect(textField.props()).toEqual( + expect.objectContaining({ + value: 'http://www.example.com/sitemap.xml', + disabled: false, // It would be disabled if isLoading is true + isInvalid: false, + }) + ); + + textField.simulate('change', { target: { value: '/foo' } }); + expect(onChange).toHaveBeenCalledWith('/foo'); + }); + }); + + describe('routes', () => { + it('can calculate an update and delete route correctly', () => { + const wrapper = shallow( + + ); + + const table = wrapper.find(GenericEndpointInlineEditableTable); + + const sitemap = { id: '1', url: '/whatever' }; + expect(table.prop('deleteRoute')(sitemap)).toEqual( + '/api/app_search/engines/my-engine/crawler/domains/6113e1407a2f2e6f42489794/sitemaps/1' + ); + expect(table.prop('updateRoute')(sitemap)).toEqual( + '/api/app_search/engines/my-engine/crawler/domains/6113e1407a2f2e6f42489794/sitemaps/1' + ); + }); + }); + + it('shows a no items message whem there are no sitemaps to show', () => { + const wrapper = shallow( + + ); + + const editNewItems = jest.fn(); + const table = wrapper.find(GenericEndpointInlineEditableTable); + const message = mountWithIntl(
{table.prop('noItemsMessage')!(editNewItems)}
); + expect(message.find(EuiEmptyPrompt).exists()).toBe(true); + }); + + describe('when a sitemap is added', () => { + it('should update the sitemaps for the current domain, and clear flash messages', () => { + const updateSitemaps = jest.fn(); + setMockActions({ + updateSitemaps, + }); + const wrapper = shallow( + + ); + const table = wrapper.find(GenericEndpointInlineEditableTable); + + const sitemapThatWasAdded = { id: '2', value: 'bar' }; + const updatedSitemaps = [ + { id: '1', value: 'foo' }, + { id: '2', value: 'bar' }, + ]; + table.prop('onAdd')(sitemapThatWasAdded, updatedSitemaps); + expect(updateSitemaps).toHaveBeenCalledWith(updatedSitemaps); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + }); + + describe('when a sitemap is updated', () => { + it('should update the sitemaps for the current domain, and clear flash messages', () => { + const updateSitemaps = jest.fn(); + setMockActions({ + updateSitemaps, + }); + const wrapper = shallow( + + ); + const table = wrapper.find(GenericEndpointInlineEditableTable); + + const sitemapThatWasUpdated = { id: '2', value: 'bar' }; + const updatedSitemaps = [ + { id: '1', value: 'foo' }, + { id: '2', value: 'baz' }, + ]; + table.prop('onUpdate')(sitemapThatWasUpdated, updatedSitemaps); + expect(updateSitemaps).toHaveBeenCalledWith(updatedSitemaps); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + }); + + describe('when a sitemap is deleted', () => { + it('should update the sitemaps for the current domain, clear flash messages, and show a success', () => { + const updateSitemaps = jest.fn(); + setMockActions({ + updateSitemaps, + }); + const wrapper = shallow( + + ); + const table = wrapper.find(GenericEndpointInlineEditableTable); + + const sitemapThatWasDeleted = { id: '2', value: 'bar' }; + const updatedSitemaps = [{ id: '1', value: 'foo' }]; + table.prop('onDelete')(sitemapThatWasDeleted, updatedSitemaps); + expect(updateSitemaps).toHaveBeenCalledWith(updatedSitemaps); + expect(clearFlashMessages).toHaveBeenCalled(); + expect(flashSuccessToast).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/sitemaps_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/sitemaps_table.tsx new file mode 100644 index 0000000000000..eaa1526299fcd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/sitemaps_table.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions } from 'kea'; + +import { EuiButton, EuiEmptyPrompt, EuiFieldText, EuiText } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { clearFlashMessages, flashSuccessToast } from '../../../../shared/flash_messages'; +import { GenericEndpointInlineEditableTable } from '../../../../shared/tables/generic_endpoint_inline_editable_table'; +import { InlineEditableTableColumn } from '../../../../shared/tables/inline_editable_table/types'; +import { ItemWithAnID } from '../../../../shared/tables/types'; +import { CrawlerSingleDomainLogic } from '../crawler_single_domain_logic'; +import { CrawlerDomain, Sitemap } from '../types'; + +const ADD_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.sitemapsTable.addButtonLabel', + { defaultMessage: 'Add sitemap' } +); + +interface SitemapsTableProps { + domain: CrawlerDomain; + engineName: string; + items: Sitemap[]; +} + +export const SitemapsTable: React.FC = ({ domain, engineName, items }) => { + const { updateSitemaps } = useActions(CrawlerSingleDomainLogic); + const field = 'url'; + + const columns: Array> = [ + { + editingRender: (sitemap, onChange, { isInvalid, isLoading }) => ( + onChange(e.target.value)} + disabled={isLoading} + isInvalid={isInvalid} + /> + ), + render: (sitemap) => {(sitemap as Sitemap)[field]}, + name: i18n.translate('xpack.enterpriseSearch.appSearch.crawler.sitemapsTable.urlTableHead', { + defaultMessage: 'URL', + }), + field, + }, + ]; + + const sitemapsRoute = `/api/app_search/engines/${engineName}/crawler/domains/${domain.id}/sitemaps`; + const getSitemapRoute = (sitemap: Sitemap) => + `/api/app_search/engines/${engineName}/crawler/domains/${domain.id}/sitemaps/${sitemap.id}`; + + return ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.crawler.sitemapsTable.description', { + defaultMessage: 'Specify sitemap URLs for the crawler on this domain.', + })} +

+ } + instanceId="SitemapsTable" + items={items} + canRemoveLastItem + noItemsMessage={(editNewItem) => ( + <> + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.sitemapsTable.emptyMessageTitle', + { + defaultMessage: 'There are no existing sitemaps.', + } + )} + + } + titleSize="s" + body={Add a sitemap to specify an entry point for the crawler.} + actions={{ADD_BUTTON_LABEL}} + /> + + )} + addRoute={sitemapsRoute} + deleteRoute={getSitemapRoute} + updateRoute={getSitemapRoute} + dataProperty="sitemaps" + onAdd={(_, newSitemaps) => { + updateSitemaps(newSitemaps as Sitemap[]); + clearFlashMessages(); + }} + onDelete={(_, newSitemaps) => { + updateSitemaps(newSitemaps as Sitemap[]); + clearFlashMessages(); + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.sitemapsTable.deleteSuccessToastMessage', + { + defaultMessage: 'The sitemap has been deleted.', + } + ) + ); + }} + onUpdate={(_, newSitemaps) => { + updateSitemaps(newSitemaps as Sitemap[]); + clearFlashMessages(); + }} + title={i18n.translate('xpack.enterpriseSearch.appSearch.crawler.sitemapsTable.title', { + defaultMessage: 'Sitemaps', + })} + disableReordering + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx index da910ebc30726..464ecbe157c4f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx @@ -23,6 +23,7 @@ import { CrawlerStatusIndicator } from './components/crawler_status_indicator/cr import { DeleteDomainPanel } from './components/delete_domain_panel'; import { EntryPointsTable } from './components/entry_points_table'; import { ManageCrawlsPopover } from './components/manage_crawls_popover/manage_crawls_popover'; +import { SitemapsTable } from './components/sitemaps_table'; import { CRAWLER_TITLE } from './constants'; import { CrawlerSingleDomainLogic } from './crawler_single_domain_logic'; @@ -59,6 +60,10 @@ export const CrawlerSingleDomain: React.FC = () => { + + + + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.test.ts index ead0c0ad91ced..60f3aca7794eb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.test.ts @@ -81,6 +81,36 @@ describe('CrawlerSingleDomainLogic', () => { }); }); }); + + describe('updateSitemaps', () => { + beforeEach(() => { + mount({ + domain: { + id: '507f1f77bcf86cd799439011', + sitemaps: [], + }, + }); + + CrawlerSingleDomainLogic.actions.updateSitemaps([ + { + id: '1234', + url: 'http://www.example.com/sitemap.xml', + }, + ]); + }); + + it('should update the sitemaps on the domain', () => { + expect(CrawlerSingleDomainLogic.values.domain).toEqual({ + id: '507f1f77bcf86cd799439011', + sitemaps: [ + { + id: '1234', + url: 'http://www.example.com/sitemap.xml', + }, + ], + }); + }); + }); }); describe('listeners', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.ts index 780cab45564bb..24830e9d727ca 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.ts @@ -14,7 +14,7 @@ import { KibanaLogic } from '../../../shared/kibana'; import { ENGINE_CRAWLER_PATH } from '../../routes'; import { EngineLogic, generateEnginePath } from '../engine'; -import { CrawlerDomain, EntryPoint } from './types'; +import { CrawlerDomain, EntryPoint, Sitemap } from './types'; import { crawlerDomainServerToClient, getDeleteDomainSuccessMessage } from './utils'; export interface CrawlerSingleDomainValues { @@ -27,6 +27,7 @@ interface CrawlerSingleDomainActions { fetchDomainData(domainId: string): { domainId: string }; onReceiveDomainData(domain: CrawlerDomain): { domain: CrawlerDomain }; updateEntryPoints(entryPoints: EntryPoint[]): { entryPoints: EntryPoint[] }; + updateSitemaps(entryPoints: Sitemap[]): { sitemaps: Sitemap[] }; } export const CrawlerSingleDomainLogic = kea< @@ -38,6 +39,7 @@ export const CrawlerSingleDomainLogic = kea< fetchDomainData: (domainId) => ({ domainId }), onReceiveDomainData: (domain) => ({ domain }), updateEntryPoints: (entryPoints) => ({ entryPoints }), + updateSitemaps: (sitemaps) => ({ sitemaps }), }, reducers: { dataLoading: [ @@ -52,6 +54,8 @@ export const CrawlerSingleDomainLogic = kea< onReceiveDomainData: (_, { domain }) => domain, updateEntryPoints: (currentDomain, { entryPoints }) => ({ ...currentDomain, entryPoints } as CrawlerDomain), + updateSitemaps: (currentDomain, { sitemaps }) => + ({ ...currentDomain, sitemaps } as CrawlerDomain), }, ], }, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.test.ts new file mode 100644 index 0000000000000..21ff58e2d85ef --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { mockDependencies, mockRequestHandler, MockRouter } from '../../__mocks__'; + +import { registerCrawlerSitemapRoutes } from './crawler_sitemaps'; + +describe('crawler sitemap routes', () => { + describe('POST /api/app_search/engines/{engineName}/crawler/domains/{domainId}/sitemaps', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines/{engineName}/crawler/domains/{domainId}/sitemaps', + }); + + registerCrawlerSitemapRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/sitemaps', + params: { + respond_with: 'index', + }, + }); + }); + + it('validates correctly with required params', () => { + const request = { + params: { engineName: 'some-engine', domainId: '1234' }, + body: { + url: 'http://www.example.com/sitemaps.xml', + }, + }; + mockRouter.shouldValidate(request); + }); + + it('fails otherwise', () => { + const request = { params: {}, body: {} }; + mockRouter.shouldThrow(request); + }); + }); + + describe('PUT /api/app_search/engines/{engineName}/crawler/domains/{domainId}/sitemaps/{sitemapId}', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'put', + path: + '/api/app_search/engines/{engineName}/crawler/domains/{domainId}/sitemaps/{sitemapId}', + }); + + registerCrawlerSitemapRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/sitemaps/:sitemapId', + params: { + respond_with: 'index', + }, + }); + }); + + it('validates correctly with required params', () => { + const request = { + params: { engineName: 'some-engine', domainId: '1234', sitemapId: '5678' }, + body: { + url: 'http://www.example.com/sitemaps.xml', + }, + }; + mockRouter.shouldValidate(request); + }); + + it('fails otherwise', () => { + const request = { params: {}, body: {} }; + mockRouter.shouldThrow(request); + }); + }); + + describe('DELETE /api/app_search/engines/{engineName}/crawler/domains/{domainId}/sitemaps/{sitemapId}', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'delete', + path: + '/api/app_search/engines/{engineName}/crawler/domains/{domainId}/sitemaps/{sitemapId}', + }); + + registerCrawlerSitemapRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/sitemaps/:sitemapId', + params: { + respond_with: 'index', + }, + }); + }); + + it('validates correctly with required params', () => { + const request = { + params: { engineName: 'some-engine', domainId: '1234', sitemapId: '5678' }, + }; + mockRouter.shouldValidate(request); + }); + + it('fails otherwise', () => { + const request = { params: {} }; + mockRouter.shouldThrow(request); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.ts new file mode 100644 index 0000000000000..ab4c390243d37 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.ts @@ -0,0 +1,77 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerCrawlerSitemapRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.post( + { + path: '/api/app_search/engines/{engineName}/crawler/domains/{domainId}/sitemaps', + validate: { + params: schema.object({ + engineName: schema.string(), + domainId: schema.string(), + }), + body: schema.object({ + url: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/sitemaps', + params: { + respond_with: 'index', + }, + }) + ); + + router.put( + { + path: '/api/app_search/engines/{engineName}/crawler/domains/{domainId}/sitemaps/{sitemapId}', + validate: { + params: schema.object({ + engineName: schema.string(), + domainId: schema.string(), + sitemapId: schema.string(), + }), + body: schema.object({ + url: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/sitemaps/:sitemapId', + params: { + respond_with: 'index', + }, + }) + ); + + router.delete( + { + path: '/api/app_search/engines/{engineName}/crawler/domains/{domainId}/sitemaps/{sitemapId}', + validate: { + params: schema.object({ + engineName: schema.string(), + domainId: schema.string(), + sitemapId: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/sitemaps/:sitemapId', + params: { + respond_with: 'index', + }, + }) + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index af5cc78f01e78..3f794421348d7 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -11,6 +11,7 @@ import { registerAnalyticsRoutes } from './analytics'; import { registerApiLogsRoutes } from './api_logs'; import { registerCrawlerRoutes } from './crawler'; import { registerCrawlerEntryPointRoutes } from './crawler_entry_points'; +import { registerCrawlerSitemapRoutes } from './crawler_sitemaps'; import { registerCredentialsRoutes } from './credentials'; import { registerCurationsRoutes } from './curations'; import { registerDocumentsRoutes, registerDocumentRoutes } from './documents'; @@ -46,4 +47,5 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerOnboardingRoutes(dependencies); registerCrawlerRoutes(dependencies); registerCrawlerEntryPointRoutes(dependencies); + registerCrawlerSitemapRoutes(dependencies); }; From 212b1898e65d06b4279977017116340cd522462b Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Fri, 13 Aug 2021 10:10:53 -0400 Subject: [PATCH 02/56] Revert "[Enterprise Search] Set up basic scaffolding for Cypress tests in Kibana (#108309)" (#108541) This reverts commit 4d7aa45e149029392f5f724ba99f1ffa5b6188f1. --- src/dev/typescript/projects.ts | 16 ---- x-pack/plugins/enterprise_search/README.md | 83 ------------------- x-pack/plugins/enterprise_search/cypress.sh | 18 ---- .../applications/app_search/cypress.json | 20 ----- .../cypress/integration/engines.spec.ts | 18 ---- .../app_search/cypress/support/commands.ts | 19 ----- .../app_search/cypress/tsconfig.json | 5 -- .../enterprise_search/cypress.json | 21 ----- .../cypress/integration/overview.spec.ts | 42 ---------- .../enterprise_search/cypress/tsconfig.json | 5 -- .../applications/shared/cypress/commands.ts | 35 -------- .../applications/shared/cypress/routes.ts | 10 --- .../applications/shared/cypress/tsconfig.json | 8 -- .../workplace_search/cypress.json | 20 ----- .../cypress/integration/overview.spec.ts | 18 ---- .../cypress/support/commands.ts | 19 ----- .../workplace_search/cypress/tsconfig.json | 5 -- .../plugins/enterprise_search/tsconfig.json | 1 - .../cypress.config.ts | 39 --------- 19 files changed, 402 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/cypress.sh delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/cypress.json delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/cypress/integration/engines.spec.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/cypress/support/commands.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/cypress/tsconfig.json delete mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress.json delete mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress/integration/overview.spec.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress/tsconfig.json delete mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/cypress/commands.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/cypress/routes.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/cypress/tsconfig.json delete mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress.json delete mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress/integration/overview.spec.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress/support/commands.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress/tsconfig.json delete mode 100644 x-pack/test/functional_enterprise_search/cypress.config.ts diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index 80da8bf71727a..419d4f0854ecc 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -40,22 +40,6 @@ export const PROJECTS = [ createProject('x-pack/plugins/security_solution/cypress/tsconfig.json', { name: 'security_solution/cypress', }), - createProject( - 'x-pack/plugins/enterprise_search/public/applications/shared/cypress/tsconfig.json', - { name: 'enterprise_search/cypress' } - ), - createProject( - 'x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress/tsconfig.json', - { name: 'enterprise_search/cypress' } - ), - createProject( - 'x-pack/plugins/enterprise_search/public/applications/app_search/cypress/tsconfig.json', - { name: 'enterprise_search/cypress' } - ), - createProject( - 'x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress/tsconfig.json', - { name: 'enterprise_search/cypress' } - ), createProject('x-pack/plugins/osquery/cypress/tsconfig.json', { name: 'osquery/cypress', }), diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md index 5c8d767de3099..ca8ee68c42a34 100644 --- a/x-pack/plugins/enterprise_search/README.md +++ b/x-pack/plugins/enterprise_search/README.md @@ -66,89 +66,6 @@ sh jest.sh public/applications/shared/flash_messages/flash_messages_logic.test.t ### E2E tests -We currently have two testing libraries in which we run E2E tests: - -- [Cypress](#cypress-tests) - - Will contain the majority of our happy path E2E testing -- [Kibana's Functional Test Runner (FTR)](#kibana-ftr-tests) - - Contains basic tests that only run when the Enterprise Search host is not configured - - It's likely we will not continue to expand these tests, and might even trim some over time (to be replaced by Cypress) - -#### Cypress tests - -Documentation: https://docs.cypress.io/ - -Cypress tests can be run directly from the `x-pack/plugins/enterprise_search` folder. You can use our handy cypress.sh script to run specific product test suites: - -```bash -# Basic syntax -sh cypress.sh {run|open} {suite} - -# Examples -sh cypress.sh run overview # run Enterprise Search overview tests -sh cypress.sh open overview # open Enterprise Search overview tests - -sh cypress.sh run as # run App Search tests -sh cypress.sh open as # open App Search tests - -sh cypress.sh run ws # run Workplace Search tests -sh cypress.sh open ws # open Workplace Search tests - -# Overriding env variables -sh cypress.sh open as --env username=enterprise_search password=123 - -# Overriding config settings, e.g. changing the base URL to a dev path, or enabling video recording -sh cypress.sh open as --config baseUrl=http://localhost:5601/xyz video=true - -# Only run a single specific test file -sh cypress.sh run ws --spec '**/example.spec.ts' - -# Opt to run Chrome headlessly -sh cypress.sh run ws --headless -``` - -There are 3 ways you can spin up the required environments to run our Cypress tests: - -1. Running Cypress against local dev environments: - - Elasticsearch: - - Start a local instance, or use Kibana's `yarn es snapshot` command (with all configurations/versions required to run Enterprise Search locally) - - NOTE: We generally recommend a fresh instance (or blowing away your `data/` folder) to reduce false negatives due to custom user data - - Kibana: - - You **must** have `csp.strict: false` and `csp.warnLegacyBrowsers: false` set in your `kibana.dev.yml`. - - You should either start Kibana with `yarn start --no-base-path` or pass `--config baseUrl=http://localhost:5601/xyz` into your Cypress command. - - Enterprise Search: - - Nothing extra is required to run Cypress tests, only what is already needed to run Kibana/Enterprise Search locally. -2. Running Cypress against Kibana's functional test server: - - :information_source: While we won't use the runner, we can still make use of Kibana's functional test server to help us spin up Elasticsearch and Kibana instances. - - NOTE: We recommend stopping any other local dev processes, to reduce issues with memory/performance - - From the `x-pack/` project folder, run `node scripts/functional_tests_server --config test/functional_enterprise_search/cypress.config.ts` - - Kibana: - - You will need to pass `--config baseUrl=http://localhost:5620` into your Cypress command. - - Enterprise Search: - - :warning: TODO: We _currently_ do not have a way of spinning up Enterprise Search from Kibana's FTR - for now, you can use local Enterprise Search (pointed at the FTR's `http://localhost:9220` Elasticsearch host instance) -3. Running Cypress against Enterprise Search dockerized stack scripts - - :warning: This is for Enterprise Search devs only, as this requires access to our closed source Enterprise Search repo - - `stack_scripts/start-with-es-native-auth.sh --with-kibana` - - Note that the tradeoff of an easier one-command start experience is you will not be able to run Cypress tests against any local changes. - -##### Debugging - -Cypress can either run silently in a headless browser in the command line (`run` or `--headless` mode), which is the default mode used by CI, or opened interactively in an included app and the Chrome browser (`open` or `--headed --no-exit` mode). - -For debugging failures locally, we generally recommend using open mode, which allows you to run a single specific test suite, and makes browser dev tools available to you so you can pause and inspect DOM as needed. - -> :warning: Although this is more extra caution than a hard-and-fast rule, we generally recommend taking a break and not clicking or continuing to use the app while tests are running. This can eliminate or lower the possibility of hard-to-reproduce/intermittently flaky behavior and timeouts due to user interference. - -##### Artifacts - -All failed tests will output a screenshot to the `x-pack/plugins/enterprise_search/target/cypress/screenshots` folder. We strongly recommend starting there for debugging failed tests to inspect error messages and UI state at point of failure. - -To track what Cypress is doing while running tests, you can pass in `--config video=true` which will output screencaptures to a `videos/` folder for all tests (both successful and failing). This can potentially provide more context leading up to the failure point, if a static screenshot isn't providing enough information. - -> :information_source: We have videos turned off in our config to reduce test runtime, especially on CI, but suggest re-enabling it for any deep debugging. - -#### Kibana FTR tests - See [our functional test runner README](../../test/functional_enterprise_search). Our automated accessibility tests can be found in [x-pack/test/accessibility/apps](../../test/accessibility/apps/enterprise_search.ts). diff --git a/x-pack/plugins/enterprise_search/cypress.sh b/x-pack/plugins/enterprise_search/cypress.sh deleted file mode 100644 index 9dbdd81ab788f..0000000000000 --- a/x-pack/plugins/enterprise_search/cypress.sh +++ /dev/null @@ -1,18 +0,0 @@ -#! /bin/bash - -# Use either `cypress run` or `cypress open` - defaults to run -MODE="${1:-run}" - -# Choose which product folder to use, e.g. `yarn cypress open as` -PRODUCT="${2}" -# Provide helpful shorthands -if [ "$PRODUCT" == "as" ]; then PRODUCT='app_search'; fi -if [ "$PRODUCT" == "ws" ]; then PRODUCT='workplace_search'; fi -if [ "$PRODUCT" == "overview" ]; then PRODUCT='enterprise_search'; fi - -# Pass all remaining arguments (e.g., ...rest) from the 3rd arg onwards -# as an open-ended string. Appends onto to the end the Cypress command -# @see https://docs.cypress.io/guides/guides/command-line.html#Options -ARGS="${*:3}" - -../../../node_modules/.bin/cypress "$MODE" --project "public/applications/$PRODUCT" --browser chrome $ARGS diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/cypress.json b/x-pack/plugins/enterprise_search/public/applications/app_search/cypress.json deleted file mode 100644 index 766aaf6df36ad..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/cypress.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "supportFile": "./cypress/support/commands.ts", - "pluginsFile": false, - "retries": { - "runMode": 2 - }, - "baseUrl": "http://localhost:5601", - "env": { - "username": "elastic", - "password": "changeme" - }, - "screenshotsFolder": "../../../target/cypress/screenshots", - "videosFolder": "../../../target/cypress/videos", - "defaultCommandTimeout": 120000, - "execTimeout": 120000, - "pageLoadTimeout": 180000, - "viewportWidth": 1600, - "viewportHeight": 1200, - "video": false -} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/cypress/integration/engines.spec.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/cypress/integration/engines.spec.ts deleted file mode 100644 index 5e651aab075c6..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/cypress/integration/engines.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { login } from '../support/commands'; - -context('Engines', () => { - beforeEach(() => { - login(); - }); - - it('renders', () => { - cy.contains('Engines'); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/cypress/support/commands.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/cypress/support/commands.ts deleted file mode 100644 index 50b5fcd179297..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/cypress/support/commands.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { login as baseLogin } from '../../../shared/cypress/commands'; -import { appSearchPath } from '../../../shared/cypress/routes'; - -interface Login { - path?: string; - username?: string; - password?: string; -} -export const login = ({ path = '/', ...args }: Login = {}) => { - baseLogin({ ...args }); - cy.visit(`${appSearchPath}${path}`); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/cypress/tsconfig.json b/x-pack/plugins/enterprise_search/public/applications/app_search/cypress/tsconfig.json deleted file mode 100644 index ce9df3ca76c09..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/cypress/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "../../shared/cypress/tsconfig.json", - "references": [{ "path": "../../shared/cypress/tsconfig.json" }], - "include": ["./**/*"] -} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress.json b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress.json deleted file mode 100644 index 8ca8bdfd79a49..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "supportFile": false, - "pluginsFile": false, - "retries": { - "runMode": 2 - }, - "baseUrl": "http://localhost:5601", - "env": { - "username": "elastic", - "password": "changeme" - }, - "fixturesFolder": false, - "screenshotsFolder": "../../../target/cypress/screenshots", - "videosFolder": "../../../target/cypress/videos", - "defaultCommandTimeout": 120000, - "execTimeout": 120000, - "pageLoadTimeout": 180000, - "viewportWidth": 1600, - "viewportHeight": 1200, - "video": false -} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress/integration/overview.spec.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress/integration/overview.spec.ts deleted file mode 100644 index 10742ce987b7d..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress/integration/overview.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { login } from '../../../shared/cypress/commands'; -import { overviewPath } from '../../../shared/cypress/routes'; - -context('Enterprise Search Overview', () => { - beforeEach(() => { - login(); - }); - - it('should contain product cards', () => { - cy.visit(overviewPath); - cy.contains('Welcome to Elastic Enterprise Search'); - - cy.get('[data-test-subj="appSearchProductCard"]') - .contains('Launch App Search') - .should('have.attr', 'href') - .and('match', /app_search/); - - cy.get('[data-test-subj="workplaceSearchProductCard"]') - .contains('Launch Workplace Search') - .should('have.attr', 'href') - .and('match', /workplace_search/); - }); - - it('should have a setup guide', () => { - // @see https://github.com/quasarframework/quasar/issues/2233#issuecomment-492975745 - // This only appears to occur for setup guides - I haven't (yet?) run into it on other pages - cy.on('uncaught:exception', (err) => { - if (err.message.includes('> ResizeObserver loop limit exceeded')) return false; - }); - - cy.visit(`${overviewPath}/setup_guide`); - cy.contains('Setup Guide'); - cy.contains('Add your Enterprise Search host URL to your Kibana configuration'); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress/tsconfig.json b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress/tsconfig.json deleted file mode 100644 index ce9df3ca76c09..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/cypress/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "../../shared/cypress/tsconfig.json", - "references": [{ "path": "../../shared/cypress/tsconfig.json" }], - "include": ["./**/*"] -} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/cypress/commands.ts b/x-pack/plugins/enterprise_search/public/applications/shared/cypress/commands.ts deleted file mode 100644 index 5f9738fae5064..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/cypress/commands.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* - * Shared non-product-specific commands - */ - -/* - * Log in a user via XHR - * @see https://docs.cypress.io/guides/getting-started/testing-your-app#Logging-in - */ -interface Login { - username?: string; - password?: string; -} -export const login = ({ - username = Cypress.env('username'), - password = Cypress.env('password'), -}: Login = {}) => { - cy.request({ - method: 'POST', - url: '/internal/security/login', - headers: { 'kbn-xsrf': 'cypress' }, - body: { - providerType: 'basic', - providerName: 'basic', - currentURL: '/', - params: { username, password }, - }, - }); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/cypress/routes.ts b/x-pack/plugins/enterprise_search/public/applications/shared/cypress/routes.ts deleted file mode 100644 index b1a0aaba95661..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/cypress/routes.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const overviewPath = '/app/enterprise_search/overview'; -export const appSearchPath = '/app/enterprise_search/app_search'; -export const workplaceSearchPath = '/app/enterprise_search/workplace_search'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/cypress/tsconfig.json b/x-pack/plugins/enterprise_search/public/applications/shared/cypress/tsconfig.json deleted file mode 100644 index 725a36f893fa9..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/cypress/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../../../../../../tsconfig.base.json", - "exclude": [], - "include": ["./**/*"], - "compilerOptions": { - "types": ["cypress", "node"] - } -} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress.json b/x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress.json deleted file mode 100644 index 766aaf6df36ad..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "supportFile": "./cypress/support/commands.ts", - "pluginsFile": false, - "retries": { - "runMode": 2 - }, - "baseUrl": "http://localhost:5601", - "env": { - "username": "elastic", - "password": "changeme" - }, - "screenshotsFolder": "../../../target/cypress/screenshots", - "videosFolder": "../../../target/cypress/videos", - "defaultCommandTimeout": 120000, - "execTimeout": 120000, - "pageLoadTimeout": 180000, - "viewportWidth": 1600, - "viewportHeight": 1200, - "video": false -} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress/integration/overview.spec.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress/integration/overview.spec.ts deleted file mode 100644 index 8ce6e4ebcfb05..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress/integration/overview.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { login } from '../support/commands'; - -context('Overview', () => { - beforeEach(() => { - login(); - }); - - it('renders', () => { - cy.contains('Workplace Search'); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress/support/commands.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress/support/commands.ts deleted file mode 100644 index d91b73fd78c05..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress/support/commands.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { login as baseLogin } from '../../../shared/cypress/commands'; -import { workplaceSearchPath } from '../../../shared/cypress/routes'; - -interface Login { - path?: string; - username?: string; - password?: string; -} -export const login = ({ path = '/', ...args }: Login = {}) => { - baseLogin({ ...args }); - cy.visit(`${workplaceSearchPath}${path}`); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress/tsconfig.json b/x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress/tsconfig.json deleted file mode 100644 index ce9df3ca76c09..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/cypress/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "../../shared/cypress/tsconfig.json", - "references": [{ "path": "../../shared/cypress/tsconfig.json" }], - "include": ["./**/*"] -} diff --git a/x-pack/plugins/enterprise_search/tsconfig.json b/x-pack/plugins/enterprise_search/tsconfig.json index ce288f8b4b97d..481c4527d5977 100644 --- a/x-pack/plugins/enterprise_search/tsconfig.json +++ b/x-pack/plugins/enterprise_search/tsconfig.json @@ -6,7 +6,6 @@ "declaration": true, "declarationMap": true }, - "exclude": ["public/applications/**/cypress/**/*"], "include": [ "common/**/*", "public/**/*", diff --git a/x-pack/test/functional_enterprise_search/cypress.config.ts b/x-pack/test/functional_enterprise_search/cypress.config.ts deleted file mode 100644 index 9a6918ab0557d..0000000000000 --- a/x-pack/test/functional_enterprise_search/cypress.config.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FtrConfigProviderContext } from '@kbn/test'; - -// TODO: If Kibana CI doesn't end up using this (e.g., uses Dockerized containers -// instead of the functional test server), we can opt to delete this file later. - -export default async function ({ readConfigFile }: FtrConfigProviderContext) { - const baseConfig = await readConfigFile(require.resolve('./base_config')); - - return { - // default to the xpack functional config - ...baseConfig.getAll(), - - esTestCluster: { - ...baseConfig.get('esTestCluster'), - serverArgs: [ - ...baseConfig.get('esTestCluster.serverArgs'), - 'xpack.security.enabled=true', - 'xpack.security.authc.api_key.enabled=true', - ], - }, - - kbnTestServer: { - ...baseConfig.get('kbnTestServer'), - serverArgs: [ - ...baseConfig.get('kbnTestServer.serverArgs'), - '--csp.strict=false', - '--csp.warnLegacyBrowsers=false', - '--enterpriseSearch.host=http://localhost:3002', - ], - }, - }; -} From 7dc24e65d6fc059b0d86014ecc88a7812789dbd6 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 13 Aug 2021 15:24:51 +0100 Subject: [PATCH 03/56] chore(NA): upgrades bazel rules nodejs into v3.8.0 (#108471) --- WORKSPACE.bazel | 6 +++--- package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel index a3c0cd76250e0..384277822709c 100644 --- a/WORKSPACE.bazel +++ b/WORKSPACE.bazel @@ -10,15 +10,15 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") # Fetch Node.js rules http_archive( name = "build_bazel_rules_nodejs", - sha256 = "8f5f192ba02319254aaf2cdcca00ec12eaafeb979a80a1e946773c520ae0a2c9", - urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.7.0/rules_nodejs-3.7.0.tar.gz"], + sha256 = "e79c08a488cc5ac40981987d862c7320cee8741122a2649e9b08e850b6f20442", + urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.8.0/rules_nodejs-3.8.0.tar.gz"], ) # Now that we have the rules let's import from them to complete the work load("@build_bazel_rules_nodejs//:index.bzl", "check_rules_nodejs_version", "node_repositories", "yarn_install") # Assure we have at least a given rules_nodejs version -check_rules_nodejs_version(minimum_version_string = "3.7.0") +check_rules_nodejs_version(minimum_version_string = "3.8.0") # Setup the Node.js toolchain for the architectures we want to support # diff --git a/package.json b/package.json index 70c89c197eeb9..17beadebca91c 100644 --- a/package.json +++ b/package.json @@ -446,7 +446,7 @@ "@babel/traverse": "^7.12.12", "@babel/types": "^7.12.12", "@bazel/ibazel": "^0.15.10", - "@bazel/typescript": "^3.7.0", + "@bazel/typescript": "^3.8.0", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.6.0", "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana", diff --git a/yarn.lock b/yarn.lock index 5ff06955b63cb..39e70709bbf0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1227,10 +1227,10 @@ resolved "https://registry.yarnpkg.com/@bazel/ibazel/-/ibazel-0.15.10.tgz#cf0cff1aec6d8e7bb23e1fc618d09fbd39b7a13f" integrity sha512-0v+OwCQ6fsGFa50r6MXWbUkSGuWOoZ22K4pMSdtWiL5LKFIE4kfmMmtQS+M7/ICNwk2EIYob+NRreyi/DGUz5A== -"@bazel/typescript@^3.7.0": - version "3.7.0" - resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.7.0.tgz#a4d648a36f7ef4960c8a16222f853a4c285a522d" - integrity sha512-bkNHZaCWg4Jk+10wzhFDhB+RRZkfob/yydC4qRzUVxCDLPFICYgC0PWeLhf/ixEhVeHtS0Cmv74M+QziqKSdbw== +"@bazel/typescript@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.8.0.tgz#725d51a1c25e314a1d8cddb8b880ac05ba97acd4" + integrity sha512-4C1pLe4V7aidWqcPsWNqXFS7uHAB1nH5SUKG5uWoVv4JT9XhkNSvzzQIycMwXs2tZeCylX4KYNeNvfKrmkyFlw== dependencies: protobufjs "6.8.8" semver "5.6.0" From 4a1366ca524a195540705289e2e51435ffb023c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Casper=20H=C3=BCbertz?= Date: Fri, 13 Aug 2021 17:01:56 +0200 Subject: [PATCH 04/56] [APM] Change table SparkPlot content properties (#108516) --- .../service_overview_errors_table/get_column.tsx | 1 + .../public/components/shared/charts/spark_plot/index.tsx | 9 +++++++-- .../components/shared/dependencies_table/index.tsx | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/get_column.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/get_column.tsx index cd2c70b0f10b7..ae1fc581f5fdc 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/get_column.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/get_column.tsx @@ -62,6 +62,7 @@ export function getColumns({ return ; }, width: `${unit * 9}px`, + align: 'right', }, { field: 'occurrences', diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx index 6b93fe9605e42..6206965882243 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx @@ -73,13 +73,18 @@ export function SparkPlot({ const chartSize = { height: theme.eui.euiSizeL, - width: compact ? unit * 3 : unit * 4, + width: compact ? unit * 4 : unit * 5, }; const Sparkline = hasComparisonSeries ? LineSeries : AreaSeries; return ( - + {hasValidTimeseries(series) ? ( diff --git a/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx index 0b8fb787c8d8a..9c5c6368f5758 100644 --- a/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx @@ -82,6 +82,7 @@ export function DependenciesTable(props: Props) { render: (_, { currentStats, previousStats }) => { return ( Date: Fri, 13 Aug 2021 17:52:04 +0200 Subject: [PATCH 05/56] [ML] APM Latency Correlations: Improve log log chart loading behavior and axis ticks. (#108211) - Makes use of ChartContainer to improve the loading behavior of the log log chart to include a loading indicator. - Improves y axis ticks for the log log chart. Will set the max y domain to the next rounded value with one more digit, for example, if the max y value is 4567, the y domain will be extended to 10000 and 10000 being the top tick. This makes sure we'll always have a top tick, fixes a bug where with low number <10 we'd end up with just a low 1 tick. - Improves x axis ticks to support different time units. --- .../apm/common/utils/formatters/duration.ts | 13 +- .../app/correlations/correlations_chart.tsx | 186 +++++++++--------- .../correlations/ml_latency_correlations.tsx | 46 ++--- 3 files changed, 126 insertions(+), 119 deletions(-) diff --git a/x-pack/plugins/apm/common/utils/formatters/duration.ts b/x-pack/plugins/apm/common/utils/formatters/duration.ts index b060f1aa6e005..917521117af4e 100644 --- a/x-pack/plugins/apm/common/utils/formatters/duration.ts +++ b/x-pack/plugins/apm/common/utils/formatters/duration.ts @@ -35,7 +35,7 @@ export type TimeFormatter = ( type TimeFormatterBuilder = (max: number) => TimeFormatter; -function getUnitLabelAndConvertedValue( +export function getUnitLabelAndConvertedValue( unitKey: DurationTimeUnit, value: number ) { @@ -122,14 +122,17 @@ function convertTo({ export const toMicroseconds = (value: number, timeUnit: TimeUnit) => moment.duration(value, timeUnit).asMilliseconds() * 1000; -function getDurationUnitKey(max: number): DurationTimeUnit { - if (max > toMicroseconds(10, 'hours')) { +export function getDurationUnitKey( + max: number, + threshold = 10 +): DurationTimeUnit { + if (max > toMicroseconds(threshold, 'hours')) { return 'hours'; } - if (max > toMicroseconds(10, 'minutes')) { + if (max > toMicroseconds(threshold, 'minutes')) { return 'minutes'; } - if (max > toMicroseconds(10, 'seconds')) { + if (max > toMicroseconds(threshold, 'seconds')) { return 'seconds'; } if (max > toMicroseconds(1, 'milliseconds')) { diff --git a/x-pack/plugins/apm/public/components/app/correlations/correlations_chart.tsx b/x-pack/plugins/apm/public/components/app/correlations/correlations_chart.tsx index 50d5f28aa5d55..e3ff631ae1a6f 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/correlations_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/correlations_chart.tsx @@ -24,15 +24,20 @@ import { import euiVars from '@elastic/eui/dist/eui_theme_light.json'; -import { EuiSpacer } from '@elastic/eui'; - import { i18n } from '@kbn/i18n'; -import { getDurationFormatter } from '../../../../common/utils/formatters'; +import { + getDurationUnitKey, + getUnitLabelAndConvertedValue, +} from '../../../../common/utils/formatters'; -import { useTheme } from '../../../hooks/use_theme'; import { HistogramItem } from '../../../../common/search_strategies/correlations/types'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { useTheme } from '../../../hooks/use_theme'; + +import { ChartContainer } from '../../shared/charts/chart_container'; + const { euiColorMediumShade } = euiVars; const axisColor = euiColorMediumShade; @@ -70,18 +75,13 @@ const chartTheme: PartialTheme = { }, }; -// Log based axis cannot start a 0. Use a small positive number instead. -const yAxisDomain = { - min: 0.9, -}; - interface CorrelationsChartProps { field?: string; value?: string; histogram?: HistogramItem[]; markerValue: number; markerPercentile: number; - overallHistogram: HistogramItem[]; + overallHistogram?: HistogramItem[]; } const annotationsStyle = { @@ -133,7 +133,6 @@ export function CorrelationsChart({ }: CorrelationsChartProps) { const euiTheme = useTheme(); - if (!Array.isArray(overallHistogram)) return
; const annotationsDataValues: LineAnnotationDatum[] = [ { dataValue: markerValue, @@ -149,9 +148,14 @@ export function CorrelationsChart({ }, ]; - const xMax = Math.max(...overallHistogram.map((d) => d.key)) ?? 0; - - const durationFormatter = getDurationFormatter(xMax); + // This will create y axis ticks for 1, 10, 100, 1000 ... + const yMax = + Math.max(...(overallHistogram ?? []).map((d) => d.doc_count)) ?? 0; + const yTicks = Math.ceil(Math.log10(yMax)); + const yAxisDomain = { + min: 0.9, + max: Math.pow(10, yTicks), + }; const histogram = replaceHistogramDotsWithBars(originalHistogram); @@ -160,82 +164,86 @@ export function CorrelationsChart({ data-test-subj="apmCorrelationsChart" style={{ overflow: 'hidden', textOverflow: 'ellipsis' }} > - 0} + status={ + Array.isArray(overallHistogram) + ? FETCH_STATUS.SUCCESS + : FETCH_STATUS.LOADING + } > - - - durationFormatter(d).formatted} - /> - - d === 0 || Number.isInteger(Math.log10(d)) ? d : '' - } - /> - - {Array.isArray(histogram) && - field !== undefined && - value !== undefined && ( - - )} - - + + + + { + const unit = getDurationUnitKey(d, 1); + const converted = getUnitLabelAndConvertedValue(unit, d); + const convertedValueParts = converted.convertedValue.split('.'); + const convertedValue = + convertedValueParts.length === 2 && + convertedValueParts[1] === '0' + ? convertedValueParts[0] + : converted.convertedValue; + return `${convertedValue}${converted.unitLabel}`; + }} + /> + + + {Array.isArray(histogram) && + field !== undefined && + value !== undefined && ( + + )} + +
); } diff --git a/x-pack/plugins/apm/public/components/app/correlations/ml_latency_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/ml_latency_correlations.tsx index 7b9c8461221d6..bbd6648ccaf6e 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/ml_latency_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/ml_latency_correlations.tsx @@ -349,33 +349,29 @@ export function MlLatencyCorrelations({ onClose }: Props) { )} - {overallHistogram !== undefined ? ( - <> - -

- {i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.chartTitle', - { - defaultMessage: - 'Latency distribution for {name} (Log-Log Plot)', - values: { - name: transactionName ?? serviceName, - }, - } - )} -

-
- + +

+ {i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.chartTitle', + { + defaultMessage: 'Latency distribution for {name} (Log-Log Plot)', + values: { + name: transactionName ?? serviceName, + }, + } + )} +

+
- - - ) : null} + + +
{histograms.length > 0 && selectedHistogram !== undefined && ( From fe3b7d61c8ef108124ce58e8f5e0b840328c8755 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Fri, 13 Aug 2021 17:55:38 +0200 Subject: [PATCH 06/56] [ML] Fix the Job audit messages service (#108526) * [ML] refactor to ts * [ML] fix types --- .../{message_levels.js => message_levels.ts} | 2 +- .../anomaly_detection_jobs/summary_job.ts | 4 +- .../job_audit_messages.d.ts | 33 ----- ...udit_messages.js => job_audit_messages.ts} | 131 ++++++++++++------ 4 files changed, 92 insertions(+), 78 deletions(-) rename x-pack/plugins/ml/common/constants/{message_levels.js => message_levels.ts} (96%) delete mode 100644 x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.d.ts rename x-pack/plugins/ml/server/models/job_audit_messages/{job_audit_messages.js => job_audit_messages.ts} (71%) diff --git a/x-pack/plugins/ml/common/constants/message_levels.js b/x-pack/plugins/ml/common/constants/message_levels.ts similarity index 96% rename from x-pack/plugins/ml/common/constants/message_levels.js rename to x-pack/plugins/ml/common/constants/message_levels.ts index 16269bbf755da..fd6cef75174ae 100644 --- a/x-pack/plugins/ml/common/constants/message_levels.js +++ b/x-pack/plugins/ml/common/constants/message_levels.ts @@ -10,4 +10,4 @@ export const MESSAGE_LEVEL = { INFO: 'info', SUCCESS: 'success', WARNING: 'warning', -}; +} as const; diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts index 28071f88da9d9..37dad58bfbd45 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts @@ -42,10 +42,10 @@ export interface MlSummaryJob { export interface AuditMessage { job_id: string; msgTime: number; - level: string; + level?: string; highestLevel: string; highestLevelText: string; - text: string; + text?: string; cleared?: boolean; } diff --git a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.d.ts b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.d.ts deleted file mode 100644 index d3748163957db..0000000000000 --- a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { IScopedClusterClient } from 'kibana/server'; -import type { MlClient } from '../../lib/ml_client'; -import type { JobSavedObjectService } from '../../saved_objects'; -import { JobMessage } from '../../../common/types/audit_message'; - -export function isClearable(index?: string): boolean; - -export function jobAuditMessagesProvider( - client: IScopedClusterClient, - mlClient: MlClient -): { - getJobAuditMessages: ( - jobSavedObjectService: JobSavedObjectService, - options: { - jobId?: string; - from?: string; - start?: string; - end?: string; - } - ) => { messages: JobMessage[]; notificationIndices: string[] }; - getAuditMessagesSummary: (jobIds?: string[]) => any; - clearJobAuditMessages: ( - jobId: string, - notificationIndices: string[] - ) => { success: boolean; last_cleared: number }; -}; diff --git a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts similarity index 71% rename from x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js rename to x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts index 311df2ac418c0..98ed76319a0f7 100644 --- a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js +++ b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts @@ -5,12 +5,22 @@ * 2.0. */ +import moment from 'moment'; +import type { IScopedClusterClient } from 'kibana/server'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types'; +import type { estypes } from '@elastic/elasticsearch'; import { ML_NOTIFICATION_INDEX_PATTERN } from '../../../common/constants/index_patterns'; import { MESSAGE_LEVEL } from '../../../common/constants/message_levels'; -import moment from 'moment'; +import type { JobSavedObjectService } from '../../saved_objects'; +import type { MlClient } from '../../lib/ml_client'; +import type { JobMessage } from '../../../common/types/audit_message'; +import { AuditMessage } from '../../../common/types/anomaly_detection_jobs'; const SIZE = 1000; -const LEVEL = { system_info: -1, info: 0, warning: 1, error: 2 }; +const LEVEL = { system_info: -1, info: 0, warning: 1, error: 2 } as const; + +type LevelName = keyof typeof LEVEL; +type LevelValue = typeof LEVEL[keyof typeof LEVEL]; // filter to match job_type: 'anomaly_detector' or no job_type field at all // if no job_type field exist, we can assume the message is for an anomaly detector job @@ -36,24 +46,40 @@ const anomalyDetectorTypeFilter = { }, }; -export function isClearable(index) { +export function isClearable(index?: string): boolean { if (typeof index === 'string') { const match = index.match(/\d{6}$/); - return match !== null && match.length && Number(match[match.length - 1]) >= 2; + return match !== null && !!match.length && Number(match[match.length - 1]) >= 2; } return false; } -export function jobAuditMessagesProvider({ asInternalUser }, mlClient) { +export function jobAuditMessagesProvider( + { asInternalUser }: IScopedClusterClient, + mlClient: MlClient +) { // search for audit messages, // jobId is optional. without it, all jobs will be listed. // from is optional and should be a string formatted in ES time units. e.g. 12h, 1d, 7d - async function getJobAuditMessages(jobSavedObjectService, { jobId, from, start, end }) { + async function getJobAuditMessages( + jobSavedObjectService: JobSavedObjectService, + { + jobId, + from, + start, + end, + }: { + jobId?: string; + from?: string; + start?: string; + end?: string; + } + ) { let gte = null; if (jobId !== undefined && from === undefined) { const jobs = await mlClient.getJobs({ job_id: jobId }); - if (jobs.count > 0 && jobs.jobs !== undefined) { - gte = moment(jobs.jobs[0].create_time).valueOf(); + if (jobs.body.count > 0 && jobs.body.jobs !== undefined) { + gte = moment(jobs.body.jobs[0].create_time).valueOf(); } } else if (from !== undefined) { gte = `now-${from}`; @@ -120,7 +146,7 @@ export function jobAuditMessagesProvider({ asInternalUser }, mlClient) { }); } - const { body } = await asInternalUser.search({ + const { body } = await asInternalUser.search({ index: ML_NOTIFICATION_INDEX_PATTERN, ignore_unavailable: true, size: SIZE, @@ -130,21 +156,21 @@ export function jobAuditMessagesProvider({ asInternalUser }, mlClient) { }, }); - let messages = []; - const notificationIndices = []; + let messages: JobMessage[] = []; + const notificationIndices: string[] = []; - if (body.hits.total.value > 0) { - let notificationIndex; + if ((body.hits.total as estypes.SearchTotalHits).value > 0) { + let notificationIndex: string; body.hits.hits.forEach((hit) => { if (notificationIndex !== hit._index && isClearable(hit._index)) { notificationIndices.push(hit._index); notificationIndex = hit._index; } - messages.push(hit._source); + messages.push(hit._source!); }); } - messages = await jobSavedObjectService.filterJobsForSpace( + messages = await jobSavedObjectService.filterJobsForSpace( 'anomaly-detector', messages, 'job_id' @@ -153,13 +179,14 @@ export function jobAuditMessagesProvider({ asInternalUser }, mlClient) { } // search highest, most recent audit messages for all jobs for the last 24hrs. - async function getAuditMessagesSummary(jobIds) { + async function getAuditMessagesSummary(jobIds: string[]): Promise { // TODO This is the current default value of the cluster setting `search.max_buckets`. // This should possibly consider the real settings in a future update. const maxBuckets = 10000; - let levelsPerJobAggSize = maxBuckets; + const levelsPerJobAggSize = + Array.isArray(jobIds) && jobIds.length > 0 ? jobIds.length : maxBuckets; - const query = { + const query: QueryDslQueryContainer = { bool: { filter: [ { @@ -170,6 +197,15 @@ export function jobAuditMessagesProvider({ asInternalUser }, mlClient) { }, }, anomalyDetectorTypeFilter, + ...(Array.isArray(jobIds) && jobIds.length > 0 + ? [ + { + terms: { + job_id: jobIds, + }, + }, + ] + : []), ], must_not: { term: { @@ -179,17 +215,6 @@ export function jobAuditMessagesProvider({ asInternalUser }, mlClient) { }, }; - // If the jobIds arg is supplied, add a query filter - // to only include those jobIds in the aggregations. - if (Array.isArray(jobIds) && jobIds.length > 0) { - query.bool.filter.push({ - terms: { - job_id: jobIds, - }, - }); - levelsPerJobAggSize = jobIds.length; - } - const { body } = await asInternalUser.search({ index: ML_NOTIFICATION_INDEX_PATTERN, ignore_unavailable: true, @@ -232,22 +257,39 @@ export function jobAuditMessagesProvider({ asInternalUser }, mlClient) { }, }); - let messagesPerJob = []; - const jobMessages = []; + interface LevelsPerJob { + key: string; + levels: estypes.AggregationsTermsAggregate<{ + key: LevelName; + latestMessage: estypes.AggregationsTermsAggregate<{ + key: string; + latestMessage: estypes.AggregationsValueAggregate; + }>; + }>; + } + + let messagesPerJob: LevelsPerJob[] = []; + + const jobMessages: AuditMessage[] = []; + + const bodyAgg = body.aggregations as { + levelsPerJob?: estypes.AggregationsTermsAggregate; + }; + if ( - body.hits.total.value > 0 && - body.aggregations && - body.aggregations.levelsPerJob && - body.aggregations.levelsPerJob.buckets && - body.aggregations.levelsPerJob.buckets.length + (body.hits.total as estypes.SearchTotalHits).value > 0 && + bodyAgg && + bodyAgg.levelsPerJob && + bodyAgg.levelsPerJob.buckets && + bodyAgg.levelsPerJob.buckets.length ) { - messagesPerJob = body.aggregations.levelsPerJob.buckets; + messagesPerJob = bodyAgg.levelsPerJob.buckets; } messagesPerJob.forEach((job) => { // ignore system messages (id==='') if (job.key !== '' && job.levels && job.levels.buckets && job.levels.buckets.length) { - let highestLevel = 0; + let highestLevel: LevelValue = 0; let highestLevelText = ''; let msgTime = 0; @@ -268,6 +310,7 @@ export function jobAuditMessagesProvider({ asInternalUser }, mlClient) { // note the time in ms for the highest level // so we can filter them out later if they're earlier than the // job's create time. + if (msg.latestMessage && msg.latestMessage.value_as_string) { const time = moment(msg.latestMessage.value_as_string); msgTime = time.valueOf(); @@ -287,13 +330,17 @@ export function jobAuditMessagesProvider({ asInternalUser }, mlClient) { } } }); + return jobMessages; } const clearedTime = new Date().getTime(); // Sets 'cleared' to true for messages in the last 24hrs and index new message for clear action - async function clearJobAuditMessages(jobId, notificationIndices) { + async function clearJobAuditMessages( + jobId: string, + notificationIndices: string[] + ): Promise<{ success: boolean; last_cleared: number }> { const newClearedMessage = { job_id: jobId, job_type: 'anomaly_detection', @@ -321,7 +368,7 @@ export function jobAuditMessagesProvider({ asInternalUser }, mlClient) { }, }; - const promises = [ + const promises: Array> = [ asInternalUser.updateByQuery({ index: notificationIndices.join(','), ignore_unavailable: true, @@ -349,8 +396,8 @@ export function jobAuditMessagesProvider({ asInternalUser }, mlClient) { return { success: true, last_cleared: clearedTime }; } - function levelToText(level) { - return Object.keys(LEVEL)[Object.values(LEVEL).indexOf(level)]; + function levelToText(level: LevelValue): LevelName { + return (Object.keys(LEVEL) as LevelName[])[Object.values(LEVEL).indexOf(level)]; } return { From dd85150f73d92ce4ad6810f8a099ebce91a6d4c1 Mon Sep 17 00:00:00 2001 From: Sandra G Date: Fri, 13 Aug 2021 12:07:46 -0400 Subject: [PATCH 07/56] [Monitoring] Convert elasticsearch_settings dir to typescript (#108112) * convert elasticsearch_settings dir files to typescript * fix type * change tests to ts --- .../lib/elasticsearch_settings/cluster.js | 39 ---------------- .../{cluster.test.js => cluster.test.ts} | 25 +++++----- .../lib/elasticsearch_settings/cluster.ts | 46 +++++++++++++++++++ ...ind_reason.test.js => find_reason.test.ts} | 29 ++++++------ .../{find_reason.js => find_reason.ts} | 18 ++++---- .../{index.js => index.ts} | 0 .../{nodes.test.js => nodes.test.ts} | 11 +++-- .../{nodes.js => nodes.ts} | 5 +- ...ion_disabled.js => collection_disabled.ts} | 4 +- ...ction_enabled.js => collection_enabled.ts} | 4 +- ...ion_interval.js => collection_interval.ts} | 4 +- x-pack/plugins/monitoring/server/types.ts | 7 +++ 12 files changed, 110 insertions(+), 82 deletions(-) delete mode 100644 x-pack/plugins/monitoring/server/lib/elasticsearch_settings/cluster.js rename x-pack/plugins/monitoring/server/lib/elasticsearch_settings/{cluster.test.js => cluster.test.ts} (85%) create mode 100644 x-pack/plugins/monitoring/server/lib/elasticsearch_settings/cluster.ts rename x-pack/plugins/monitoring/server/lib/elasticsearch_settings/{find_reason.test.js => find_reason.test.ts} (91%) rename x-pack/plugins/monitoring/server/lib/elasticsearch_settings/{find_reason.js => find_reason.ts} (92%) rename x-pack/plugins/monitoring/server/lib/elasticsearch_settings/{index.js => index.ts} (100%) rename x-pack/plugins/monitoring/server/lib/elasticsearch_settings/{nodes.test.js => nodes.test.ts} (92%) rename x-pack/plugins/monitoring/server/lib/elasticsearch_settings/{nodes.js => nodes.ts} (91%) rename x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/{collection_disabled.js => collection_disabled.ts} (87%) rename x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/{collection_enabled.js => collection_enabled.ts} (87%) rename x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/{collection_interval.js => collection_interval.ts} (87%) diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/cluster.js b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/cluster.js deleted file mode 100644 index 1c20634c10220..0000000000000 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/cluster.js +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { get } from 'lodash'; -import { findReason } from './find_reason'; - -export function handleResponse(response, isCloudEnabled) { - const sources = ['persistent', 'transient', 'defaults']; - for (const source of sources) { - const monitoringSettings = get(response[source], 'xpack.monitoring'); - if (monitoringSettings !== undefined) { - const check = findReason( - monitoringSettings, - { - context: `cluster ${source}`, - }, - isCloudEnabled - ); - - if (check.found) { - return check; - } - } - } - - return { found: false }; -} - -export async function checkClusterSettings(req) { - const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin'); - const { cloud } = req.server.newPlatform.setup.plugins; - const isCloudEnabled = !!(cloud && cloud.isCloudEnabled); - const response = await callWithRequest(req, 'cluster.getSettings', { include_defaults: true }); - return handleResponse(response, isCloudEnabled); -} diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/cluster.test.js b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/cluster.test.ts similarity index 85% rename from x-pack/plugins/monitoring/server/lib/elasticsearch_settings/cluster.test.js rename to x-pack/plugins/monitoring/server/lib/elasticsearch_settings/cluster.test.ts index c94b2bd4b0447..966a327e3084c 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/cluster.test.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/cluster.test.ts @@ -5,10 +5,15 @@ * 2.0. */ +import { ClusterGetSettingsResponse } from '@elastic/elasticsearch/api/types'; import { checkClusterSettings } from '.'; +import { LegacyRequest } from '../../types'; describe('Elasticsearch Cluster Settings', () => { - const makeResponse = (property, response = {}) => { + const makeResponse = ( + property: keyof ClusterGetSettingsResponse, + response: any = {} + ): ClusterGetSettingsResponse => { const result = { persistent: {}, transient: {}, @@ -18,8 +23,8 @@ describe('Elasticsearch Cluster Settings', () => { return result; }; - const getReq = (response) => { - return { + const getReq = (response: ClusterGetSettingsResponse) => { + return ({ server: { newPlatform: { setup: { @@ -40,20 +45,14 @@ describe('Elasticsearch Cluster Settings', () => { }, }, }, - }; + } as unknown) as LegacyRequest; }; - it('should return { found: false } given no response from ES', async () => { - const mockReq = getReq(makeResponse('ignore', {})); - const result = await checkClusterSettings(mockReq); - expect(result).toEqual({ found: false }); - }); - it('should find default collection interval reason', async () => { const setting = { xpack: { monitoring: { collection: { interval: -1 } } }, }; - const makeExpected = (source) => ({ + const makeExpected = (source: keyof ClusterGetSettingsResponse) => ({ found: true, reason: { context: `cluster ${source}`, @@ -82,7 +81,7 @@ describe('Elasticsearch Cluster Settings', () => { const setting = { xpack: { monitoring: { exporters: { myCoolExporter: {} } } }, }; - const makeExpected = (source) => ({ + const makeExpected = (source: keyof ClusterGetSettingsResponse) => ({ found: true, reason: { context: `cluster ${source}`, @@ -111,7 +110,7 @@ describe('Elasticsearch Cluster Settings', () => { const setting = { xpack: { monitoring: { enabled: 'false' } }, }; - const makeExpected = (source) => ({ + const makeExpected = (source: keyof ClusterGetSettingsResponse) => ({ found: true, reason: { context: `cluster ${source}`, diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/cluster.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/cluster.ts new file mode 100644 index 0000000000000..4f46f65591d62 --- /dev/null +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/cluster.ts @@ -0,0 +1,46 @@ +/* + * 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 { get } from 'lodash'; +import { ClusterGetSettingsResponse } from '@elastic/elasticsearch/api/types'; +import { findReason } from './find_reason'; +import { ClusterSettingsReasonResponse, LegacyRequest } from '../../types'; + +export function handleResponse( + response: ClusterGetSettingsResponse, + isCloudEnabled: boolean +): ClusterSettingsReasonResponse { + let source: keyof ClusterGetSettingsResponse; + for (source in response) { + if (Object.prototype.hasOwnProperty.call(response, source)) { + const monitoringSettings = get(response[source], 'xpack.monitoring'); + if (monitoringSettings !== undefined) { + const check = findReason( + monitoringSettings, + { + context: `cluster ${source}`, + }, + isCloudEnabled + ); + + if (check.found) { + return check; + } + } + } + } + + return { found: false }; +} + +export async function checkClusterSettings(req: LegacyRequest) { + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin'); + const { cloud } = req.server.newPlatform.setup.plugins; + const isCloudEnabled = !!(cloud && cloud.isCloudEnabled); + const response = await callWithRequest(req, 'cluster.getSettings', { include_defaults: true }); + return handleResponse(response, isCloudEnabled); +} diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/find_reason.test.js b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/find_reason.test.ts similarity index 91% rename from x-pack/plugins/monitoring/server/lib/elasticsearch_settings/find_reason.test.js rename to x-pack/plugins/monitoring/server/lib/elasticsearch_settings/find_reason.test.ts index 6031e01776734..a984a3a220306 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/find_reason.test.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/find_reason.test.ts @@ -19,7 +19,8 @@ describe('Elasticsearch Settings Find Reason for No Data', () => { interval: -1, }, }, - context + context, + false ); expect(result).toEqual({ found: true, @@ -39,7 +40,8 @@ describe('Elasticsearch Settings Find Reason for No Data', () => { enabled: false, }, }, - context + context, + false ); expect(result).toEqual({ found: true, @@ -61,7 +63,8 @@ describe('Elasticsearch Settings Find Reason for No Data', () => { coolExporterToIgnore: {}, }, }, - context + context, + false ); expect(result).toEqual({ found: true, @@ -76,14 +79,14 @@ describe('Elasticsearch Settings Find Reason for No Data', () => { describe('collection interval', () => { it('should not flag collection interval if value is > 0', async () => { - const result = await findReason({ collection: { interval: 1 } }, context); + const result = await findReason({ collection: { interval: 1 } }, context, false); expect(result).toEqual({ found: false }); }); it('should flag collection interval for any invalid value', async () => { let result; - result = await findReason({ collection: { interval: 0 } }, context); + result = await findReason({ collection: { interval: 0 } }, context, false); expect(result).toEqual({ found: true, reason: { @@ -93,7 +96,7 @@ describe('Elasticsearch Settings Find Reason for No Data', () => { }, }); - result = await findReason({ collection: { interval: -10 } }, context); + result = await findReason({ collection: { interval: -10 } }, context, false); expect(result).toEqual({ found: true, reason: { @@ -103,7 +106,7 @@ describe('Elasticsearch Settings Find Reason for No Data', () => { }, }); - result = await findReason({ collection: { interval: null } }, context); + result = await findReason({ collection: { interval: null } }, context, false); expect(result).toEqual({ found: true, reason: { @@ -116,16 +119,16 @@ describe('Elasticsearch Settings Find Reason for No Data', () => { }); it('should not flag enabled if value is true', async () => { - const result = await findReason({ enabled: true }, context); + const result = await findReason({ enabled: true }, context, false); expect(result).toEqual({ found: false }); }); it('should not flag exporters if value is undefined/null', async () => { let result; - result = await findReason({ exporters: undefined }, context); + result = await findReason({ exporters: undefined }, context, false); expect(result).toEqual({ found: false }); - result = await findReason({ exporters: null }, context); + result = await findReason({ exporters: null }, context, false); expect(result).toEqual({ found: false }); }); @@ -151,7 +154,7 @@ describe('Elasticsearch Settings Find Reason for No Data', () => { }, }, }; - const result = await findReason(input, context); + const result = await findReason(input, context, false); expect(result).toEqual({ found: true, reason: { @@ -204,7 +207,7 @@ describe('Elasticsearch Settings Find Reason for No Data', () => { }, }, }; - const result = await findReason(input, context); + const result = await findReason(input, context, false); expect(result).toEqual({ found: true, reason: { @@ -236,7 +239,7 @@ describe('Elasticsearch Settings Find Reason for No Data', () => { }, }, }; - const result = await findReason(input, context); + const result = await findReason(input, context, false); expect(result).toEqual({ found: false }); }); }); diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/find_reason.js b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/find_reason.ts similarity index 92% rename from x-pack/plugins/monitoring/server/lib/elasticsearch_settings/find_reason.js rename to x-pack/plugins/monitoring/server/lib/elasticsearch_settings/find_reason.ts index 6d4469d591c90..2e01856c1ed8f 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/find_reason.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/find_reason.ts @@ -6,17 +6,22 @@ */ import { get } from 'lodash'; +import { ClusterSettingsReasonResponse } from '../../types'; /* * Return true if the settings property is enabled or is using its default state of enabled * Note: this assumes that a 0 corresponds to disabled */ -const isEnabledOrDefault = (property) => { +const isEnabledOrDefault = (property: string) => { return property === undefined || (Boolean(property) && property !== 'false'); }; -export function findReason(settingsSource, context, isCloudEnabled) { - const iterateReasons = () => { +export function findReason( + settingsSource: any, + context: { context: string }, + isCloudEnabled: boolean +) { + const iterateReasons = (): ClusterSettingsReasonResponse => { // PluginEnabled: check for `monitoring.enabled: false` const monitoringEnabled = get(settingsSource, 'enabled'); if (!isEnabledOrDefault(monitoringEnabled)) { @@ -92,9 +97,8 @@ export function findReason(settingsSource, context, isCloudEnabled) { return exporter.type !== 'local' && isEnabledOrDefault(exporter.enabled); }); if (allEnabledRemote.length > 0 && allEnabledLocal.length === 0) { - let ret = {}; if (isCloudEnabled) { - ret = { + return { found: true, reason: { property: 'xpack.monitoring.exporters.cloud_enabled', @@ -102,7 +106,7 @@ export function findReason(settingsSource, context, isCloudEnabled) { }, }; } else { - ret = { + return { found: true, reason: { property: 'xpack.monitoring.exporters', @@ -112,11 +116,9 @@ export function findReason(settingsSource, context, isCloudEnabled) { }, }; } - return ret; } } } - return { found: false }; }; diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/index.js b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/index.ts similarity index 100% rename from x-pack/plugins/monitoring/server/lib/elasticsearch_settings/index.js rename to x-pack/plugins/monitoring/server/lib/elasticsearch_settings/index.ts diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/nodes.test.js b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/nodes.test.ts similarity index 92% rename from x-pack/plugins/monitoring/server/lib/elasticsearch_settings/nodes.test.js rename to x-pack/plugins/monitoring/server/lib/elasticsearch_settings/nodes.test.ts index cc9707d2edf8b..7e1b93e50f5aa 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/nodes.test.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/nodes.test.ts @@ -6,10 +6,11 @@ */ import { checkNodesSettings } from '.'; +import { LegacyRequest } from '../../types'; describe('Elasticsearch Nodes Settings', () => { - const getReq = (response) => { - return { + const getReq = (response?: any) => { + return ({ server: { newPlatform: { setup: { @@ -23,12 +24,14 @@ describe('Elasticsearch Nodes Settings', () => { plugins: { elasticsearch: { getCluster() { - return { callWithRequest: () => Promise.resolve(response) }; + return { + callWithRequest: () => Promise.resolve(response), + }; }, }, }, }, - }; + } as unknown) as LegacyRequest; }; it('should return { found: false } given no response from ES', async () => { diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/nodes.js b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/nodes.ts similarity index 91% rename from x-pack/plugins/monitoring/server/lib/elasticsearch_settings/nodes.js rename to x-pack/plugins/monitoring/server/lib/elasticsearch_settings/nodes.ts index bc01b2578986d..3e428b47d6174 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/nodes.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/nodes.ts @@ -6,9 +6,10 @@ */ import { get } from 'lodash'; +import { LegacyRequest } from '../../types'; import { findReason } from './find_reason'; -export function handleResponse({ nodes = {} } = {}, isCloudEnabled) { +export function handleResponse({ nodes = {} } = {}, isCloudEnabled: boolean) { const nodeIds = Object.keys(nodes); for (const nodeId of nodeIds) { const nodeSettings = get(nodes, [nodeId, 'settings']); @@ -31,7 +32,7 @@ export function handleResponse({ nodes = {} } = {}, isCloudEnabled) { return { found: false }; } -export async function checkNodesSettings(req) { +export async function checkNodesSettings(req: LegacyRequest) { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin'); const { cloud } = req.server.newPlatform.setup.plugins; const isCloudEnabled = !!(cloud && cloud.isCloudEnabled); diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_disabled.js b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_disabled.ts similarity index 87% rename from x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_disabled.js rename to x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_disabled.ts index 049762f903578..44ad2a3634188 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_disabled.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_disabled.ts @@ -5,7 +5,9 @@ * 2.0. */ -export function setCollectionDisabled(req) { +import { LegacyRequest } from '../../../types'; + +export function setCollectionDisabled(req: LegacyRequest) { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin'); const params = { body: { diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_enabled.js b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_enabled.ts similarity index 87% rename from x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_enabled.js rename to x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_enabled.ts index 1208b95b085a6..a09dc6bb46ce9 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_enabled.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_enabled.ts @@ -5,7 +5,9 @@ * 2.0. */ -export function setCollectionEnabled(req) { +import { LegacyRequest } from '../../../types'; + +export function setCollectionEnabled(req: LegacyRequest) { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin'); const params = { body: { diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_interval.js b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_interval.ts similarity index 87% rename from x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_interval.js rename to x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_interval.ts index c6677c7cfe245..873c1106f1aac 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_interval.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch_settings/set/collection_interval.ts @@ -5,7 +5,9 @@ * 2.0. */ -export function setCollectionInterval(req) { +import { LegacyRequest } from '../../../types'; + +export function setCollectionInterval(req: LegacyRequest) { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin'); const params = { body: { diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts index 8120840aba03e..a0590c0f173fe 100644 --- a/x-pack/plugins/monitoring/server/types.ts +++ b/x-pack/plugins/monitoring/server/types.ts @@ -172,3 +172,10 @@ export interface Bucket { export interface Aggregation { buckets: Bucket[]; } +export interface ClusterSettingsReasonResponse { + found: boolean; + reason?: { + property?: string; + data?: string; + }; +} From e33daccca3b76aaa061206c7826991218f6fe941 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Fri, 13 Aug 2021 18:24:00 +0200 Subject: [PATCH 08/56] [Lens] Disable the global timepicker for index pattern without primary timefield and visualizations without timefield (#108052) --- .../lens/public/app_plugin/app.test.tsx | 48 ++++++++++- .../lens/public/app_plugin/lens_top_nav.tsx | 12 ++- .../indexpattern.test.ts | 79 +++++++++++++++++++ .../indexpattern_datasource/indexpattern.tsx | 10 +++ x-pack/plugins/lens/public/mocks.tsx | 5 +- .../time_range_middleware.test.ts | 4 +- x-pack/plugins/lens/public/types.ts | 4 + 7 files changed, 154 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index c26cce3317cf6..a10b0f436010f 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -269,6 +269,52 @@ describe('Lens App', () => { }); }); + describe('TopNavMenu#showDatePicker', () => { + it('shows date picker if any used index pattern isTimeBased', async () => { + const customServices = makeDefaultServices(sessionIdSubject); + customServices.data.indexPatterns.get = jest + .fn() + .mockImplementation((id) => + Promise.resolve({ id, isTimeBased: () => true } as IndexPattern) + ); + const { services } = await mountWith({ services: customServices }); + expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith( + expect.objectContaining({ showDatePicker: true }), + {} + ); + }); + it('shows date picker if active datasource isTimeBased', async () => { + const customServices = makeDefaultServices(sessionIdSubject); + customServices.data.indexPatterns.get = jest + .fn() + .mockImplementation((id) => + Promise.resolve({ id, isTimeBased: () => true } as IndexPattern) + ); + const customProps = makeDefaultProps(); + customProps.datasourceMap.testDatasource.isTimeBased = () => true; + const { services } = await mountWith({ props: customProps, services: customServices }); + expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith( + expect.objectContaining({ showDatePicker: true }), + {} + ); + }); + it('does not show date picker if index pattern nor active datasource is not time based', async () => { + const customServices = makeDefaultServices(sessionIdSubject); + customServices.data.indexPatterns.get = jest + .fn() + .mockImplementation((id) => + Promise.resolve({ id, isTimeBased: () => true } as IndexPattern) + ); + const customProps = makeDefaultProps(); + customProps.datasourceMap.testDatasource.isTimeBased = () => false; + const { services } = await mountWith({ props: customProps, services: customServices }); + expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith( + expect.objectContaining({ showDatePicker: false }), + {} + ); + }); + }); + describe('persistence', () => { it('passes query and indexPatterns to TopNavMenu', async () => { const { instance, lensStore, services } = await mountWith({ preloadedState: {} }); @@ -294,7 +340,7 @@ describe('Lens App', () => { expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith( expect.objectContaining({ query: 'fake query', - indexPatterns: [{ id: 'mockip' }], + indexPatterns: [{ id: 'mockip', isTimeBased: expect.any(Function) }], }), {} ); diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx index f777d053b314b..c4c2a7523e589 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx @@ -166,6 +166,7 @@ export const LensTopNavMenu = ({ activeDatasourceId, datasourceStates, } = useLensSelector((state) => state.lens); + const allLoaded = Object.values(datasourceStates).every(({ isLoading }) => isLoading === false); useEffect(() => { const activeDatasource = @@ -390,7 +391,16 @@ export const LensTopNavMenu = ({ dateRangeTo={to} indicateNoData={indicateNoData} showSearchBar={true} - showDatePicker={true} + showDatePicker={ + indexPatterns.some((ip) => ip.isTimeBased()) || + Boolean( + allLoaded && + activeDatasourceId && + datasourceMap[activeDatasourceId].isTimeBased( + datasourceStates[activeDatasourceId].state + ) + ) + } showQueryBar={true} showFilterBar={true} data-test-subj="lnsApp_topNav" diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index b3176dbcfe409..678644101d5ce 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -1544,4 +1544,83 @@ describe('IndexPattern Data Source', () => { }); }); }); + describe('#isTimeBased', () => { + it('should return true if date histogram exists in any layer', () => { + const state = enrichBaseState({ + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['metric'], + columns: { + metric: { + label: 'Count of records2', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + second: { + indexPatternId: '1', + columnOrder: ['bucket1', 'bucket2', 'metric2'], + columns: { + metric2: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + bucket1: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: '1d', + }, + }, + bucket2: { + label: 'Terms', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'geo.src', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 10, + }, + }, + }, + }, + }, + }); + expect(indexPatternDatasource.isTimeBased(state)).toEqual(true); + }); + it('should return false if date histogram does not exist in any layer', () => { + const state = enrichBaseState({ + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['metric'], + columns: { + metric: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + }, + }); + expect(indexPatternDatasource.isTimeBased(state)).toEqual(false); + }); + }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 3a2d0df88a6cd..618cca418a8e7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -474,6 +474,16 @@ export function getIndexPatternDatasource({ const ids = Object.values(state.layers || {}).map(({ indexPatternId }) => indexPatternId); return ids.filter((id) => !state.indexPatterns[id]); }, + isTimeBased: (state) => { + const { layers } = state; + return ( + Boolean(layers) && + Object.values(layers).some((layer) => { + const buckets = layer.columnOrder.filter((colId) => layer.columns[colId].isBucketed); + return buckets.some((colId) => layer.columns[colId].operationType === 'date_histogram'); + }) + ); + }, }; return indexPatternDatasource; diff --git a/x-pack/plugins/lens/public/mocks.tsx b/x-pack/plugins/lens/public/mocks.tsx index 03f03e2f3826c..d4c058c124639 100644 --- a/x-pack/plugins/lens/public/mocks.tsx +++ b/x-pack/plugins/lens/public/mocks.tsx @@ -147,6 +147,7 @@ export function createMockDatasource(id: string): DatasourceMock { publicAPIMock, getErrorMessages: jest.fn((_state) => undefined), checkIntegrity: jest.fn((_state) => []), + isTimeBased: jest.fn(), }; } @@ -309,9 +310,7 @@ export function mockDataPlugin(sessionIdSubject = new Subject()) { state$: new Observable(), }, indexPatterns: { - get: jest.fn((id) => { - return new Promise((resolve) => resolve({ id })); - }), + get: jest.fn().mockImplementation((id) => Promise.resolve({ id, isTimeBased: () => true })), }, search: createMockSearchService(), nowProvider: { diff --git a/x-pack/plugins/lens/public/state_management/time_range_middleware.test.ts b/x-pack/plugins/lens/public/state_management/time_range_middleware.test.ts index a3a53a6d380ed..8718f79f94782 100644 --- a/x-pack/plugins/lens/public/state_management/time_range_middleware.test.ts +++ b/x-pack/plugins/lens/public/state_management/time_range_middleware.test.ts @@ -117,9 +117,7 @@ function makeDefaultData(): jest.Mocked { state$: new Observable(), }, indexPatterns: { - get: jest.fn((id) => { - return new Promise((resolve) => resolve({ id })); - }), + get: jest.fn().mockImplementation((id) => Promise.resolve({ id, isTimeBased: () => true })), }, search: createMockSearchService(), nowProvider: { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index bf576cb65c688..0a04e4fea932d 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -265,6 +265,10 @@ export interface Datasource { * The frame calls this function to display warnings about visualization */ getWarningMessages?: (state: T, frame: FramePublicAPI) => React.ReactNode[] | undefined; + /** + * Checks if the visualization created is time based, for example date histogram + */ + isTimeBased: (state: T) => boolean; } export interface DatasourceFixAction { From 57e395540d15f9971d4a2331bf733f65c69400f8 Mon Sep 17 00:00:00 2001 From: Luke Elmers Date: Fri, 13 Aug 2021 10:51:38 -0600 Subject: [PATCH 09/56] [docs] Update Maps docs based on 7.14 UI. (#104762) --- docs/maps/images/app_gis_icon.png | Bin 0 -> 557 bytes docs/maps/maps-getting-started.asciidoc | 23 ++++++++++++----------- docs/maps/vector-layer.asciidoc | 2 ++ 3 files changed, 14 insertions(+), 11 deletions(-) create mode 100644 docs/maps/images/app_gis_icon.png diff --git a/docs/maps/images/app_gis_icon.png b/docs/maps/images/app_gis_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5fbc0325841308881a8ee91bda050ab57e59d4bc GIT binary patch literal 557 zcmV+|0@D47P)(^xB>_oNB=7(L0R>P@R7L;)|MvFwt*xzAR#qAs8dq0W{{H?- zN=iULK(Vp0`}_MpKR-)LOa1-*tgNiV!^3iNa#~ti+1c3{85xR-id$P-P*702ySqF* zJo@_ju&}UjaB%nc_e@Mo4-XGbO-=6Z?jRr_#>U3;^Yird^dBD|;^N}n-QCjC(q?96 z(b3VQq@*n^E!^DPqobqc<>e_UDNasK%*@QczrPd|6p4w61_lPt&(Ful$C{d&CnqOK zNlCoCye1|lo12@!z`#;cQna+RuCA^(H#asmHjj^wI5;@`{QR@Cv&qTHxVX4NLPDXT zp~1nyXlQ6GEG$DqL%F%R%gf7+jg3`RRcdN#6B82{bG|SD006;BL_t(|oMT`B0!Aig z7FH%k1}I=-=iubx^ilo^y16`*_-B~>*BHB}{bFi%5MSW8<+S4U6Zz|e?+!C2UYpMk;D z45-;$RoH@oLB`U`+L*zH!I<6FPKJTO-ocT}$=Su##m&jx!@-k*&C6TIN7C2NSJKBn vAQ0^2AZEp2{b0qAAU2qfbVI{Jb-^qE&n6YU-1=1T00000NkvXXu0mjfgs=i; literal 0 HcmV?d00001 diff --git a/docs/maps/maps-getting-started.asciidoc b/docs/maps/maps-getting-started.asciidoc index 97a5fc7ddaef4..548a574293403 100644 --- a/docs/maps/maps-getting-started.asciidoc +++ b/docs/maps/maps-getting-started.asciidoc @@ -34,8 +34,7 @@ refer to <>. . Open the main menu, and then click *Dashboard*. . Click **Create dashboard**. . Set the time range to *Last 7 days*. -. Click **Create panel**. -. Click **Maps**. +. Click the **Create new Maps** icon image:maps/images/app_gis_icon.png[] [float] [[maps-add-choropleth-layer]] @@ -62,14 +61,15 @@ and lighter shades will symbolize countries with less traffic. . Add a Tooltip field: -** Select **ISO 3166-1 alpha-2 code** and **name**. -** Click **Add**. +** **ISO 3166-1 alpha-2 code** is added by default. +** Click **+ Add** to open field select. +** Select **name** and click *Add*. -. In **Layer style**, set: +. In **Layer style**: -** **Fill color: As number** to the grey color ramp -** **Border color** to white -** **Label** to symbol label +** Set **Fill color: As number** to the grey color ramp. +** Set **Border color** to white. +** Under **Label**, change **By value** to **Fixed**. . Click **Save & close**. + @@ -135,9 +135,10 @@ grids with less bytes transferred. ** **Name** to `Total Requests and Bytes` ** **Visibility** to the range [0, 9] ** **Opacity** to 100% -. In **Metrics**, use: -** **Agregation** set to **Count**, and -** **Aggregation** set to **Sum** with **Field** set to **bytes** +. In **Metrics**: +** Set **Agregation** to **Count**. +** Click **Add metric**. +** Set **Aggregation** to **Sum** with **Field** set to **bytes**. . In **Layer style**, change **Symbol size**: ** Set the field select to *sum bytes*. ** Set the min size to 7 and the max size to 25 px. diff --git a/docs/maps/vector-layer.asciidoc b/docs/maps/vector-layer.asciidoc index 5017ecf91dffd..7191197c27dbe 100644 --- a/docs/maps/vector-layer.asciidoc +++ b/docs/maps/vector-layer.asciidoc @@ -14,6 +14,8 @@ To add a vector layer to your map, click *Add layer*, then select one of the fol *Clusters and grids*:: Geospatial data grouped in grids with metrics for each gridded cell. The index must contain at least one field mapped as {ref}/geo-point.html[geo_point] or {ref}/geo-shape.html[geo_shape]. +*Create index*:: Draw shapes on the map and index in Elasticsearch. + *Documents*:: Points, lines, and polyons from Elasticsearch. The index must contain at least one field mapped as {ref}/geo-point.html[geo_point] or {ref}/geo-shape.html[geo_shape]. + From e35be9d87c86a9340033193547a1b2c9a4be1bf7 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 13 Aug 2021 13:05:11 -0400 Subject: [PATCH 10/56] Fix flaky security/spaces tests (#108088) --- .../import/lib/check_origin_conflicts.test.ts | 20 +++++++++----- .../import/lib/check_origin_conflicts.ts | 20 ++++++++++---- .../common/suites/import.ts | 9 ++----- .../common/suites/copy_to_space.ts | 11 +++++--- .../common/suites/delete.ts | 27 +++++-------------- .../security_and_spaces/apis/delete.ts | 3 +-- 6 files changed, 44 insertions(+), 46 deletions(-) diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts index 90693f29836d7..011e5500b8d9c 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts @@ -23,14 +23,20 @@ type SavedObjectType = SavedObject<{ title?: string }>; type CheckOriginConflictsParams = Parameters[0]; /** - * Function to create a realistic-looking import object given a type, ID, and optional originId + * Function to create a realistic-looking import object given a type, ID, optional originId, and optional updated_at */ -const createObject = (type: string, id: string, originId?: string): SavedObjectType => ({ +const createObject = ( + type: string, + id: string, + originId?: string, + updatedAt?: string +): SavedObjectType => ({ type, id, attributes: { title: `Title for ${type}:${id}` }, references: (Symbol() as unknown) as SavedObjectReference[], ...(originId && { originId }), + ...(updatedAt && { updated_at: updatedAt }), }); const MULTI_NS_TYPE = 'multi'; @@ -389,21 +395,21 @@ describe('#checkOriginConflicts', () => { // try to import obj1 and obj2 const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); const obj2 = createObject(MULTI_NS_TYPE, 'id-2', 'originId-foo'); - const objA = createObject(MULTI_NS_TYPE, 'id-A', obj1.id); - const objB = createObject(MULTI_NS_TYPE, 'id-B', obj1.id); + const objA = createObject(MULTI_NS_TYPE, 'id-A', obj1.id, '2017-09-21T18:59:16.270Z'); + const objB = createObject(MULTI_NS_TYPE, 'id-B', obj1.id, '2021-08-10T13:21:44.135Z'); const objC = createObject(MULTI_NS_TYPE, 'id-C', obj2.originId); const objD = createObject(MULTI_NS_TYPE, 'id-D', obj2.originId); const objects = [obj1, obj2]; const params = setupParams({ objects }); mockFindResult(objA, objB); // find for obj1: the result is an inexact match with two destinations - mockFindResult(objC, objD); // find for obj2: the result is an inexact match with two destinations + mockFindResult(objD, objC); // find for obj2: the result is an inexact match with two destinations const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { importIdMap: new Map(), errors: [ - createAmbiguousConflictError(obj1, [objA, objB]), - createAmbiguousConflictError(obj2, [objC, objD]), + createAmbiguousConflictError(obj1, [objB, objA]), // Assert that these have been sorted by updatedAt in descending order + createAmbiguousConflictError(obj2, [objC, objD]), // Assert that these have been sorted by ID in ascending order (since their updatedAt values are the same) ], pendingOverwrites: new Set(), }; diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts index 1952a04ab815c..d689f37f5ad26 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts @@ -58,11 +58,21 @@ const createQuery = (type: string, id: string, rawIdPrefix: string) => const transformObjectsToAmbiguousConflictFields = ( objects: Array> ) => - objects.map(({ id, attributes, updated_at: updatedAt }) => ({ - id, - title: attributes?.title, - updatedAt, - })); + objects + .map(({ id, attributes, updated_at: updatedAt }) => ({ + id, + title: attributes?.title, + updatedAt, + })) + // Sort to ensure that integration tests are not flaky + .sort((a, b) => { + const aUpdatedAt = a.updatedAt ?? ''; + const bUpdatedAt = b.updatedAt ?? ''; + if (aUpdatedAt !== bUpdatedAt) { + return aUpdatedAt < bUpdatedAt ? 1 : -1; // descending + } + return a.id < b.id ? -1 : 1; // ascending + }); const getAmbiguousConflictSourceKey = ({ object }: InexactMatch) => `${object.type}:${object.originId || object.id}`; diff --git a/x-pack/test/saved_object_api_integration/common/suites/import.ts b/x-pack/test/saved_object_api_integration/common/suites/import.ts index 34c53fc577094..3a8876a9dfae7 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/import.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/import.ts @@ -163,16 +163,11 @@ export function importTestSuiteFactory( type: 'conflict', ...(expectedNewId && { destinationId: expectedNewId }), }; - if (fail409Param === 'ambiguous_conflict_1a1b') { - // "ambiguous source" conflict - error = { - type: 'ambiguous_conflict', - destinations: [getConflictDest(`${CID}1`)], - }; - } else if (fail409Param === 'ambiguous_conflict_2c') { + if (fail409Param === 'ambiguous_conflict_2c') { // "ambiguous destination" conflict error = { type: 'ambiguous_conflict', + // response destinations should be sorted by updatedAt in descending order, then ID in ascending order destinations: [getConflictDest(`${CID}2a`), getConflictDest(`${CID}2b`)], }; } diff --git a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts index d187228a83b17..73d1058bef2fc 100644 --- a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts @@ -571,15 +571,18 @@ export function copyToSpaceTestSuiteFactory( expectNewCopyResponse(response, ambiguousConflictId, title); } else { // It doesn't matter if overwrite is enabled or not, the object will not be copied because there are two matches in the destination space - const updatedAt = '2017-09-21T18:59:16.270Z'; const destinations = [ - // response should be sorted by updatedAt in descending order + // response destinations should be sorted by updatedAt in descending order, then ID in ascending order + { + id: 'conflict_2_all', + title: 'A shared saved-object in all spaces', + updatedAt: '2017-09-21T18:59:16.270Z', + }, { id: 'conflict_2_space_2', title: 'A shared saved-object in one space', - updatedAt, + updatedAt: '2017-09-21T18:59:16.270Z', }, - { id: 'conflict_2_all', title: 'A shared saved-object in all spaces', updatedAt }, ]; expect(success).to.eql(false); expect(successCount).to.eql(0); diff --git a/x-pack/test/spaces_api_integration/common/suites/delete.ts b/x-pack/test/spaces_api_integration/common/suites/delete.ts index ccd08fb2d93e9..2e261d3c93bae 100644 --- a/x-pack/test/spaces_api_integration/common/suites/delete.ts +++ b/x-pack/test/spaces_api_integration/common/suites/delete.ts @@ -49,7 +49,10 @@ export function deleteTestSuiteFactory( size: 0, query: { terms: { - type: ['visualization', 'dashboard', 'space', 'config', 'index-pattern'], + type: ['visualization', 'dashboard', 'space', 'index-pattern'], + // TODO: add assertions for config objects -- these assertions were removed because of flaky behavior in #92358, but we should + // consider adding them again at some point, especially if we convert config objects to `namespaceType: 'multiple-isolated'` in + // the future. }, }, aggs: { @@ -80,7 +83,7 @@ export function deleteTestSuiteFactory( const expectedBuckets = [ { key: 'default', - doc_count: 9, + doc_count: 8, countByType: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -97,10 +100,6 @@ export function deleteTestSuiteFactory( key: 'space', doc_count: 2, }, - { - key: 'config', - doc_count: 1, - }, { key: 'index-pattern', doc_count: 1, @@ -109,7 +108,7 @@ export function deleteTestSuiteFactory( }, }, { - doc_count: 7, + doc_count: 6, key: 'space_1', countByType: { doc_count_error_upper_bound: 0, @@ -123,10 +122,6 @@ export function deleteTestSuiteFactory( key: 'dashboard', doc_count: 2, }, - { - key: 'config', - doc_count: 1, - }, { key: 'index-pattern', doc_count: 1, @@ -190,16 +185,6 @@ export function deleteTestSuiteFactory( await esArchiver.load( 'x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces' ); - - // since we want to verify that we only delete the right things - // and can't include a config document with the correct id in the - // archive we read the settings to trigger an automatic upgrade - // in each space - await supertest.get('/api/kibana/settings').auth(user.username, user.password).expect(200); - await supertest - .get('/s/space_1/api/kibana/settings') - .auth(user.username, user.password) - .expect(200); }); afterEach(() => esArchiver.unload( diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/delete.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/delete.ts index b22e24d33c246..2ba0e4d77bfbe 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/delete.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/delete.ts @@ -24,8 +24,7 @@ export default function deleteSpaceTestSuite({ getService }: FtrProviderContext) expectReservedSpaceResult, } = deleteTestSuiteFactory(es, esArchiver, supertestWithoutAuth); - // FLAKY: https://github.com/elastic/kibana/issues/92358 - describe.skip('delete', () => { + describe('delete', () => { [ { spaceId: SPACES.DEFAULT.spaceId, From f3e094c836cd5d8ea22e8c85a9c06db9e8b4cca1 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Fri, 13 Aug 2021 13:08:46 -0400 Subject: [PATCH 11/56] [App Search] Added a CrawlRulesTable to the Crawler view (#108458) --- .../components/crawl_rules_table.test.tsx | 341 ++++++++++++++++++ .../crawler/components/crawl_rules_table.tsx | 203 +++++++++++ .../crawler/crawler_single_domain.tsx | 10 + .../crawler_single_domain_logic.test.ts | 36 +- .../crawler/crawler_single_domain_logic.ts | 6 +- .../app_search/components/crawler/types.ts | 52 +++ .../reorderable_table/reorderable_table.tsx | 31 +- .../server/routes/app_search/crawler.test.ts | 43 +++ .../server/routes/app_search/crawler.ts | 23 ++ .../app_search/crawler_crawl_rules.test.ts | 139 +++++++ .../routes/app_search/crawler_crawl_rules.ts | 84 +++++ .../server/routes/app_search/index.ts | 2 + 12 files changed, 953 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_rules_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_rules_table.tsx create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_rules_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_rules_table.test.tsx new file mode 100644 index 0000000000000..90998a31fa273 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_rules_table.test.tsx @@ -0,0 +1,341 @@ +/* + * 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 { mockFlashMessageHelpers, setMockActions } from '../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiFieldText, EuiSelect } from '@elastic/eui'; + +import { GenericEndpointInlineEditableTable } from '../../../../shared/tables/generic_endpoint_inline_editable_table'; + +import { CrawlerPolicies, CrawlerRules } from '../types'; + +import { CrawlRulesTable } from './crawl_rules_table'; + +describe('CrawlRulesTable', () => { + const { clearFlashMessages, flashSuccessToast } = mockFlashMessageHelpers; + const engineName = 'my-engine'; + const crawlRules = [ + { id: '1', pattern: '*', policy: CrawlerPolicies.allow, rule: CrawlerRules.beginsWith }, + { id: '2', pattern: '*', policy: CrawlerPolicies.deny, rule: CrawlerRules.endsWith }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(GenericEndpointInlineEditableTable).exists()).toBe(true); + }); + + describe('columns', () => { + const crawlRule = { + id: '1', + pattern: '*', + policy: CrawlerPolicies.allow, + rule: CrawlerRules.beginsWith, + }; + let wrapper: ShallowWrapper; + + beforeEach(() => { + wrapper = shallow( + + ); + }); + + const renderColumn = (index: number) => { + const columns = wrapper.find(GenericEndpointInlineEditableTable).prop('columns'); + return shallow(
{columns[index].render(crawlRule)}
); + }; + + const onChange = jest.fn(); + const renderColumnInEditingMode = (index: number) => { + const columns = wrapper.find(GenericEndpointInlineEditableTable).prop('columns'); + return shallow( +
+ {columns[index].editingRender(crawlRule, onChange, { + isInvalid: false, + isLoading: false, + })} +
+ ); + }; + + describe('policy column', () => { + it('shows the policy of a crawl rule', () => { + expect(renderColumn(0).html()).toContain('Allow'); + }); + + it('can show the policy of a crawl rule as editable', () => { + const column = renderColumnInEditingMode(0); + + const selectField = column.find(EuiSelect); + expect(selectField.props()).toEqual( + expect.objectContaining({ + value: 'allow', + disabled: false, + isInvalid: false, + options: [ + { text: 'Allow', value: 'allow' }, + { text: 'Disallow', value: 'deny' }, + ], + }) + ); + + selectField.simulate('change', { target: { value: 'deny' } }); + expect(onChange).toHaveBeenCalledWith('deny'); + }); + }); + + describe('rule column', () => { + it('shows the rule of a crawl rule', () => { + expect(renderColumn(1).html()).toContain('Begins with'); + }); + + it('can show the rule of a crawl rule as editable', () => { + const column = renderColumnInEditingMode(1); + + const selectField = column.find(EuiSelect); + expect(selectField.props()).toEqual( + expect.objectContaining({ + value: 'begins', + disabled: false, + isInvalid: false, + options: [ + { text: 'Begins with', value: 'begins' }, + { text: 'Ends with', value: 'ends' }, + { text: 'Contains', value: 'contains' }, + { text: 'Regex', value: 'regex' }, + ], + }) + ); + + selectField.simulate('change', { target: { value: 'ends' } }); + expect(onChange).toHaveBeenCalledWith('ends'); + }); + }); + + describe('pattern column', () => { + it('shows the pattern of a crawl rule', () => { + expect(renderColumn(2).html()).toContain('*'); + }); + + it('can show the pattern of a crawl rule as editable', () => { + const column = renderColumnInEditingMode(2); + + const field = column.find(EuiFieldText); + expect(field.props()).toEqual( + expect.objectContaining({ + value: '*', + disabled: false, + isInvalid: false, + }) + ); + + field.simulate('change', { target: { value: 'foo' } }); + expect(onChange).toHaveBeenCalledWith('foo'); + }); + }); + }); + + describe('routes', () => { + it('can calculate an update and delete route correctly', () => { + const wrapper = shallow( + + ); + + const table = wrapper.find(GenericEndpointInlineEditableTable); + + const crawlRule = { + id: '1', + pattern: '*', + policy: CrawlerPolicies.allow, + rule: CrawlerRules.beginsWith, + }; + expect(table.prop('deleteRoute')(crawlRule)).toEqual( + '/api/app_search/engines/my-engine/crawler/domains/6113e1407a2f2e6f42489794/crawl_rules/1' + ); + expect(table.prop('updateRoute')(crawlRule)).toEqual( + '/api/app_search/engines/my-engine/crawler/domains/6113e1407a2f2e6f42489794/crawl_rules/1' + ); + }); + }); + + it('shows a custom description if one is provided', () => { + const wrapper = shallow( + + ); + + const table = wrapper.find(GenericEndpointInlineEditableTable); + expect(table.prop('description')).toEqual('I am a description'); + }); + + it('shows a default crawl rule as uneditable if one is provided', () => { + const wrapper = shallow( + + ); + + const table = wrapper.find(GenericEndpointInlineEditableTable); + expect(table.prop('uneditableItems')).toEqual([crawlRules[0]]); + }); + + describe('when a crawl rule is added', () => { + it('should update the crawl rules for the current domain, and clear flash messages', () => { + const updateCrawlRules = jest.fn(); + setMockActions({ + updateCrawlRules, + }); + const wrapper = shallow( + + ); + const table = wrapper.find(GenericEndpointInlineEditableTable); + + const crawlRulesThatWasAdded = { + id: '2', + pattern: '*', + policy: CrawlerPolicies.deny, + rule: CrawlerRules.endsWith, + }; + const updatedCrawlRules = [ + { id: '1', pattern: '*', policy: CrawlerPolicies.allow, rule: CrawlerRules.beginsWith }, + { id: '2', pattern: '*', policy: CrawlerPolicies.deny, rule: CrawlerRules.endsWith }, + ]; + table.prop('onAdd')(crawlRulesThatWasAdded, updatedCrawlRules); + expect(updateCrawlRules).toHaveBeenCalledWith(updatedCrawlRules); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + }); + + describe('when a crawl rule is updated', () => { + it('should update the crawl rules for the current domain, and clear flash messages', () => { + const updateCrawlRules = jest.fn(); + setMockActions({ + updateCrawlRules, + }); + const wrapper = shallow( + + ); + const table = wrapper.find(GenericEndpointInlineEditableTable); + + const crawlRulesThatWasUpdated = { + id: '2', + pattern: '*', + policy: CrawlerPolicies.deny, + rule: CrawlerRules.endsWith, + }; + const updatedCrawlRules = [ + { id: '1', pattern: '*', policy: CrawlerPolicies.allow, rule: CrawlerRules.beginsWith }, + { + id: '2', + pattern: 'newPattern', + policy: CrawlerPolicies.deny, + rule: CrawlerRules.endsWith, + }, + ]; + table.prop('onUpdate')(crawlRulesThatWasUpdated, updatedCrawlRules); + expect(updateCrawlRules).toHaveBeenCalledWith(updatedCrawlRules); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + }); + + describe('when a crawl rule is deleted', () => { + it('should update the crawl rules for the current domain, clear flash messages, and show a success', () => { + const updateCrawlRules = jest.fn(); + setMockActions({ + updateCrawlRules, + }); + const wrapper = shallow( + + ); + const table = wrapper.find(GenericEndpointInlineEditableTable); + + const crawlRulesThatWasDeleted = { + id: '2', + pattern: '*', + policy: CrawlerPolicies.deny, + rule: CrawlerRules.endsWith, + }; + const updatedCrawlRules = [ + { id: '1', pattern: '*', policy: CrawlerPolicies.allow, rule: CrawlerRules.beginsWith }, + ]; + table.prop('onDelete')(crawlRulesThatWasDeleted, updatedCrawlRules); + expect(updateCrawlRules).toHaveBeenCalledWith(updatedCrawlRules); + expect(clearFlashMessages).toHaveBeenCalled(); + expect(flashSuccessToast).toHaveBeenCalled(); + }); + }); + + describe('when a crawl rule is reordered', () => { + it('should update the crawl rules for the current domain and clear flash messages', () => { + const updateCrawlRules = jest.fn(); + setMockActions({ + updateCrawlRules, + }); + const wrapper = shallow( + + ); + const table = wrapper.find(GenericEndpointInlineEditableTable); + + const updatedCrawlRules = [ + { id: '2', pattern: '*', policy: CrawlerPolicies.deny, rule: CrawlerRules.endsWith }, + { id: '1', pattern: '*', policy: CrawlerPolicies.allow, rule: CrawlerRules.beginsWith }, + ]; + table.prop('onReorder')!(updatedCrawlRules); + expect(updateCrawlRules).toHaveBeenCalledWith(updatedCrawlRules); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_rules_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_rules_table.tsx new file mode 100644 index 0000000000000..9af8cb66fdc4f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_rules_table.tsx @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions } from 'kea'; + +import { EuiCode, EuiFieldText, EuiLink, EuiSelect, EuiText } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { clearFlashMessages, flashSuccessToast } from '../../../../shared/flash_messages'; +import { GenericEndpointInlineEditableTable } from '../../../../shared/tables/generic_endpoint_inline_editable_table'; +import { InlineEditableTableColumn } from '../../../../shared/tables/inline_editable_table/types'; +import { ItemWithAnID } from '../../../../shared/tables/types'; +import { DOCS_PREFIX } from '../../../routes'; +import { CrawlerSingleDomainLogic } from '../crawler_single_domain_logic'; +import { + CrawlerPolicies, + CrawlerRules, + CrawlRule, + getReadableCrawlerPolicy, + getReadableCrawlerRule, +} from '../types'; + +interface CrawlRulesTableProps { + description?: React.ReactNode; + domainId: string; + engineName: string; + crawlRules: CrawlRule[]; + defaultCrawlRule?: CrawlRule; +} + +const DEFAULT_DESCRIPTION = ( +

+ + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlRulesTable.descriptionLinkText', + { defaultMessage: 'Learn more about crawl rules' } + )} + + ), + }} + /> +

+); + +export const CrawlRulesTable: React.FC = ({ + description = DEFAULT_DESCRIPTION, + domainId, + engineName, + crawlRules, + defaultCrawlRule, +}) => { + const { updateCrawlRules } = useActions(CrawlerSingleDomainLogic); + + const columns: Array> = [ + { + editingRender: (crawlRule, onChange, { isInvalid, isLoading }) => ( + onChange(e.target.value)} + disabled={isLoading} + isInvalid={isInvalid} + options={[CrawlerPolicies.allow, CrawlerPolicies.deny].map( + (policyOption: CrawlerPolicies) => ({ + text: getReadableCrawlerPolicy(policyOption), + value: policyOption, + }) + )} + /> + ), + render: (crawlRule) => ( + {getReadableCrawlerPolicy((crawlRule as CrawlRule).policy)} + ), + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlRulesTable.policyTableHead', + { + defaultMessage: 'Policy', + } + ), + field: 'policy', + }, + { + editingRender: (crawlRule, onChange, { isInvalid, isLoading }) => ( + onChange(e.target.value)} + disabled={isLoading} + isInvalid={isInvalid} + options={[ + CrawlerRules.beginsWith, + CrawlerRules.endsWith, + CrawlerRules.contains, + CrawlerRules.regex, + ].map((ruleOption: CrawlerRules) => ({ + text: getReadableCrawlerRule(ruleOption), + value: ruleOption, + }))} + /> + ), + render: (crawlRule) => ( + {getReadableCrawlerRule((crawlRule as CrawlRule).rule)} + ), + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlRulesTable.ruleTableHead', + { + defaultMessage: 'Rule', + } + ), + field: 'rule', + }, + { + editingRender: (crawlRule, onChange, { isInvalid, isLoading }) => ( + onChange(e.target.value)} + disabled={isLoading} + isInvalid={isInvalid} + /> + ), + render: (crawlRule) => {(crawlRule as CrawlRule).pattern}, + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlRulesTable.pathPatternTableHead', + { + defaultMessage: 'Path pattern', + } + ), + field: 'pattern', + }, + ]; + + const crawlRulesRoute = `/api/app_search/engines/${engineName}/crawler/domains/${domainId}/crawl_rules`; + const domainRoute = `/api/app_search/engines/${engineName}/crawler/domains/${domainId}`; + const getCrawlRuleRoute = (crawlRule: CrawlRule) => + `/api/app_search/engines/${engineName}/crawler/domains/${domainId}/crawl_rules/${crawlRule.id}`; + + return ( + { + updateCrawlRules(newCrawlRules as CrawlRule[]); + clearFlashMessages(); + }} + onDelete={(_, newCrawlRules) => { + updateCrawlRules(newCrawlRules as CrawlRule[]); + clearFlashMessages(); + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlRulesTable.deleteSuccessToastMessage', + { + defaultMessage: 'The crawl rule has been deleted.', + } + ) + ); + }} + onUpdate={(_, newCrawlRules) => { + updateCrawlRules(newCrawlRules as CrawlRule[]); + clearFlashMessages(); + }} + onReorder={(newCrawlRules) => { + updateCrawlRules(newCrawlRules as CrawlRule[]); + clearFlashMessages(); + }} + title={i18n.translate('xpack.enterpriseSearch.appSearch.crawler.crawlRulesTable.title', { + defaultMessage: 'Crawl rules', + })} + uneditableItems={defaultCrawlRule ? [defaultCrawlRule] : undefined} + canRemoveLastItem + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx index 464ecbe157c4f..b93fb8592cff8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx @@ -18,6 +18,7 @@ import { i18n } from '@kbn/i18n'; import { EngineLogic, getEngineBreadcrumbs } from '../engine'; import { AppSearchPageTemplate } from '../layout'; +import { CrawlRulesTable } from './components/crawl_rules_table'; import { CrawlerStatusBanner } from './components/crawler_status_banner'; import { CrawlerStatusIndicator } from './components/crawler_status_indicator/crawler_status_indicator'; import { DeleteDomainPanel } from './components/delete_domain_panel'; @@ -64,6 +65,15 @@ export const CrawlerSingleDomain: React.FC = () => { + + + + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.test.ts index 60f3aca7794eb..492bd363a5f2d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.test.ts @@ -16,7 +16,7 @@ import '../../__mocks__/engine_logic.mock'; import { nextTick } from '@kbn/test/jest'; import { CrawlerSingleDomainLogic, CrawlerSingleDomainValues } from './crawler_single_domain_logic'; -import { CrawlerDomain } from './types'; +import { CrawlerDomain, CrawlerPolicies, CrawlerRules } from './types'; const DEFAULT_VALUES: CrawlerSingleDomainValues = { dataLoading: true, @@ -111,6 +111,40 @@ describe('CrawlerSingleDomainLogic', () => { }); }); }); + + describe('updateCrawlRules', () => { + beforeEach(() => { + mount({ + domain: { + id: '507f1f77bcf86cd799439011', + crawlRules: [], + }, + }); + + CrawlerSingleDomainLogic.actions.updateCrawlRules([ + { + id: '1234', + policy: CrawlerPolicies.allow, + rule: CrawlerRules.beginsWith, + pattern: 'foo', + }, + ]); + }); + + it('should update the crawl rules on the domain', () => { + expect(CrawlerSingleDomainLogic.values.domain).toEqual({ + id: '507f1f77bcf86cd799439011', + crawlRules: [ + { + id: '1234', + policy: CrawlerPolicies.allow, + rule: CrawlerRules.beginsWith, + pattern: 'foo', + }, + ], + }); + }); + }); }); describe('listeners', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.ts index 24830e9d727ca..78912f736926d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.ts @@ -14,7 +14,7 @@ import { KibanaLogic } from '../../../shared/kibana'; import { ENGINE_CRAWLER_PATH } from '../../routes'; import { EngineLogic, generateEnginePath } from '../engine'; -import { CrawlerDomain, EntryPoint, Sitemap } from './types'; +import { CrawlerDomain, EntryPoint, Sitemap, CrawlRule } from './types'; import { crawlerDomainServerToClient, getDeleteDomainSuccessMessage } from './utils'; export interface CrawlerSingleDomainValues { @@ -26,6 +26,7 @@ interface CrawlerSingleDomainActions { deleteDomain(domain: CrawlerDomain): { domain: CrawlerDomain }; fetchDomainData(domainId: string): { domainId: string }; onReceiveDomainData(domain: CrawlerDomain): { domain: CrawlerDomain }; + updateCrawlRules(crawlRules: CrawlRule[]): { crawlRules: CrawlRule[] }; updateEntryPoints(entryPoints: EntryPoint[]): { entryPoints: EntryPoint[] }; updateSitemaps(entryPoints: Sitemap[]): { sitemaps: Sitemap[] }; } @@ -38,6 +39,7 @@ export const CrawlerSingleDomainLogic = kea< deleteDomain: (domain) => ({ domain }), fetchDomainData: (domainId) => ({ domainId }), onReceiveDomainData: (domain) => ({ domain }), + updateCrawlRules: (crawlRules) => ({ crawlRules }), updateEntryPoints: (entryPoints) => ({ entryPoints }), updateSitemaps: (sitemaps) => ({ sitemaps }), }, @@ -52,6 +54,8 @@ export const CrawlerSingleDomainLogic = kea< null, { onReceiveDomainData: (_, { domain }) => domain, + updateCrawlRules: (currentDomain, { crawlRules }) => + ({ ...currentDomain, crawlRules } as CrawlerDomain), updateEntryPoints: (currentDomain, { entryPoints }) => ({ ...currentDomain, entryPoints } as CrawlerDomain), updateSitemaps: (currentDomain, { sitemaps }) => diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts index 0902499feb4ed..1b46e21dbcb72 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts @@ -12,6 +12,25 @@ export enum CrawlerPolicies { deny = 'deny', } +export const getReadableCrawlerPolicy = (policy: CrawlerPolicies) => { + switch (policy) { + case CrawlerPolicies.allow: + return i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlRulesPolicies.allowLabel', + { + defaultMessage: 'Allow', + } + ); + case CrawlerPolicies.deny: + return i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlRulesPolicies.disallowLabel', + { + defaultMessage: 'Disallow', + } + ); + } +}; + export enum CrawlerRules { beginsWith = 'begins', endsWith = 'ends', @@ -19,6 +38,39 @@ export enum CrawlerRules { regex = 'regex', } +export const getReadableCrawlerRule = (rule: CrawlerRules) => { + switch (rule) { + case CrawlerRules.beginsWith: + return i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlRulesCrawlerRules.beginsWithLabel', + { + defaultMessage: 'Begins with', + } + ); + case CrawlerRules.endsWith: + return i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlRulesCrawlerRules.endsWithLabel', + { + defaultMessage: 'Ends with', + } + ); + case CrawlerRules.contains: + return i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlRulesCrawlerRules.containsLabel', + { + defaultMessage: 'Contains', + } + ); + case CrawlerRules.regex: + return i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlRulesCrawlerRules.regexLabel', + { + defaultMessage: 'Regex', + } + ); + } +}; + export interface CrawlRule { id: string; policy: CrawlerPolicies; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/reorderable_table/reorderable_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/tables/reorderable_table/reorderable_table.tsx index f43b940fbf3e5..4cb12321bdfcf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/tables/reorderable_table/reorderable_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/reorderable_table/reorderable_table.tsx @@ -47,7 +47,7 @@ export const ReorderableTable = ({
: undefined} /> - {items.length === 0 && ( + {items.length === 0 && unreorderableItems.length === 0 && ( {noItemsMessage} @@ -71,20 +71,6 @@ export const ReorderableTable = ({ )} onReorder={onReorder} /> - {unreorderableItems.length > 0 && ( - ( - } - /> - )} - /> - )} )} @@ -101,6 +87,21 @@ export const ReorderableTable = ({ )} /> )} + + {unreorderableItems.length > 0 && ( + ( + } + /> + )} + /> + )}
); }; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts index b0e286077f838..38cae6d5d7f7c 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts @@ -244,6 +244,49 @@ describe('crawler routes', () => { }); }); + describe('PUT /api/app_search/engines/{name}/crawler/domains/{id}', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'put', + path: '/api/app_search/engines/{name}/crawler/domains/{id}', + }); + + registerCrawlerRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:name/crawler/domains/:id', + }); + }); + + it('validates correctly with required params', () => { + const request = { + params: { name: 'some-engine', id: '1234' }, + body: { + crawl_rules: [ + { + order: 1, + id: '5678', + }, + ], + }, + }; + mockRouter.shouldValidate(request); + }); + + it('fails otherwise', () => { + const request = { params: {}, body: {} }; + mockRouter.shouldThrow(request); + }); + }); + describe('GET /api/app_search/engines/{name}/crawler/domains/{id}', () => { let mockRouter: MockRouter; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts index 1ba7885664ff3..79664d45dbbd8 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts @@ -127,6 +127,29 @@ export function registerCrawlerRoutes({ }) ); + router.put( + { + path: '/api/app_search/engines/{name}/crawler/domains/{id}', + validate: { + params: schema.object({ + name: schema.string(), + id: schema.string(), + }), + body: schema.object({ + crawl_rules: schema.arrayOf( + schema.object({ + order: schema.number(), + id: schema.string(), + }) + ), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:name/crawler/domains/:id', + }) + ); + router.post( { path: '/api/app_search/crawler/validate_url', diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.test.ts new file mode 100644 index 0000000000000..ec131c7cd1981 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.test.ts @@ -0,0 +1,139 @@ +/* + * 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 { mockDependencies, mockRequestHandler, MockRouter } from '../../__mocks__'; + +import { registerCrawlerCrawlRulesRoutes } from './crawler_crawl_rules'; + +describe('crawler crawl rules routes', () => { + describe('POST /api/app_search/engines/{engineName}/crawler/domains/{domainId}/crawl_rules', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines/{engineName}/crawler/domains/{domainId}/crawl_rules', + }); + + registerCrawlerCrawlRulesRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/crawl_rules', + params: { + respond_with: 'index', + }, + }); + }); + + it('validates correctly with required params', () => { + const request = { + params: { engineName: 'some-engine', domainId: '1234' }, + body: { + pattern: '*', + policy: 'allow', + rule: 'begins', + }, + }; + mockRouter.shouldValidate(request); + }); + + it('fails otherwise', () => { + const request = { params: {}, body: {} }; + mockRouter.shouldThrow(request); + }); + }); + + describe('PUT /api/app_search/engines/{engineName}/crawler/domains/{domainId}/crawl_rules/{crawlRuleId}', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'put', + path: + '/api/app_search/engines/{engineName}/crawler/domains/{domainId}/crawl_rules/{crawlRuleId}', + }); + + registerCrawlerCrawlRulesRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/crawl_rules/:crawlRuleId', + params: { + respond_with: 'index', + }, + }); + }); + + it('validates correctly with required params', () => { + const request = { + params: { engineName: 'some-engine', domainId: '1234', crawlRuleId: '5678' }, + body: { + order: 1, + pattern: '*', + policy: 'allow', + rule: 'begins', + }, + }; + mockRouter.shouldValidate(request); + }); + + it('fails otherwise', () => { + const request = { params: {}, body: {} }; + mockRouter.shouldThrow(request); + }); + }); + + describe('DELETE /api/app_search/engines/{engineName}/crawler/domains/{domainId}/crawl_rules/{crawlRuleId}', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'delete', + path: + '/api/app_search/engines/{engineName}/crawler/domains/{domainId}/crawl_rules/{crawlRuleId}', + }); + + registerCrawlerCrawlRulesRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/crawl_rules/:crawlRuleId', + params: { + respond_with: 'index', + }, + }); + }); + + it('validates correctly with required params', () => { + const request = { + params: { engineName: 'some-engine', domainId: '1234', crawlRuleId: '5678' }, + }; + mockRouter.shouldValidate(request); + }); + + it('fails otherwise', () => { + const request = { params: {} }; + mockRouter.shouldThrow(request); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.ts new file mode 100644 index 0000000000000..9367ba4492558 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.ts @@ -0,0 +1,84 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerCrawlerCrawlRulesRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.post( + { + path: '/api/app_search/engines/{engineName}/crawler/domains/{domainId}/crawl_rules', + validate: { + params: schema.object({ + engineName: schema.string(), + domainId: schema.string(), + }), + body: schema.object({ + pattern: schema.string(), + policy: schema.string(), + rule: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/crawl_rules', + params: { + respond_with: 'index', + }, + }) + ); + + router.put( + { + path: + '/api/app_search/engines/{engineName}/crawler/domains/{domainId}/crawl_rules/{crawlRuleId}', + validate: { + params: schema.object({ + engineName: schema.string(), + domainId: schema.string(), + crawlRuleId: schema.string(), + }), + body: schema.object({ + order: schema.number(), + pattern: schema.string(), + policy: schema.string(), + rule: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/crawl_rules/:crawlRuleId', + params: { + respond_with: 'index', + }, + }) + ); + + router.delete( + { + path: + '/api/app_search/engines/{engineName}/crawler/domains/{domainId}/crawl_rules/{crawlRuleId}', + validate: { + params: schema.object({ + engineName: schema.string(), + domainId: schema.string(), + crawlRuleId: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/crawl_rules/:crawlRuleId', + params: { + respond_with: 'index', + }, + }) + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index 3f794421348d7..f6979bce0e780 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -10,6 +10,7 @@ import { RouteDependencies } from '../../plugin'; import { registerAnalyticsRoutes } from './analytics'; import { registerApiLogsRoutes } from './api_logs'; import { registerCrawlerRoutes } from './crawler'; +import { registerCrawlerCrawlRulesRoutes } from './crawler_crawl_rules'; import { registerCrawlerEntryPointRoutes } from './crawler_entry_points'; import { registerCrawlerSitemapRoutes } from './crawler_sitemaps'; import { registerCredentialsRoutes } from './credentials'; @@ -47,5 +48,6 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerOnboardingRoutes(dependencies); registerCrawlerRoutes(dependencies); registerCrawlerEntryPointRoutes(dependencies); + registerCrawlerCrawlRulesRoutes(dependencies); registerCrawlerSitemapRoutes(dependencies); }; From 44014c78b6673cfb8dfc9ecbcb7cba085eb985f2 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Fri, 13 Aug 2021 12:08:50 -0500 Subject: [PATCH 12/56] [canvas] Create Custom Elements Service (#107356) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../saved_elements_modal.stories.tsx | 31 ++-- .../saved_elements_modal.component.tsx | 59 +++----- .../saved_elements_modal.ts | 138 ------------------ .../saved_elements_modal.tsx | 110 ++++++++++++++ .../public/lib/custom_element_service.ts | 43 ------ .../public/lib/element_handler_creators.ts | 2 +- .../canvas/public/services/custom_element.ts | 21 +++ .../plugins/canvas/public/services/index.ts | 12 +- .../public/services/kibana/custom_element.ts | 41 ++++++ .../canvas/public/services/kibana/index.ts | 7 +- .../public/services/stubs/custom_element.ts | 21 +++ .../canvas/public/services/stubs/index.ts | 3 + 12 files changed, 246 insertions(+), 242 deletions(-) delete mode 100644 x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.ts create mode 100644 x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.tsx delete mode 100644 x-pack/plugins/canvas/public/lib/custom_element_service.ts create mode 100644 x-pack/plugins/canvas/public/services/custom_element.ts create mode 100644 x-pack/plugins/canvas/public/services/kibana/custom_element.ts create mode 100644 x-pack/plugins/canvas/public/services/stubs/custom_element.ts diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/saved_elements_modal.stories.tsx b/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/saved_elements_modal.stories.tsx index 086a4be140211..50e9ee8ac5a89 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/saved_elements_modal.stories.tsx +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/saved_elements_modal.stories.tsx @@ -17,13 +17,11 @@ storiesOf('components/SavedElementsModal', module) .add('no custom elements', () => ( )) .add( @@ -31,13 +29,11 @@ storiesOf('components/SavedElementsModal', module) (_, props) => ( ), { decorators: [waitFor(getTestCustomElements())] } @@ -47,13 +43,12 @@ storiesOf('components/SavedElementsModal', module) (_, props) => ( ), { decorators: [waitFor(getTestCustomElements())] } diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.component.tsx b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.component.tsx index ee14e89dc4b7d..1e508d2d825a3 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.component.tsx +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.component.tsx @@ -13,7 +13,6 @@ import React, { useEffect, useRef, } from 'react'; -import PropTypes from 'prop-types'; import { EuiModal, EuiModalBody, @@ -81,66 +80,62 @@ const strings = { export interface Props { /** - * Adds the custom element to the workpad + * Element add handler */ - addCustomElement: (customElement: CustomElement) => void; - /** - * Queries ES for custom element saved objects - */ - findCustomElements: () => void; + onAddCustomElement: (customElement: CustomElement) => void; /** * Handler invoked when the modal closes */ onClose: () => void; /** - * Deletes the custom element + * Element delete handler */ - removeCustomElement: (id: string) => void; + onRemoveCustomElement: (id: string) => void; /** - * Saved edits to the custom element + * Element update handler */ - updateCustomElement: (id: string, name: string, description: string, image: string) => void; + onUpdateCustomElement: (id: string, name: string, description: string, image: string) => void; /** * Array of custom elements to display */ customElements: CustomElement[]; /** - * Text used to filter custom elements list + * Element search handler */ - search: string; + onSearch: (search: string) => void; /** - * Setter for search text + * Initial search term */ - setSearch: (search: string) => void; + initialSearch?: string; } export const SavedElementsModal: FunctionComponent = ({ - search, - setSearch, customElements, - addCustomElement, - findCustomElements, + onAddCustomElement, onClose, - removeCustomElement, - updateCustomElement, + onRemoveCustomElement, + onUpdateCustomElement, + onSearch, + initialSearch = '', }) => { const hasLoadedElements = useRef(false); const [elementToDelete, setElementToDelete] = useState(null); const [elementToEdit, setElementToEdit] = useState(null); + const [search, setSearch] = useState(initialSearch); useEffect(() => { if (!hasLoadedElements.current) { hasLoadedElements.current = true; - findCustomElements(); + onSearch(''); } - }, [findCustomElements, hasLoadedElements]); + }, [onSearch, hasLoadedElements]); const showEditModal = (element: CustomElement) => setElementToEdit(element); const hideEditModal = () => setElementToEdit(null); const handleEdit = async (name: string, description: string, image: string) => { if (elementToEdit) { - updateCustomElement(elementToEdit.id, name, description, image); + onUpdateCustomElement(elementToEdit.id, name, description, image); } hideEditModal(); }; @@ -150,7 +145,7 @@ export const SavedElementsModal: FunctionComponent = ({ const handleDelete = async () => { if (elementToDelete) { - removeCustomElement(elementToDelete.id); + onRemoveCustomElement(elementToDelete.id); } hideDeleteModal(); }; @@ -193,7 +188,7 @@ export const SavedElementsModal: FunctionComponent = ({ const sortElements = (elements: CustomElement[]): CustomElement[] => sortBy(elements, 'displayName'); - const onSearch = (e: ChangeEvent) => setSearch(e.target.value); + const onFieldSearch = (e: ChangeEvent) => setSearch(e.target.value); let customElementContent = ( = ({ @@ -235,7 +230,7 @@ export const SavedElementsModal: FunctionComponent = ({ fullWidth value={search} placeholder={strings.getFindElementPlaceholder()} - onChange={onSearch} + onChange={onFieldSearch} /> {customElementContent} @@ -252,11 +247,3 @@ export const SavedElementsModal: FunctionComponent = ({ ); }; - -SavedElementsModal.propTypes = { - addCustomElement: PropTypes.func.isRequired, - findCustomElements: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - removeCustomElement: PropTypes.func.isRequired, - updateCustomElement: PropTypes.func.isRequired, -}; diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.ts b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.ts deleted file mode 100644 index 524c1a48b6cee..0000000000000 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { connect } from 'react-redux'; -import { Dispatch } from 'redux'; -import { compose, withState } from 'recompose'; -import { camelCase } from 'lodash'; -import { cloneSubgraphs } from '../../lib/clone_subgraphs'; -import * as customElementService from '../../lib/custom_element_service'; -import { withServices, WithServicesProps, pluginServices } from '../../services'; -// @ts-expect-error untyped local -import { selectToplevelNodes } from '../../state/actions/transient'; -// @ts-expect-error untyped local -import { insertNodes } from '../../state/actions/elements'; -import { getSelectedPage } from '../../state/selectors/workpad'; -import { trackCanvasUiMetric, METRIC_TYPE } from '../../lib/ui_metric'; -import { - SavedElementsModal as Component, - Props as ComponentProps, -} from './saved_elements_modal.component'; -import { State, PositionedElement, CustomElement } from '../../../types'; - -const customElementAdded = 'elements-custom-added'; - -interface OwnProps { - onClose: () => void; -} - -interface OwnPropsWithState extends OwnProps { - customElements: CustomElement[]; - setCustomElements: (customElements: CustomElement[]) => void; - search: string; - setSearch: (search: string) => void; -} - -interface DispatchProps { - selectToplevelNodes: (nodes: PositionedElement[]) => void; - insertNodes: (selectedNodes: PositionedElement[], pageId: string) => void; -} - -interface StateProps { - pageId: string; -} - -const mapStateToProps = (state: State): StateProps => ({ - pageId: getSelectedPage(state), -}); - -const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ - selectToplevelNodes: (nodes: PositionedElement[]) => - dispatch( - selectToplevelNodes( - nodes - .filter((e: PositionedElement): boolean => !e.position.parent) - .map((e: PositionedElement): string => e.id) - ) - ), - insertNodes: (selectedNodes: PositionedElement[], pageId: string) => - dispatch(insertNodes(selectedNodes, pageId)), -}); - -const mergeProps = ( - stateProps: StateProps, - dispatchProps: DispatchProps, - ownProps: OwnPropsWithState & WithServicesProps -): ComponentProps => { - const notifyService = pluginServices.getServices().notify; - const { pageId } = stateProps; - const { onClose, search, setCustomElements } = ownProps; - - const findCustomElements = async () => { - const { customElements } = await customElementService.find(search); - setCustomElements(customElements); - }; - - return { - ...ownProps, - // add custom element to the page - addCustomElement: (customElement: CustomElement) => { - const { selectedNodes = [] } = JSON.parse(customElement.content) || {}; - const clonedNodes = selectedNodes && cloneSubgraphs(selectedNodes); - if (clonedNodes) { - dispatchProps.insertNodes(clonedNodes, pageId); // first clone and persist the new node(s) - dispatchProps.selectToplevelNodes(clonedNodes); // then select the cloned node(s) - } - onClose(); - trackCanvasUiMetric(METRIC_TYPE.LOADED, customElementAdded); - }, - // custom element search - findCustomElements: async (text?: string) => { - try { - await findCustomElements(); - } catch (err) { - notifyService.error(err, { - title: `Couldn't find custom elements`, - }); - } - }, - // remove custom element - removeCustomElement: async (id: string) => { - try { - await customElementService.remove(id); - await findCustomElements(); - } catch (err) { - notifyService.error(err, { - title: `Couldn't delete custom elements`, - }); - } - }, - // update custom element - updateCustomElement: async (id: string, name: string, description: string, image: string) => { - try { - await customElementService.update(id, { - name: camelCase(name), - displayName: name, - image, - help: description, - }); - await findCustomElements(); - } catch (err) { - notifyService.error(err, { - title: `Couldn't update custom elements`, - }); - } - }, - }; -}; - -export const SavedElementsModal = compose( - withServices, - withState('search', 'setSearch', ''), - withState('customElements', 'setCustomElements', []), - connect(mapStateToProps, mapDispatchToProps, mergeProps) -)(Component); diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.tsx b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.tsx new file mode 100644 index 0000000000000..19e786edfd5fb --- /dev/null +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.tsx @@ -0,0 +1,110 @@ +/* + * 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 } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { camelCase } from 'lodash'; +import { cloneSubgraphs } from '../../lib/clone_subgraphs'; +import { useNotifyService, useCustomElementService } from '../../services'; +// @ts-expect-error untyped local +import { selectToplevelNodes } from '../../state/actions/transient'; +// @ts-expect-error untyped local +import { insertNodes } from '../../state/actions/elements'; +import { getSelectedPage } from '../../state/selectors/workpad'; +import { trackCanvasUiMetric, METRIC_TYPE } from '../../lib/ui_metric'; +import { + SavedElementsModal as Component, + Props as ComponentProps, +} from './saved_elements_modal.component'; +import { PositionedElement, CustomElement } from '../../../types'; + +const customElementAdded = 'elements-custom-added'; + +export type Props = Pick; + +export const SavedElementsModal = ({ onClose }: Props) => { + const notifyService = useNotifyService(); + const customElementService = useCustomElementService(); + const dispatch = useDispatch(); + const pageId = useSelector(getSelectedPage); + const [customElements, setCustomElements] = useState([]); + + const onSearch = async (search = '') => { + try { + const { customElements: foundElements } = await customElementService.find(search); + setCustomElements(foundElements); + } catch (err) { + notifyService.error(err, { + title: `Couldn't find custom elements`, + }); + } + }; + + const onAddCustomElement = (customElement: CustomElement) => { + const { selectedNodes = [] } = JSON.parse(customElement.content) || {}; + const clonedNodes = selectedNodes && cloneSubgraphs(selectedNodes); + + if (clonedNodes) { + dispatch(insertNodes(clonedNodes, pageId)); // first clone and persist the new node(s) + dispatch( + selectToplevelNodes( + clonedNodes + .filter((e: PositionedElement): boolean => !e.position.parent) + .map((e: PositionedElement): string => e.id) + ) + ); // then select the cloned node(s) + } + + onClose(); + trackCanvasUiMetric(METRIC_TYPE.LOADED, customElementAdded); + }; + + const onRemoveCustomElement = async (id: string) => { + try { + await customElementService.remove(id); + await onSearch(); + } catch (err) { + notifyService.error(err, { + title: `Couldn't delete custom elements`, + }); + } + }; + + const onUpdateCustomElement = async ( + id: string, + name: string, + description: string, + image: string + ) => { + try { + await customElementService.update(id, { + name: camelCase(name), + displayName: name, + image, + help: description, + }); + await onSearch(); + } catch (err) { + notifyService.error(err, { + title: `Couldn't update custom elements`, + }); + } + }; + + return ( + + ); +}; diff --git a/x-pack/plugins/canvas/public/lib/custom_element_service.ts b/x-pack/plugins/canvas/public/lib/custom_element_service.ts deleted file mode 100644 index 6da624bb5d3ae..0000000000000 --- a/x-pack/plugins/canvas/public/lib/custom_element_service.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AxiosPromise } from 'axios'; -import { API_ROUTE_CUSTOM_ELEMENT } from '../../common/lib/constants'; -import { fetch } from '../../common/lib/fetch'; -import { CustomElement } from '../../types'; -import { pluginServices } from '../services'; - -const getApiPath = function () { - const basePath = pluginServices.getServices().platform.getBasePath(); - return `${basePath}${API_ROUTE_CUSTOM_ELEMENT}`; -}; - -export const create = (customElement: CustomElement): AxiosPromise => - fetch.post(getApiPath(), customElement); - -export const get = (customElementId: string): Promise => - fetch - .get(`${getApiPath()}/${customElementId}`) - .then(({ data: element }: { data: CustomElement }) => element); - -export const update = (id: string, element: Partial): AxiosPromise => - fetch.put(`${getApiPath()}/${id}`, element); - -export const remove = (id: string): AxiosPromise => fetch.delete(`${getApiPath()}/${id}`); - -export const find = async ( - searchTerm: string -): Promise<{ total: number; customElements: CustomElement[] }> => { - const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0; - - return fetch - .get(`${getApiPath()}/find?name=${validSearchTerm ? searchTerm : ''}&perPage=10000`) - .then( - ({ data: customElements }: { data: { total: number; customElements: CustomElement[] } }) => - customElements - ); -}; diff --git a/x-pack/plugins/canvas/public/lib/element_handler_creators.ts b/x-pack/plugins/canvas/public/lib/element_handler_creators.ts index a46252081e672..62d405e167f1e 100644 --- a/x-pack/plugins/canvas/public/lib/element_handler_creators.ts +++ b/x-pack/plugins/canvas/public/lib/element_handler_creators.ts @@ -9,7 +9,6 @@ import { camelCase } from 'lodash'; import { getClipboardData, setClipboardData } from './clipboard'; import { cloneSubgraphs } from './clone_subgraphs'; import { pluginServices } from '../services'; -import * as customElementService from './custom_element_service'; import { getId } from './get_id'; import { PositionedElement } from '../../types'; import { ELEMENT_NUDGE_OFFSET, ELEMENT_SHIFT_OFFSET } from '../../common/lib/constants'; @@ -71,6 +70,7 @@ export const basicHandlerCreators = { image = '' ): void => { const notifyService = pluginServices.getServices().notify; + const customElementService = pluginServices.getServices().customElement; if (selectedNodes.length) { const content = JSON.stringify({ selectedNodes }); diff --git a/x-pack/plugins/canvas/public/services/custom_element.ts b/x-pack/plugins/canvas/public/services/custom_element.ts new file mode 100644 index 0000000000000..675a5a2f23c01 --- /dev/null +++ b/x-pack/plugins/canvas/public/services/custom_element.ts @@ -0,0 +1,21 @@ +/* + * 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 { CustomElement } from '../../types'; + +export interface CustomElementFindResponse { + total: number; + customElements: CustomElement[]; +} + +export interface CanvasCustomElementService { + create: (customElement: CustomElement) => Promise; + get: (customElementId: string) => Promise; + update: (id: string, element: Partial) => Promise; + remove: (id: string) => Promise; + find: (searchTerm: string) => Promise; +} diff --git a/x-pack/plugins/canvas/public/services/index.ts b/x-pack/plugins/canvas/public/services/index.ts index 07c1c3c253ff8..12afca2c5b8c7 100644 --- a/x-pack/plugins/canvas/public/services/index.ts +++ b/x-pack/plugins/canvas/public/services/index.ts @@ -8,18 +8,20 @@ export * from './legacy'; import { PluginServices } from '../../../../../src/plugins/presentation_util/public'; +import { CanvasEmbeddablesService } from './embeddables'; import { CanvasExpressionsService } from './expressions'; +import { CanvasCustomElementService } from './custom_element'; import { CanvasNavLinkService } from './nav_link'; -import { CanvasEmbeddablesService } from './embeddables'; import { CanvasNotifyService } from './notify'; import { CanvasPlatformService } from './platform'; import { CanvasReportingService } from './reporting'; import { CanvasWorkpadService } from './workpad'; export interface CanvasPluginServices { + customElement: CanvasCustomElementService; + embeddables: CanvasEmbeddablesService; expressions: CanvasExpressionsService; navLink: CanvasNavLinkService; - embeddables: CanvasEmbeddablesService; notify: CanvasNotifyService; platform: CanvasPlatformService; reporting: CanvasReportingService; @@ -28,11 +30,13 @@ export interface CanvasPluginServices { export const pluginServices = new PluginServices(); +export const useEmbeddablesService = () => + (() => pluginServices.getHooks().embeddables.useService())(); +export const useCustomElementService = () => + (() => pluginServices.getHooks().customElement.useService())(); export const useExpressionsService = () => (() => pluginServices.getHooks().expressions.useService())(); export const useNavLinkService = () => (() => pluginServices.getHooks().navLink.useService())(); -export const useEmbeddablesService = () => - (() => pluginServices.getHooks().embeddables.useService())(); export const useNotifyService = () => (() => pluginServices.getHooks().notify.useService())(); export const usePlatformService = () => (() => pluginServices.getHooks().platform.useService())(); export const useReportingService = () => (() => pluginServices.getHooks().reporting.useService())(); diff --git a/x-pack/plugins/canvas/public/services/kibana/custom_element.ts b/x-pack/plugins/canvas/public/services/kibana/custom_element.ts new file mode 100644 index 0000000000000..ec3b68d2d0bba --- /dev/null +++ b/x-pack/plugins/canvas/public/services/kibana/custom_element.ts @@ -0,0 +1,41 @@ +/* + * 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 { KibanaPluginServiceFactory } from '../../../../../../src/plugins/presentation_util/public'; + +import { API_ROUTE_CUSTOM_ELEMENT } from '../../../common/lib/constants'; +import { CustomElement } from '../../../types'; +import { CanvasStartDeps } from '../../plugin'; +import { CanvasCustomElementService } from '../custom_element'; + +export type CanvasCustomElementServiceFactory = KibanaPluginServiceFactory< + CanvasCustomElementService, + CanvasStartDeps +>; + +export const customElementServiceFactory: CanvasCustomElementServiceFactory = ({ coreStart }) => { + const { http } = coreStart; + const apiPath = `${API_ROUTE_CUSTOM_ELEMENT}`; + + return { + create: (customElement) => http.post(apiPath, { body: JSON.stringify(customElement) }), + get: (customElementId) => + http + .get(`${apiPath}/${customElementId}`) + .then(({ data: element }: { data: CustomElement }) => element), + update: (id, element) => http.put(`${apiPath}/${id}`, { body: JSON.stringify(element) }), + remove: (id) => http.delete(`${apiPath}/${id}`), + find: async (name) => { + return http.get(`${apiPath}/find`, { + query: { + name, + perPage: 10000, + }, + }); + }, + }; +}; diff --git a/x-pack/plugins/canvas/public/services/kibana/index.ts b/x-pack/plugins/canvas/public/services/kibana/index.ts index fb760fe4c183d..a756ca7b0d4d1 100644 --- a/x-pack/plugins/canvas/public/services/kibana/index.ts +++ b/x-pack/plugins/canvas/public/services/kibana/index.ts @@ -14,6 +14,7 @@ import { import { CanvasPluginServices } from '..'; import { CanvasStartDeps } from '../../plugin'; +import { customElementServiceFactory } from './custom_element'; import { embeddablesServiceFactory } from './embeddables'; import { expressionsServiceFactory } from './expressions'; import { navLinkServiceFactory } from './nav_link'; @@ -22,8 +23,9 @@ import { platformServiceFactory } from './platform'; import { reportingServiceFactory } from './reporting'; import { workpadServiceFactory } from './workpad'; -export { expressionsServiceFactory } from './expressions'; +export { customElementServiceFactory } from './custom_element'; export { embeddablesServiceFactory } from './embeddables'; +export { expressionsServiceFactory } from './expressions'; export { notifyServiceFactory } from './notify'; export { platformServiceFactory } from './platform'; export { reportingServiceFactory } from './reporting'; @@ -33,9 +35,10 @@ export const pluginServiceProviders: PluginServiceProviders< CanvasPluginServices, KibanaPluginServiceParams > = { + customElement: new PluginServiceProvider(customElementServiceFactory), + embeddables: new PluginServiceProvider(embeddablesServiceFactory), expressions: new PluginServiceProvider(expressionsServiceFactory), navLink: new PluginServiceProvider(navLinkServiceFactory), - embeddables: new PluginServiceProvider(embeddablesServiceFactory), notify: new PluginServiceProvider(notifyServiceFactory), platform: new PluginServiceProvider(platformServiceFactory), reporting: new PluginServiceProvider(reportingServiceFactory), diff --git a/x-pack/plugins/canvas/public/services/stubs/custom_element.ts b/x-pack/plugins/canvas/public/services/stubs/custom_element.ts new file mode 100644 index 0000000000000..d30b5db36949a --- /dev/null +++ b/x-pack/plugins/canvas/public/services/stubs/custom_element.ts @@ -0,0 +1,21 @@ +/* + * 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 { PluginServiceFactory } from '../../../../../../src/plugins/presentation_util/public'; +import { CanvasCustomElementService } from '../custom_element'; + +type CanvasCustomElementServiceFactory = PluginServiceFactory; + +const noop = (..._args: any[]): any => {}; + +export const customElementServiceFactory: CanvasCustomElementServiceFactory = () => ({ + create: noop, + find: noop, + get: noop, + remove: noop, + update: noop, +}); diff --git a/x-pack/plugins/canvas/public/services/stubs/index.ts b/x-pack/plugins/canvas/public/services/stubs/index.ts index 54bed79f61da3..df1370e83c00d 100644 --- a/x-pack/plugins/canvas/public/services/stubs/index.ts +++ b/x-pack/plugins/canvas/public/services/stubs/index.ts @@ -14,6 +14,7 @@ import { } from '../../../../../../src/plugins/presentation_util/public'; import { CanvasPluginServices } from '..'; +import { customElementServiceFactory } from './custom_element'; import { embeddablesServiceFactory } from './embeddables'; import { expressionsServiceFactory } from './expressions'; import { navLinkServiceFactory } from './nav_link'; @@ -22,6 +23,7 @@ import { platformServiceFactory } from './platform'; import { reportingServiceFactory } from './reporting'; import { workpadServiceFactory } from './workpad'; +export { customElementServiceFactory } from './custom_element'; export { expressionsServiceFactory } from './expressions'; export { navLinkServiceFactory } from './nav_link'; export { notifyServiceFactory } from './notify'; @@ -30,6 +32,7 @@ export { reportingServiceFactory } from './reporting'; export { workpadServiceFactory } from './workpad'; export const pluginServiceProviders: PluginServiceProviders = { + customElement: new PluginServiceProvider(customElementServiceFactory), embeddables: new PluginServiceProvider(embeddablesServiceFactory), expressions: new PluginServiceProvider(expressionsServiceFactory), navLink: new PluginServiceProvider(navLinkServiceFactory), From 2d385b339dd334b05eca32c907a2072cc32f8b02 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Fri, 13 Aug 2021 12:19:16 -0500 Subject: [PATCH 13/56] [canvas] Fix setup server expressions cache; move to mount (#108473) --- x-pack/plugins/canvas/public/plugin.tsx | 3 +-- x-pack/plugins/canvas/public/setup_expressions.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index 841f4b6d7c157..c149c67544865 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -95,8 +95,6 @@ export class CanvasPlugin })); } - setupExpressions({ coreSetup, setupPlugins }); - coreSetup.application.register({ category: DEFAULT_APP_CATEGORIES.kibana, id: 'canvas', @@ -108,6 +106,7 @@ export class CanvasPlugin const { CanvasSrcPlugin } = await import('../canvas_plugin_src/plugin'); const srcPlugin = new CanvasSrcPlugin(); srcPlugin.setup(coreSetup, { canvas: canvasApi }); + setupExpressions({ coreSetup, setupPlugins }); // Get start services const [coreStart, startPlugins] = await coreSetup.getStartServices(); diff --git a/x-pack/plugins/canvas/public/setup_expressions.ts b/x-pack/plugins/canvas/public/setup_expressions.ts index 3fc39564de0a9..e182d8efa097f 100644 --- a/x-pack/plugins/canvas/public/setup_expressions.ts +++ b/x-pack/plugins/canvas/public/setup_expressions.ts @@ -11,6 +11,8 @@ import { API_ROUTE_FUNCTIONS } from '../common/lib/constants'; import { CanvasSetupDeps } from './plugin'; +let cached: Promise | null = null; + // TODO: clintandrewhall - This is getting refactored shortly. https://github.com/elastic/kibana/issues/105675 export const setupExpressions = async ({ coreSetup, @@ -21,8 +23,6 @@ export const setupExpressions = async ({ }) => { const { expressions, bfetch } = setupPlugins; - let cached: Promise | null = null; - const loadServerFunctionWrappers = async () => { if (!cached) { cached = (async () => { From 560bd0b57b6aa687597eacad037bdcc381dcc8cd Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 13 Aug 2021 18:38:56 +0100 Subject: [PATCH 14/56] chore(NA): moving @kbn/es-archiver to babel transpiler (#108370) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-es-archiver/.babelrc | 3 +++ packages/kbn-es-archiver/BUILD.bazel | 25 +++++++++++++++++++------ packages/kbn-es-archiver/package.json | 4 ++-- packages/kbn-es-archiver/tsconfig.json | 3 ++- 4 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 packages/kbn-es-archiver/.babelrc diff --git a/packages/kbn-es-archiver/.babelrc b/packages/kbn-es-archiver/.babelrc new file mode 100644 index 0000000000000..7da72d1779128 --- /dev/null +++ b/packages/kbn-es-archiver/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["@kbn/babel-preset/node_preset"] +} diff --git a/packages/kbn-es-archiver/BUILD.bazel b/packages/kbn-es-archiver/BUILD.bazel index b7040b584a318..90c63f82b72fa 100644 --- a/packages/kbn-es-archiver/BUILD.bazel +++ b/packages/kbn-es-archiver/BUILD.bazel @@ -1,5 +1,6 @@ load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") +load("//src/dev/bazel:index.bzl", "jsts_transpiler") PKG_BASE_NAME = "kbn-es-archiver" PKG_REQUIRE_NAME = "@kbn/es-archiver" @@ -27,7 +28,7 @@ NPM_MODULE_EXTRA_FILES = [ "package.json", ] -SRC_DEPS = [ +RUNTIME_DEPS = [ "//packages/kbn-dev-utils", "//packages/kbn-test", "//packages/kbn-utils", @@ -43,6 +44,13 @@ SRC_DEPS = [ ] TYPES_DEPS = [ + "//packages/kbn-dev-utils", + "//packages/kbn-test", + "//packages/kbn-utils", + "@npm//@elastic/elasticsearch", + "@npm//aggregate-error", + "@npm//globby", + "@npm//zlib", "@npm//@types/bluebird", "@npm//@types/chance", "@npm//@types/jest", @@ -52,7 +60,11 @@ TYPES_DEPS = [ "@npm//@types/sinon", ] -DEPS = SRC_DEPS + TYPES_DEPS +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) ts_config( name = "tsconfig", @@ -64,13 +76,14 @@ ts_config( ) ts_project( - name = "tsc", + name = "tsc_types", args = ['--pretty'], srcs = SRCS, - deps = DEPS, + deps = TYPES_DEPS, declaration = True, declaration_map = True, - out_dir = "target", + emit_declaration_only = True, + out_dir = "target_types", source_map = True, root_dir = "src", tsconfig = ":tsconfig", @@ -79,7 +92,7 @@ ts_project( js_library( name = PKG_BASE_NAME, srcs = NPM_MODULE_EXTRA_FILES, - deps = DEPS + [":tsc"], + deps = RUNTIME_DEPS + [":target_node", ":tsc_types"], package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], ) diff --git a/packages/kbn-es-archiver/package.json b/packages/kbn-es-archiver/package.json index e8eb7b5f8f1c9..0cce08eaf0352 100644 --- a/packages/kbn-es-archiver/package.json +++ b/packages/kbn-es-archiver/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "license": "SSPL-1.0 OR Elastic License 2.0", "private": "true", - "main": "target/index.js", - "types": "target/index.d.ts", + "main": "target_node/index.js", + "types": "target_types/index.d.ts", "kibana": { "devOnly": true } diff --git a/packages/kbn-es-archiver/tsconfig.json b/packages/kbn-es-archiver/tsconfig.json index dce71fd6cd4a1..15c846f052b47 100644 --- a/packages/kbn-es-archiver/tsconfig.json +++ b/packages/kbn-es-archiver/tsconfig.json @@ -1,9 +1,10 @@ { "extends": "../../tsconfig.bazel.json", "compilerOptions": { - "outDir": "./target/types", "declaration": true, "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "./target_types", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-es-archiver/src", "types": [ From 79f1e186861112a8ff4854f7f1d516d8992a200b Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 13 Aug 2021 18:39:35 +0100 Subject: [PATCH 15/56] chore(NA): moving @kbn/io-ts-utils to babel transpiler (#108517) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-io-ts-utils/.babelrc | 3 +++ packages/kbn-io-ts-utils/BUILD.bazel | 22 ++++++++++++++----- packages/kbn-io-ts-utils/package.json | 4 ++-- packages/kbn-io-ts-utils/src/index.ts | 1 + packages/kbn-io-ts-utils/tsconfig.json | 6 +++-- .../src/create_router.test.tsx | 2 +- .../src/create_router.ts | 10 +++++++-- 7 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 packages/kbn-io-ts-utils/.babelrc diff --git a/packages/kbn-io-ts-utils/.babelrc b/packages/kbn-io-ts-utils/.babelrc new file mode 100644 index 0000000000000..7da72d1779128 --- /dev/null +++ b/packages/kbn-io-ts-utils/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["@kbn/babel-preset/node_preset"] +} diff --git a/packages/kbn-io-ts-utils/BUILD.bazel b/packages/kbn-io-ts-utils/BUILD.bazel index 0e5210bcc38a5..474fa2c2bb121 100644 --- a/packages/kbn-io-ts-utils/BUILD.bazel +++ b/packages/kbn-io-ts-utils/BUILD.bazel @@ -1,5 +1,6 @@ load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") +load("//src/dev/bazel:index.bzl", "jsts_transpiler") PKG_BASE_NAME = "kbn-io-ts-utils" PKG_REQUIRE_NAME = "@kbn/io-ts-utils" @@ -24,7 +25,7 @@ NPM_MODULE_EXTRA_FILES = [ "package.json", ] -SRC_DEPS = [ +RUNTIME_DEPS = [ "//packages/kbn-config-schema", "@npm//fp-ts", "@npm//io-ts", @@ -33,12 +34,20 @@ SRC_DEPS = [ ] TYPES_DEPS = [ + "//packages/kbn-config-schema", + "@npm//fp-ts", + "@npm//io-ts", + "@npm//tslib", "@npm//@types/jest", "@npm//@types/lodash", "@npm//@types/node", ] -DEPS = SRC_DEPS + TYPES_DEPS +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) ts_config( name = "tsconfig", @@ -50,13 +59,14 @@ ts_config( ) ts_project( - name = "tsc", + name = "tsc_types", args = ['--pretty'], srcs = SRCS, - deps = DEPS, + deps = TYPES_DEPS, declaration = True, declaration_map = True, - out_dir = "target", + emit_declaration_only = True, + out_dir = "target_types", source_map = True, root_dir = "src", tsconfig = ":tsconfig", @@ -65,7 +75,7 @@ ts_project( js_library( name = PKG_BASE_NAME, srcs = NPM_MODULE_EXTRA_FILES, - deps = DEPS + [":tsc"], + deps = RUNTIME_DEPS + [":target_node", ":tsc_types"], package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], ) diff --git a/packages/kbn-io-ts-utils/package.json b/packages/kbn-io-ts-utils/package.json index 9d22277f27c01..fb1179b06bf45 100644 --- a/packages/kbn-io-ts-utils/package.json +++ b/packages/kbn-io-ts-utils/package.json @@ -1,7 +1,7 @@ { "name": "@kbn/io-ts-utils", - "main": "./target/index.js", - "types": "./target/index.d.ts", + "main": "./target_node/index.js", + "types": "./target_types/index.d.ts", "version": "1.0.0", "license": "SSPL-1.0 OR Elastic License 2.0", "private": true diff --git a/packages/kbn-io-ts-utils/src/index.ts b/packages/kbn-io-ts-utils/src/index.ts index e94ac76b3c27b..88cfc063f738a 100644 --- a/packages/kbn-io-ts-utils/src/index.ts +++ b/packages/kbn-io-ts-utils/src/index.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +export { deepExactRt } from './deep_exact_rt'; export { jsonRt } from './json_rt'; export { mergeRt } from './merge_rt'; export { strictKeysRt } from './strict_keys_rt'; diff --git a/packages/kbn-io-ts-utils/tsconfig.json b/packages/kbn-io-ts-utils/tsconfig.json index 3ee769739dfc7..72d1479621345 100644 --- a/packages/kbn-io-ts-utils/tsconfig.json +++ b/packages/kbn-io-ts-utils/tsconfig.json @@ -1,12 +1,14 @@ { "extends": "../../tsconfig.bazel.json", "compilerOptions": { - "outDir": "./target/types", - "stripInternal": false, "declaration": true, "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "./target_types", + "rootDir": "src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-io-ts-utils/src", + "stripInternal": false, "types": [ "jest", "node" diff --git a/packages/kbn-typed-react-router-config/src/create_router.test.tsx b/packages/kbn-typed-react-router-config/src/create_router.test.tsx index fe82c48a33332..d8f42c8714e8b 100644 --- a/packages/kbn-typed-react-router-config/src/create_router.test.tsx +++ b/packages/kbn-typed-react-router-config/src/create_router.test.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; import * as t from 'io-ts'; -import { toNumberRt } from '@kbn/io-ts-utils/target/to_number_rt'; +import { toNumberRt } from '@kbn/io-ts-utils'; import { createRouter } from './create_router'; import { createMemoryHistory } from 'history'; import { route } from './route'; diff --git a/packages/kbn-typed-react-router-config/src/create_router.ts b/packages/kbn-typed-react-router-config/src/create_router.ts index 5385eb44b747c..28f9e2774eb74 100644 --- a/packages/kbn-typed-react-router-config/src/create_router.ts +++ b/packages/kbn-typed-react-router-config/src/create_router.ts @@ -14,10 +14,16 @@ import { } from 'react-router-config'; import qs from 'query-string'; import { findLastIndex, merge, compact } from 'lodash'; -import { deepExactRt } from '@kbn/io-ts-utils/target/deep_exact_rt'; -import { mergeRt } from '@kbn/io-ts-utils/target/merge_rt'; +import type { deepExactRt as deepExactRtTyped, mergeRt as mergeRtTyped } from '@kbn/io-ts-utils'; +// @ts-expect-error +import { deepExactRt as deepExactRtNonTyped } from '@kbn/io-ts-utils/target_node/deep_exact_rt'; +// @ts-expect-error +import { mergeRt as mergeRtNonTyped } from '@kbn/io-ts-utils/target_node/merge_rt'; import { Route, Router } from './types'; +const deepExactRt: typeof deepExactRtTyped = deepExactRtNonTyped; +const mergeRt: typeof mergeRtTyped = mergeRtNonTyped; + export function createRouter(routes: TRoutes): Router { const routesByReactRouterConfig = new Map(); const reactRouterConfigsByRoute = new Map(); From 79eb426a8f34edc1a0e7a010c03d5ff40b81331d Mon Sep 17 00:00:00 2001 From: Brandon Morelli Date: Fri, 13 Aug 2021 10:59:01 -0700 Subject: [PATCH 16/56] docs: Add anonymous auth to central config (#108285) --- docs/apm/agent-configuration.asciidoc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/apm/agent-configuration.asciidoc b/docs/apm/agent-configuration.asciidoc index f2e07412c4a38..4e4a37067ea10 100644 --- a/docs/apm/agent-configuration.asciidoc +++ b/docs/apm/agent-configuration.asciidoc @@ -27,6 +27,8 @@ For this reason, it is still essential to set custom default configurations loca ==== APM Server setup This feature requires {apm-server-ref}/setup-kibana-endpoint.html[Kibana endpoint configuration] in APM Server. +In addition, if an APM agent is using {apm-server-ref}/configuration-anonymous.html[anonymous authentication] to communicate with the APM Server, +the agent's service name must be included in the `apm-server.auth.anonymous.allow_service` list. APM Server acts as a proxy between the agents and Kibana. Kibana communicates any changed settings to APM Server so that your agents only need to poll APM Server to determine which settings have changed. From c6dc6e207a8ed8d30d74f84b9a554b1af5c27d2c Mon Sep 17 00:00:00 2001 From: Apoorva Joshi <30438249+ajosh0504@users.noreply.github.com> Date: Fri, 13 Aug 2021 11:04:42 -0700 Subject: [PATCH 17/56] Adding host_risk_score_latest to the list of patterns to track for telemetry (#108547) * Adding host_risk_score_latest to the list of patterns to track for telemetry * Adding a test * Removing extra spaces at end of line- should make the linter happy --- .../telemetry_collection/get_data_telemetry/constants.ts | 3 +++ .../get_data_telemetry/get_data_telemetry.test.ts | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/constants.ts b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/constants.ts index caa0c127d4eba..b5c2468b961b2 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/constants.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/constants.ts @@ -120,6 +120,9 @@ export const DATA_DATASETS_INDEX_PATTERNS = [ // meow attacks { pattern: '*meow*', patternName: 'meow' }, + + // experimental ml + { pattern: '*host_risk_score_latest', patternName: 'host_risk_score' }, ] as const; // Get the unique list of index patterns (some are duplicated for documentation purposes) diff --git a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts index dab1eaeed27ce..9fad1db9b6c3a 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts @@ -72,6 +72,8 @@ describe('get_data_telemetry', () => { { name: 'metricbeat-1234', docCount: 100, sizeInBytes: 10, isECS: false }, { name: '.app-search-1234', docCount: 0 }, { name: 'logs-endpoint.1234', docCount: 0 }, // Matching pattern with a dot in the name + { name: 'ml_host_risk_score_latest', docCount: 0 }, + { name: 'ml_host_risk_score', docCount: 0 }, // This should not match // New Indexing strategy: everything can be inferred from the constant_keyword values { name: '.ds-logs-nginx.access-default-000001', @@ -165,6 +167,11 @@ describe('get_data_telemetry', () => { index_count: 1, doc_count: 0, }, + { + pattern_name: 'host_risk_score', + index_count: 1, + doc_count: 0, + }, { data_stream: { dataset: 'nginx.access', type: 'logs' }, shipper: 'filebeat', From 505043898e3c452b883ad7326d63da50c70b036e Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 13 Aug 2021 13:38:57 -0600 Subject: [PATCH 18/56] [Maps] 'show this layer only' layer action (#107947) * [Maps] 'show this layer only' layer action * review feedback * remove ts code from js file Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../maps/public/actions/layer_actions.ts | 26 ++++ .../maps/public/classes/layers/layer.tsx | 5 + .../classes/layers/tile_layer/tile_layer.js | 4 + .../toc_entry_actions_popover.test.tsx.snap | 125 ++++++++++++++++++ .../toc_entry_actions_popover/index.ts | 6 + .../toc_entry_actions_popover.test.tsx | 13 ++ .../toc_entry_actions_popover.tsx | 16 +++ 7 files changed, 195 insertions(+) diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index eef325ca67cc5..edd21090143bf 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -246,6 +246,32 @@ export function toggleLayerVisible(layerId: string) { }; } +export function showThisLayerOnly(layerId: string) { + return ( + dispatch: ThunkDispatch, + getState: () => MapStoreState + ) => { + getLayerList(getState()).forEach((layer: ILayer, index: number) => { + if (layer.isBasemap(index)) { + return; + } + + // show target layer + if (layer.getId() === layerId) { + if (!layer.isVisible()) { + dispatch(setLayerVisibility(layerId, true)); + } + return; + } + + // hide all other layers + if (layer.isVisible()) { + dispatch(setLayerVisibility(layer.getId(), false)); + } + }); + }; +} + export function setSelectedLayer(layerId: string | null) { return async ( dispatch: ThunkDispatch, diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index 5244882d41e03..472a796a6e7c9 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -106,6 +106,7 @@ export interface ILayer { getDescriptor(): LayerDescriptor; getGeoFieldNames(): string[]; getStyleMetaDescriptorFromLocalFeatures(): Promise; + isBasemap(order: number): boolean; } export type CustomIconAndTooltipContent = { @@ -527,4 +528,8 @@ export class AbstractLayer implements ILayer { async getStyleMetaDescriptorFromLocalFeatures(): Promise { return null; } + + isBasemap(): boolean { + return false; + } } diff --git a/x-pack/plugins/maps/public/classes/layers/tile_layer/tile_layer.js b/x-pack/plugins/maps/public/classes/layers/tile_layer/tile_layer.js index 0995d117aaa47..d26c71ee9e215 100644 --- a/x-pack/plugins/maps/public/classes/layers/tile_layer/tile_layer.js +++ b/x-pack/plugins/maps/public/classes/layers/tile_layer/tile_layer.js @@ -117,4 +117,8 @@ export class TileLayer extends AbstractLayer { getLayerTypeIconName() { return 'grid'; } + + isBasemap(order) { + return order === 0; + } } diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap index c7db60ae59ef9..6531b8a2f2501 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap @@ -555,3 +555,128 @@ exports[`TOCEntryActionsPopover should not show edit actions in read only mode 1 /> `; + +exports[`TOCEntryActionsPopover should show "show this layer only" action when there are more then 2 layers 1`] = ` + + } + className="mapLayTocActions" + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="testLayer" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" +> + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Hide layer", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "showThisLayerOnlyButton", + "icon": , + "name": "Show this layer only", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerSettingsButton", + "disabled": false, + "icon": , + "name": "Edit layer settings", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "cloneLayerButton", + "icon": , + "name": "Clone layer", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "removeLayerButton", + "icon": , + "name": "Remove layer", + "onClick": [Function], + "toolTipContent": null, + }, + ], + "title": "Layer actions", + }, + ] + } + size="m" + /> + +`; diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts index 55d15f7ed2410..400904b530963 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts @@ -14,9 +14,11 @@ import { fitToLayerExtent, removeLayer, setDrawMode, + showThisLayerOnly, toggleLayerVisible, updateEditLayer, } from '../../../../../../actions'; +import { getLayerListRaw } from '../../../../../../selectors/map_selectors'; import { getIsReadOnly } from '../../../../../../selectors/ui_selectors'; import { TOCEntryActionsPopover } from './toc_entry_actions_popover'; import { DRAW_MODE } from '../../../../../../../common'; @@ -24,6 +26,7 @@ import { DRAW_MODE } from '../../../../../../../common'; function mapStateToProps(state: MapStoreState) { return { isReadOnly: getIsReadOnly(state), + numLayers: getLayerListRaw(state).length, }; } @@ -49,6 +52,9 @@ function mapDispatchToProps(dispatch: ThunkDispatch { + dispatch(showThisLayerOnly(layerId)); + }, }; } diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx index 1610954399ae7..ae62b75400769 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx @@ -49,6 +49,8 @@ const defaultProps = { enablePointEditing: () => {}, openLayerSettings: () => {}, editModeActiveForLayer: false, + numLayers: 2, + showThisLayerOnly: () => {}, }; describe('TOCEntryActionsPopover', () => { @@ -114,4 +116,15 @@ describe('TOCEntryActionsPopover', () => { expect(component).toMatchSnapshot(); }); + + test('should show "show this layer only" action when there are more then 2 layers', async () => { + const component = shallow(); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); }); diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx index 2a3186f00d7ce..ed0946e526c80 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx @@ -34,9 +34,11 @@ export interface Props { isReadOnly: boolean; layer: ILayer; removeLayer: (layerId: string) => void; + showThisLayerOnly: (layerId: string) => void; supportsFitToBounds: boolean; toggleVisible: (layerId: string) => void; editModeActiveForLayer: boolean; + numLayers: number; } interface State { @@ -158,6 +160,20 @@ export class TOCEntryActionsPopover extends Component { }, }, ]; + if (this.props.numLayers > 2) { + actionItems.push({ + name: i18n.translate('xpack.maps.layerTocActions.showThisLayerOnlyTitle', { + defaultMessage: 'Show this layer only', + }), + icon: , + 'data-test-subj': 'showThisLayerOnlyButton', + toolTipContent: null, + onClick: () => { + this._closePopover(); + this.props.showThisLayerOnly(this.props.layer.getId()); + }, + }); + } actionItems.push({ disabled: this.props.isEditButtonDisabled, name: EDIT_LAYER_SETTINGS_LABEL, From c9220056442f3d83152b1ad180a7018189ee9dda Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Fri, 13 Aug 2021 13:41:17 -0600 Subject: [PATCH 19/56] Changes out cypress pipe (#108457) ## Summary Reduces flake by changing out a Cypress pipe for a `cy.wait`. This UI element does unusual things that make it unfit for Cypress pipe such as multiple clicks against it will cause the component to have a dialog appear and disappear with transition effects which can make pipe not able to click once when the click handler is present. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../cypress/tasks/alerts_detection_rules.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 6dbd1ae16c4ad..0e81f75a19046 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -110,13 +110,17 @@ export const deleteSelectedRules = () => { export const deleteRuleFromDetailsPage = () => { cy.get(ALL_ACTIONS).should('be.visible'); - cy.root() - .pipe(($el) => { - $el.find(ALL_ACTIONS).trigger('click'); - return $el.find(RULE_DETAILS_DELETE_BTN); - }) - .should(($el) => expect($el).to.be.visible); - cy.get(RULE_DETAILS_DELETE_BTN).pipe(($el) => $el.trigger('click')); + // We cannot use cy.root().pipe($el) withing this function and instead have to use a cy.wait() + // for the click handler to be registered. If you see flake here because of click handler issues + // increase the cy.wait(). The reason we cannot use cypress pipe is because multiple clicks on ALL_ACTIONS + // causes the pop up to show and then the next click for it to hide. Multiple clicks can cause + // the DOM to queue up and once we detect that the element is visible it can then become invisible later + cy.wait(1000); + cy.get(ALL_ACTIONS).click(); + cy.get(RULE_DETAILS_DELETE_BTN).should('be.visible'); + cy.get(RULE_DETAILS_DELETE_BTN) + .pipe(($el) => $el.trigger('click')) + .should(($el) => expect($el).to.be.not.visible); }; export const duplicateSelectedRules = () => { From 3b31ffc5fd2b0b47669b23fb4cc1c78d1f1607b6 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Fri, 13 Aug 2021 15:42:14 -0400 Subject: [PATCH 20/56] [Security Solution][Endpoint] Improve logic for determining count of pending actions and reenable pending status on UI (#108114) - Re-enable display of Pending isolation status on the UI along with a experimental feature flag to be able to turn it back off - Improves the logic around determining if an isolation action has actually been processed by the Endpoint by looking for an endpoint metadata update whose `event.created` timestamp is more recent than the timestamp on the isolation Action Response. The goal is to minimize/avoid the UX around isolation where a user might not see the result from the Endpoint (isolated or released) after having seen the pending status on the UI. - Add some protective code around our server side license watcher so that failures are not bubbled up and instead are logged to the kibana logs - Added new `findHostMetadataForFleetAgents()` method to the `EndpointMetadataService` - Added test mocks for `EndpointMetadataService` and tests for new method --- .../policy/get_policy_data_for_update.ts | 30 +++ .../common/endpoint/service/policy/index.ts | 8 + .../common/endpoint/types/actions.ts | 2 + .../common/endpoint/types/index.ts | 4 +- .../common/experimental_features.ts | 1 + .../endpoint_host_isolation_status.test.tsx | 27 ++- .../endpoint_host_isolation_status.tsx | 211 +++++++++--------- .../public/common/store/app/reducer.ts | 10 +- .../policy/store/policy_details/middleware.ts | 4 +- .../policy/store/policy_details/selectors.ts | 52 +---- .../endpoint/lib/policy/license_watch.ts | 69 +++--- .../endpoint/routes/actions/status.test.ts | 10 + .../server/endpoint/routes/actions/status.ts | 6 +- .../routes/metadata/query_builders.ts | 22 ++ .../server/endpoint/services/actions.ts | 132 ++++++++--- .../endpoint_metadata_service.test.ts | 69 ++++++ .../metadata/endpoint_metadata_service.ts | 32 ++- .../endpoint/services/metadata/mocks.ts | 45 ++++ .../signals/get_input_output_index.test.ts | 8 +- .../factory/hosts/details/helpers.ts | 6 +- .../factory/hosts/details/index.test.tsx | 8 +- 21 files changed, 508 insertions(+), 248 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/endpoint/service/policy/get_policy_data_for_update.ts create mode 100644 x-pack/plugins/security_solution/common/endpoint/service/policy/index.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.test.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/services/metadata/mocks.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/service/policy/get_policy_data_for_update.ts b/x-pack/plugins/security_solution/common/endpoint/service/policy/get_policy_data_for_update.ts new file mode 100644 index 0000000000000..b929cde3dbb1c --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/service/policy/get_policy_data_for_update.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { cloneDeep } from 'lodash'; +import { MaybeImmutable, NewPolicyData, PolicyData } from '../../types'; + +/** + * Given a Policy Data (package policy) object, return back a new object with only the field + * needed for an Update/Create API action + * @param policy + */ +export const getPolicyDataForUpdate = (policy: MaybeImmutable): NewPolicyData => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { id, revision, created_by, created_at, updated_by, updated_at, ...newPolicy } = policy; + // cast to `NewPolicyData` (mutable) since we cloned the entire object + const policyDataForUpdate = cloneDeep(newPolicy) as NewPolicyData; + const endpointPolicy = policyDataForUpdate.inputs[0].config.policy.value; + + // trim custom malware notification string + [ + endpointPolicy.windows.popup.malware, + endpointPolicy.mac.popup.malware, + ].forEach((objWithMessage) => objWithMessage.message.trim()); + + return policyDataForUpdate; +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/policy/index.ts b/x-pack/plugins/security_solution/common/endpoint/service/policy/index.ts new file mode 100644 index 0000000000000..dc50d67197498 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/service/policy/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './get_policy_data_for_update'; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index 25fc831ca0aa4..be11ec3f99910 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -32,7 +32,9 @@ export interface EndpointActionResponse { action_id: string; /** The agent id that sent this action response */ agent_id: string; + /** timestamp when the action started to be processed by the Elastic Agent and/or Endpoint on the host */ started_at: string; + /** timestamp when the action completed processing by the Elastic Agent and/or Endpoint on the host */ completed_at: string; error?: string; action_data: EndpointActionData; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index dde7f7799757c..f398f1d57e600 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -6,7 +6,7 @@ */ import { ApplicationStart } from 'kibana/public'; -import { NewPackagePolicy, PackagePolicy } from '../../../../fleet/common'; +import { PackagePolicy, UpdatePackagePolicy } from '../../../../fleet/common'; import { ManifestSchema } from '../schema/manifest'; export * from './actions'; @@ -1020,7 +1020,7 @@ export type PolicyData = PackagePolicy & NewPolicyData; /** * New policy data. Used when updating the policy record via ingest APIs */ -export type NewPolicyData = NewPackagePolicy & { +export type NewPolicyData = UpdatePackagePolicy & { inputs: [ { type: 'endpoint'; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 0ae42d4baaec4..857aab10590e4 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -18,6 +18,7 @@ export const allowedExperimentalValues = Object.freeze({ trustedAppsByPolicyEnabled: false, excludePoliciesInFilterEnabled: false, uebaEnabled: false, + disableIsolationUIPendingStatuses: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.test.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.test.tsx index 373b4d78a84cc..72b7e987436ba 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.test.tsx @@ -16,9 +16,11 @@ describe('when using the EndpointHostIsolationStatus component', () => { let render: ( renderProps?: Partial ) => ReturnType; + let appContext: AppContextTestRender; beforeEach(() => { - const appContext = createAppRootMockRenderer(); + appContext = createAppRootMockRenderer(); + render = (renderProps = {}) => appContext.render( { expect(getByTestId('test').textContent).toBe('Isolated'); }); - // FIXME: un-skip when we bring back the pending isolation statuses - it.skip.each([ + it.each([ ['Isolating', { pendingIsolate: 2 }], ['Releasing', { pendingUnIsolate: 2 }], ['4 actions pending', { isIsolated: true, pendingUnIsolate: 2, pendingIsolate: 2 }], @@ -54,4 +55,24 @@ describe('when using the EndpointHostIsolationStatus component', () => { // Validate that the text color is set to `subdued` expect(getByTestId('test-pending').classList.contains('euiTextColor--subdued')).toBe(true); }); + + describe('and the disableIsolationUIPendingStatuses experimental feature flag is true', () => { + beforeEach(() => { + appContext.setExperimentalFlag({ disableIsolationUIPendingStatuses: true }); + }); + + it('should render `null` if not isolated', () => { + const renderResult = render({ pendingIsolate: 10, pendingUnIsolate: 20 }); + expect(renderResult.container.textContent).toBe(''); + }); + + it('should show `Isolated` when no pending actions and isolated', () => { + const { getByTestId } = render({ + isIsolated: true, + pendingIsolate: 10, + pendingUnIsolate: 20, + }); + expect(getByTestId('test').textContent).toBe('Isolated'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.tsx index 425172a5cd460..6795557f17f1a 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.tsx @@ -6,133 +6,140 @@ */ import React, { memo, useMemo } from 'react'; -import { EuiBadge } from '@elastic/eui'; +import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiTextColor, EuiToolTip } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -// import { useTestIdGenerator } from '../../../../management/components/hooks/use_test_id_generator'; +import { useTestIdGenerator } from '../../../../management/components/hooks/use_test_id_generator'; +import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; export interface EndpointHostIsolationStatusProps { isIsolated: boolean; /** the count of pending isolate actions */ pendingIsolate?: number; - /** the count of pending unisoalte actions */ + /** the count of pending unisolate actions */ pendingUnIsolate?: number; 'data-test-subj'?: string; } /** - * Component will display a host isoaltion status based on whether it is currently isolated or there are + * Component will display a host isolation status based on whether it is currently isolated or there are * isolate/unisolate actions pending. If none of these are applicable, no UI component will be rendered * (`null` is returned) */ export const EndpointHostIsolationStatus = memo( - ({ - isIsolated, - /* pendingIsolate = 0, pendingUnIsolate = 0,*/ 'data-test-subj': dataTestSubj, - }) => { - // const getTestId = useTestIdGenerator(dataTestSubj); + ({ isIsolated, pendingIsolate = 0, pendingUnIsolate = 0, 'data-test-subj': dataTestSubj }) => { + const getTestId = useTestIdGenerator(dataTestSubj); + const isPendingStatuseDisabled = useIsExperimentalFeatureEnabled( + 'disableIsolationUIPendingStatuses' + ); return useMemo(() => { + if (isPendingStatuseDisabled) { + // If nothing is pending and host is not currently isolated, then render nothing + if (!isIsolated) { + return null; + } + + return ( + + + + ); + } + // If nothing is pending and host is not currently isolated, then render nothing - if (!isIsolated) { + if (!isIsolated && !pendingIsolate && !pendingUnIsolate) { return null; } - // if (!isIsolated && !pendingIsolate && !pendingUnIsolate) { - // return null; - // } + // If nothing is pending, but host is isolated, then show isolation badge + if (!pendingIsolate && !pendingUnIsolate) { + return ( + + + + ); + } + + // If there are multiple types of pending isolation actions, then show count of actions with tooltip that displays breakdown + if (pendingIsolate && pendingUnIsolate) { + return ( + + +
+ +
+ + + + + {pendingIsolate} + + + + + + {pendingUnIsolate} + +
+ } + > + + + + + + ); + } + + // Show 'pending [un]isolate' depending on what's pending return ( - + + {pendingIsolate ? ( + + ) : ( + + )} + ); - - // If nothing is pending and host is not currently isolated, then render nothing - // if (!isIsolated && !pendingIsolate && !pendingUnIsolate) { - // return null; - // } - // - // // If nothing is pending, but host is isolated, then show isolation badge - // if (!pendingIsolate && !pendingUnIsolate) { - // return ( - // - // - // - // ); - // } - // - // // If there are multiple types of pending isolation actions, then show count of actions with tooltip that displays breakdown - // if (pendingIsolate && pendingUnIsolate) { - // return ( - // - // - //
- // - //
- // - // - // - // - // {pendingIsolate} - // - // - // - // - // - // {pendingUnIsolate} - // - // - // } - // > - // - // - // - //
- //
- // ); - // } - // - // // Show 'pending [un]isolate' depending on what's pending - // return ( - // - // - // {pendingIsolate ? ( - // - // ) : ( - // - // )} - // - // - // ); - }, [dataTestSubj, isIsolated /* , getTestId , pendingIsolate, pendingUnIsolate*/]); + }, [ + dataTestSubj, + getTestId, + isIsolated, + isPendingStatuseDisabled, + pendingIsolate, + pendingUnIsolate, + ]); } ); diff --git a/x-pack/plugins/security_solution/public/common/store/app/reducer.ts b/x-pack/plugins/security_solution/public/common/store/app/reducer.ts index 6ab572490f5d7..df07920526a9f 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/reducer.ts @@ -11,20 +11,14 @@ import { Note } from '../../lib/note'; import { addError, addErrorHash, addNotes, removeError, updateNote } from './actions'; import { AppModel, NotesById } from './model'; +import { allowedExperimentalValues } from '../../../../common/experimental_features'; export type AppState = AppModel; export const initialAppState: AppState = { notesById: {}, errors: [], - enableExperimental: { - trustedAppsByPolicyEnabled: false, - excludePoliciesInFilterEnabled: false, - metricsEntitiesEnabled: false, - ruleRegistryEnabled: false, - tGridEnabled: false, - uebaEnabled: false, - }, + enableExperimental: { ...allowedExperimentalValues }, }; interface UpdateNotesByIdParams { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts index 9e6bc4b0097f3..93c279db8a55b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts @@ -13,7 +13,6 @@ import { isOnPolicyDetailsPage, policyDetails, policyDetailsForUpdate, - getPolicyDataForUpdate, } from './selectors'; import { sendGetPackagePolicy, @@ -22,6 +21,7 @@ import { } from '../services/ingest'; import { NewPolicyData, PolicyData } from '../../../../../../common/endpoint/types'; import { ImmutableMiddlewareFactory } from '../../../../../common/store'; +import { getPolicyDataForUpdate } from '../../../../../../common/endpoint/service/policy/get_policy_data_for_update'; export const policyDetailsMiddlewareFactory: ImmutableMiddlewareFactory = ( coreStart @@ -112,7 +112,7 @@ export const policyDetailsMiddlewareFactory: ImmutableMiddlewareFactory) => state.policyItem; @@ -58,55 +59,6 @@ export const licensedPolicy: ( } ); -/** - * Given a Policy Data (package policy) object, return back a new object with only the field - * needed for an Update/Create API action - * @param policy - */ -export const getPolicyDataForUpdate = ( - policy: PolicyData | Immutable -): NewPolicyData | Immutable => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { id, revision, created_by, created_at, updated_by, updated_at, ...newPolicy } = policy; - - // trim custom malware notification string - return { - ...newPolicy, - inputs: (newPolicy as Immutable).inputs.map((input) => ({ - ...input, - config: input.config && { - ...input.config, - policy: { - ...input.config.policy, - value: { - ...input.config.policy.value, - windows: { - ...input.config.policy.value.windows, - popup: { - ...input.config.policy.value.windows.popup, - malware: { - ...input.config.policy.value.windows.popup.malware, - message: input.config.policy.value.windows.popup.malware.message.trim(), - }, - }, - }, - mac: { - ...input.config.policy.value.mac, - popup: { - ...input.config.policy.value.mac.popup, - malware: { - ...input.config.policy.value.mac.popup.malware, - message: input.config.policy.value.mac.popup.malware.message.trim(), - }, - }, - }, - }, - }, - }, - })), - }; -}; - /** * Return only the policy structure accepted for update/create */ @@ -114,7 +66,7 @@ export const policyDetailsForUpdate: ( state: Immutable ) => Immutable | undefined = createSelector(licensedPolicy, (policy) => { if (policy) { - return getPolicyDataForUpdate(policy); + return getPolicyDataForUpdate(policy) as Immutable; } }); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.ts b/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.ts index b8c6e57f72cea..63c5c85bd1e93 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.ts @@ -15,11 +15,7 @@ import { SavedObjectsClientContract, SavedObjectsServiceStart, } from 'src/core/server'; -import { - PackagePolicy, - PACKAGE_POLICY_SAVED_OBJECT_TYPE, - UpdatePackagePolicy, -} from '../../../../../fleet/common'; +import { PackagePolicy, PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../../fleet/common'; import { PackagePolicyServiceInterface } from '../../../../../fleet/server'; import { ILicense } from '../../../../../licensing/common/types'; import { @@ -27,6 +23,8 @@ import { unsetPolicyFeaturesAccordingToLicenseLevel, } from '../../../../common/license/policy_config'; import { LicenseService } from '../../../../common/license/license'; +import { PolicyData } from '../../../../common/endpoint/types'; +import { getPolicyDataForUpdate } from '../../../../common/endpoint/service/policy'; export class PolicyWatcher { private logger: Logger; @@ -83,6 +81,7 @@ export class PolicyWatcher { page: number; perPage: number; }; + do { try { response = await this.policyService.list(this.makeInternalSOClient(this.soStart), { @@ -96,33 +95,17 @@ export class PolicyWatcher { ); return; } - response.items.forEach(async (policy) => { - const updatePolicy: UpdatePackagePolicy = { - name: policy.name, - description: policy.description, - namespace: policy.namespace, - enabled: policy.enabled, - policy_id: policy.policy_id, - output_id: policy.output_id, - package: policy.package, - inputs: policy.inputs, - version: policy.version, - }; - const policyConfig = updatePolicy.inputs[0].config?.policy.value; - if (!isEndpointPolicyValidForLicense(policyConfig, license)) { - updatePolicy.inputs[0].config!.policy.value = unsetPolicyFeaturesAccordingToLicenseLevel( - policyConfig, - license - ); - try { - await this.policyService.update( - this.makeInternalSOClient(this.soStart), - this.esClient, - policy.id, - updatePolicy + + for (const policy of response.items as PolicyData[]) { + const updatePolicy = getPolicyDataForUpdate(policy); + const policyConfig = updatePolicy.inputs[0].config.policy.value; + + try { + if (!isEndpointPolicyValidForLicense(policyConfig, license)) { + updatePolicy.inputs[0].config.policy.value = unsetPolicyFeaturesAccordingToLicenseLevel( + policyConfig, + license ); - } catch (e) { - // try again for transient issues try { await this.policyService.update( this.makeInternalSOClient(this.soStart), @@ -130,14 +113,28 @@ export class PolicyWatcher { policy.id, updatePolicy ); - } catch (ee) { - this.logger.warn( - `Unable to remove platinum features from policy ${policy.id}: ${ee.message}` - ); + } catch (e) { + // try again for transient issues + try { + await this.policyService.update( + this.makeInternalSOClient(this.soStart), + this.esClient, + policy.id, + updatePolicy + ); + } catch (ee) { + this.logger.warn(`Unable to remove platinum features from policy ${policy.id}`); + this.logger.warn(ee); + } } } + } catch (error) { + this.logger.warn( + `Failure while attempting to verify Endpoint Policy features for policy [${policy.id}]` + ); + this.logger.warn(error); } - }); + } } while (response.page * response.perPage < response.total); } } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts index bc14dc7265071..facd53643bc4f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts @@ -243,6 +243,11 @@ describe('Endpoint Action Status', () => { ], [aMockResponse(actionID, mockAgentID)] ); + (endpointAppContextService.getEndpointMetadataService as jest.Mock) = jest + .fn() + .mockReturnValue({ + findHostMetadataForFleetAgents: jest.fn().mockResolvedValue([]), + }); const response = await getPendingStatus({ query: { agent_ids: [mockAgentID], @@ -273,6 +278,11 @@ describe('Endpoint Action Status', () => { ], [aMockResponse(actionTwoID, agentThree)] ); + (endpointAppContextService.getEndpointMetadataService as jest.Mock) = jest + .fn() + .mockReturnValue({ + findHostMetadataForFleetAgents: jest.fn().mockResolvedValue([]), + }); const response = await getPendingStatus({ query: { agent_ids: [agentOne, agentTwo, agentThree], diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts index ec03acee0335d..4ba03bf220c21 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts @@ -47,7 +47,11 @@ export const actionStatusRequestHandler = function ( ? [...new Set(req.query.agent_ids)] : [req.query.agent_ids]; - const response = await getPendingActionCounts(esClient, agentIDs); + const response = await getPendingActionCounts( + esClient, + endpointContext.service.getEndpointMetadataService(), + agentIDs + ); return res.ok({ body: { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts index 99ec1d1022747..32ab80fb67684 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts @@ -166,6 +166,28 @@ export function getESQueryHostMetadataByID(agentID: string): estypes.SearchReque }; } +export function getESQueryHostMetadataByFleetAgentIds( + fleetAgentIds: string[] +): estypes.SearchRequest { + return { + body: { + query: { + bool: { + filter: [ + { + bool: { + should: [{ terms: { 'elastic.agent.id': fleetAgentIds } }], + }, + }, + ], + }, + }, + sort: MetadataSortMethod, + }, + index: metadataCurrentIndexPattern, + }; +} + export function getESQueryHostMetadataByIDs(agentIDs: string[]) { return { body: { diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions.ts index 89f088e322ffa..80fb1c5d9c7b0 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions.ts @@ -14,6 +14,10 @@ import { EndpointActionResponse, EndpointPendingActions, } from '../../../common/endpoint/types'; +import { catchAndWrapError } from '../utils'; +import { EndpointMetadataService } from './metadata'; + +const PENDING_ACTION_RESPONSE_MAX_LAPSED_TIME = 300000; // 300k ms === 5 minutes export const getAuditLogResponse = async ({ elasticAgentId, @@ -173,6 +177,8 @@ const getActivityLog = async ({ export const getPendingActionCounts = async ( esClient: ElasticsearchClient, + metadataService: EndpointMetadataService, + /** The Fleet Agent IDs to be checked */ agentIDs: string[] ): Promise => { // retrieve the unexpired actions for the given hosts @@ -197,11 +203,60 @@ export const getPendingActionCounts = async ( }, { ignore: [404] } ) - .then((result) => result.body?.hits?.hits?.map((a) => a._source!) || []); + .then((result) => result.body?.hits?.hits?.map((a) => a._source!) || []) + .catch(catchAndWrapError); // retrieve any responses to those action IDs from these agents - const actionIDs = recentActions.map((a) => a.action_id); - const responses = await esClient + const responses = await fetchActionResponseIds( + esClient, + metadataService, + recentActions.map((a) => a.action_id), + agentIDs + ); + const pending: EndpointPendingActions[] = []; + + for (const agentId of agentIDs) { + const responseIDsFromAgent = responses[agentId]; + + pending.push({ + agent_id: agentId, + pending_actions: recentActions + .filter((a) => a.agents.includes(agentId) && !responseIDsFromAgent.includes(a.action_id)) + .map((a) => a.data.command) + .reduce((acc, cur) => { + if (cur in acc) { + acc[cur] += 1; + } else { + acc[cur] = 1; + } + return acc; + }, {} as EndpointPendingActions['pending_actions']), + }); + } + + return pending; +}; + +/** + * Returns back a map of elastic Agent IDs to array of Action IDs that have received a response. + * + * @param esClient + * @param metadataService + * @param actionIds + * @param agentIds + */ +const fetchActionResponseIds = async ( + esClient: ElasticsearchClient, + metadataService: EndpointMetadataService, + actionIds: string[], + agentIds: string[] +): Promise> => { + const actionResponsesByAgentId: Record = agentIds.reduce((acc, agentId) => { + acc[agentId] = []; + return acc; + }, {} as Record); + + const actionResponses = await esClient .search( { index: AGENT_ACTIONS_RESULTS_INDEX, @@ -211,8 +266,8 @@ export const getPendingActionCounts = async ( query: { bool: { filter: [ - { terms: { action_id: actionIDs } }, // get results for these actions - { terms: { agent_id: agentIDs } }, // ignoring responses from agents we're not looking for + { terms: { action_id: actionIds } }, // get results for these actions + { terms: { agent_id: agentIds } }, // ONLY responses for the agents we are interested in (ignore others) ], }, }, @@ -220,28 +275,49 @@ export const getPendingActionCounts = async ( }, { ignore: [404] } ) - .then((result) => result.body?.hits?.hits?.map((a) => a._source!) || []); - - // respond with action-count per agent - const pending: EndpointPendingActions[] = agentIDs.map((aid) => { - const responseIDsFromAgent = responses - .filter((r) => r.agent_id === aid) - .map((r) => r.action_id); - return { - agent_id: aid, - pending_actions: recentActions - .filter((a) => a.agents.includes(aid) && !responseIDsFromAgent.includes(a.action_id)) - .map((a) => a.data.command) - .reduce((acc, cur) => { - if (cur in acc) { - acc[cur] += 1; - } else { - acc[cur] = 1; - } - return acc; - }, {} as EndpointPendingActions['pending_actions']), - }; - }); + .then((result) => result.body?.hits?.hits?.map((a) => a._source!) || []) + .catch(catchAndWrapError); - return pending; + if (actionResponses.length === 0) { + return actionResponsesByAgentId; + } + + // Get the latest docs from the metadata datastream for the Elastic Agent IDs in the action responses + // This will be used determine if we should withhold the action id from the returned list in cases where + // the Endpoint might not yet have sent an updated metadata document (which would be representative of + // the state of the endpoint post-action) + const latestEndpointMetadataDocs = await metadataService.findHostMetadataForFleetAgents( + esClient, + agentIds + ); + + // Object of Elastic Agent Ids to event created date + const endpointLastEventCreated: Record = latestEndpointMetadataDocs.reduce( + (acc, endpointMetadata) => { + acc[endpointMetadata.elastic.agent.id] = new Date(endpointMetadata.event.created); + return acc; + }, + {} as Record + ); + + for (const actionResponse of actionResponses) { + const lastEndpointMetadataEventTimestamp = endpointLastEventCreated[actionResponse.agent_id]; + const actionCompletedAtTimestamp = new Date(actionResponse.completed_at); + // If enough time has lapsed in checking for updated Endpoint metadata doc so that we don't keep + // checking it forever. + // It uses the `@timestamp` in order to ensure we are looking at times that were set by the server + const enoughTimeHasLapsed = + Date.now() - new Date(actionResponse['@timestamp']).getTime() > + PENDING_ACTION_RESPONSE_MAX_LAPSED_TIME; + + if ( + !lastEndpointMetadataEventTimestamp || + enoughTimeHasLapsed || + lastEndpointMetadataEventTimestamp > actionCompletedAtTimestamp + ) { + actionResponsesByAgentId[actionResponse.agent_id].push(actionResponse.action_id); + } + } + + return actionResponsesByAgentId; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.test.ts new file mode 100644 index 0000000000000..05c7c618f58c1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + createEndpointMetadataServiceTestContextMock, + EndpointMetadataServiceTestContextMock, +} from './mocks'; +import { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ElasticsearchClientMock } from '../../../../../../../src/core/server/elasticsearch/client/mocks'; +import { createV2SearchResponse } from '../../routes/metadata/support/test_support'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { getESQueryHostMetadataByFleetAgentIds } from '../../routes/metadata/query_builders'; +import { EndpointError } from '../../errors'; +import { HostMetadata } from '../../../../common/endpoint/types'; + +describe('EndpointMetadataService', () => { + let testMockedContext: EndpointMetadataServiceTestContextMock; + let metadataService: EndpointMetadataServiceTestContextMock['endpointMetadataService']; + let esClient: ElasticsearchClientMock; + + beforeEach(() => { + testMockedContext = createEndpointMetadataServiceTestContextMock(); + metadataService = testMockedContext.endpointMetadataService; + esClient = elasticsearchServiceMock.createScopedClusterClient().asInternalUser; + }); + + describe('#findHostMetadataForFleetAgents()', () => { + let fleetAgentIds: string[]; + let endpointMetadataDoc: HostMetadata; + + beforeEach(() => { + fleetAgentIds = ['one', 'two']; + endpointMetadataDoc = new EndpointDocGenerator().generateHostMetadata(); + esClient.search.mockReturnValue( + elasticsearchServiceMock.createSuccessTransportRequestPromise( + createV2SearchResponse(endpointMetadataDoc) + ) + ); + }); + + it('should call elasticsearch with proper filter', async () => { + await metadataService.findHostMetadataForFleetAgents(esClient, fleetAgentIds); + expect(esClient.search).toHaveBeenCalledWith( + { ...getESQueryHostMetadataByFleetAgentIds(fleetAgentIds), size: fleetAgentIds.length }, + { ignore: [404] } + ); + }); + + it('should throw a wrapped elasticsearch Error when one occurs', async () => { + esClient.search.mockRejectedValue(new Error('foo bar')); + await expect( + metadataService.findHostMetadataForFleetAgents(esClient, fleetAgentIds) + ).rejects.toThrow(EndpointError); + }); + + it('should return an array of Host Metadata documents', async () => { + const response = await metadataService.findHostMetadataForFleetAgents( + esClient, + fleetAgentIds + ); + expect(response).toEqual([endpointMetadataDoc]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts index 92a0391492a3f..5e2f46aa4c285 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts @@ -24,8 +24,14 @@ import { FleetAgentNotFoundError, FleetAgentPolicyNotFoundError, } from './errors'; -import { getESQueryHostMetadataByID } from '../../routes/metadata/query_builders'; -import { queryResponseToHostResult } from '../../routes/metadata/support/query_strategies'; +import { + getESQueryHostMetadataByFleetAgentIds, + getESQueryHostMetadataByID, +} from '../../routes/metadata/query_builders'; +import { + queryResponseToHostListResult, + queryResponseToHostResult, +} from '../../routes/metadata/support/query_strategies'; import { catchAndWrapError, DEFAULT_ENDPOINT_HOST_STATUS, @@ -62,7 +68,7 @@ export class EndpointMetadataService { * * @private */ - public get DANGEROUS_INTERNAL_SO_CLIENT() { + private get DANGEROUS_INTERNAL_SO_CLIENT() { // The INTERNAL SO client must be created during the first time its used. This is because creating it during // instance initialization (in `constructor(){}`) causes the SO Client to be invalid (perhaps because this // instantiation is happening during the plugin's the start phase) @@ -95,6 +101,26 @@ export class EndpointMetadataService { throw new EndpointHostNotFoundError(`Endpoint with id ${endpointId} not found`); } + /** + * Find a list of Endpoint Host Metadata document associated with a given list of Fleet Agent Ids + * @param esClient + * @param fleetAgentIds + */ + async findHostMetadataForFleetAgents( + esClient: ElasticsearchClient, + fleetAgentIds: string[] + ): Promise { + const query = getESQueryHostMetadataByFleetAgentIds(fleetAgentIds); + + query.size = fleetAgentIds.length; + + const searchResult = await esClient + .search(query, { ignore: [404] }) + .catch(catchAndWrapError); + + return queryResponseToHostListResult(searchResult.body).resultList; + } + /** * Retrieve a single endpoint host metadata along with fleet information * diff --git a/x-pack/plugins/security_solution/server/endpoint/services/metadata/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/services/metadata/mocks.ts new file mode 100644 index 0000000000000..147d8e11b567c --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/metadata/mocks.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsServiceStart } from 'kibana/server'; +import { EndpointMetadataService } from './endpoint_metadata_service'; +import { savedObjectsServiceMock } from '../../../../../../../src/core/server/mocks'; +import { + createMockAgentPolicyService, + createMockAgentService, +} from '../../../../../fleet/server/mocks'; +import { AgentPolicyServiceInterface, AgentService } from '../../../../../fleet/server'; + +/** + * Endpoint Metadata Service test context. Includes an instance of `EndpointMetadataService` along with the + * dependencies that were used to initialize that instance. + */ +export interface EndpointMetadataServiceTestContextMock { + savedObjectsStart: jest.Mocked; + agentService: jest.Mocked; + agentPolicyService: jest.Mocked; + endpointMetadataService: EndpointMetadataService; +} + +export const createEndpointMetadataServiceTestContextMock = ( + savedObjectsStart: jest.Mocked = savedObjectsServiceMock.createStartContract(), + agentService: jest.Mocked = createMockAgentService(), + agentPolicyService: jest.Mocked = createMockAgentPolicyService() +): EndpointMetadataServiceTestContextMock => { + const endpointMetadataService = new EndpointMetadataService( + savedObjectsStart, + agentService, + agentPolicyService + ); + + return { + savedObjectsStart, + agentService, + agentPolicyService, + endpointMetadataService, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts index c8ef0093291d5..787c26871d869 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts @@ -8,6 +8,7 @@ import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mocks'; import { DEFAULT_INDEX_KEY, DEFAULT_INDEX_PATTERN } from '../../../../common/constants'; import { getInputIndex, GetInputIndex } from './get_input_output_index'; +import { allowedExperimentalValues } from '../../../../common/experimental_features'; describe('get_input_output_index', () => { let servicesMock: AlertServicesMock; @@ -33,12 +34,7 @@ describe('get_input_output_index', () => { version: '8.0.0', index: ['test-input-index-1'], experimentalFeatures: { - trustedAppsByPolicyEnabled: false, - excludePoliciesInFilterEnabled: false, - metricsEntitiesEnabled: false, - ruleRegistryEnabled: false, - tGridEnabled: false, - uebaEnabled: false, + ...allowedExperimentalValues, }, }; }); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts index 9b9f49a167397..c96e0040fd23d 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts @@ -209,7 +209,11 @@ export const getHostEndpoint = async ( // Get Agent Status agentService.getAgentStatusById(esClient.asCurrentUser, fleetAgentId), // Get a list of pending actions (if any) - getPendingActionCounts(esClient.asCurrentUser, [fleetAgentId]).then((results) => { + getPendingActionCounts( + esClient.asCurrentUser, + endpointContext.service.getEndpointMetadataService(), + [fleetAgentId] + ).then((results) => { return results[0].pending_actions; }), ]); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/index.test.tsx b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/index.test.tsx index e921e8420eb96..7729934123899 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/index.test.tsx +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/index.test.tsx @@ -18,6 +18,7 @@ import { } from '../../../../../../../../../src/core/server'; import { EndpointAppContext } from '../../../../../endpoint/types'; import { EndpointAppContextService } from '../../../../../endpoint/endpoint_app_context_services'; +import { allowedExperimentalValues } from '../../../../../../common/experimental_features'; const mockDeps = { esClient: {} as IScopedClusterClient, @@ -30,12 +31,7 @@ const mockDeps = { }, config: jest.fn().mockResolvedValue({}), experimentalFeatures: { - trustedAppsByPolicyEnabled: false, - excludePoliciesInFilterEnabled: false, - metricsEntitiesEnabled: false, - ruleRegistryEnabled: false, - tGridEnabled: false, - uebaEnabled: false, + ...allowedExperimentalValues, }, service: {} as EndpointAppContextService, } as EndpointAppContext, From 2fb785de64b6d562ec35ba67af9d39f2db7b4530 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Fri, 13 Aug 2021 13:11:35 -0700 Subject: [PATCH 21/56] [Reporting/Mgmt] Fix the missing deprecation warning under job status (#108484) * [Reporting/Mgmt] Fix the missing deprecation warning under job status * improve unit test * add space before the text and update snapshots --- x-pack/plugins/reporting/public/lib/job.tsx | 101 +++++---- .../report_listing.test.tsx.snap | 192 ++++++++++++------ .../public/management/report_listing.test.tsx | 183 ++++++++++++++++- .../reporting_management/report_listing.ts | 2 +- 4 files changed, 366 insertions(+), 112 deletions(-) diff --git a/x-pack/plugins/reporting/public/lib/job.tsx b/x-pack/plugins/reporting/public/lib/job.tsx index 86d618c57379b..31205af340741 100644 --- a/x-pack/plugins/reporting/public/lib/job.tsx +++ b/x-pack/plugins/reporting/public/lib/job.tsx @@ -103,14 +103,31 @@ export class Job { }); } - if (smallMessage) { - return ( + let deprecatedMessage: React.ReactElement | undefined; + if (this.isDeprecated) { + deprecatedMessage = ( - {smallMessage} + {' '} + + {i18n.translate('xpack.reporting.jobStatusDetail.deprecatedText', { + defaultMessage: `This is a deprecated export type. Automation of this report will need to be re-created for compatibility with future versions of Kibana.`, + })} + ); } + if (smallMessage) { + return ( + <> + + {smallMessage} + + {deprecatedMessage ? deprecatedMessage : null} + + ); + } + return null; } @@ -169,45 +186,45 @@ export class Job { } getWarnings() { - if (this.status !== FAILED) { - const warnings: string[] = []; - if (this.isDeprecated) { - warnings.push( - i18n.translate('xpack.reporting.jobWarning.exportTypeDeprecated', { - defaultMessage: - 'This is a deprecated export type. Automation of this report will need to be re-created for compatibility with future versions of Kibana.', - }) - ); - } - if (this.csv_contains_formulas) { - warnings.push( - i18n.translate('xpack.reporting.jobWarning.csvContainsFormulas', { - defaultMessage: - 'Your CSV contains characters that spreadsheet applications might interpret as formulas.', - }) - ); - } - if (this.max_size_reached) { - warnings.push( - i18n.translate('xpack.reporting.jobWarning.maxSizeReachedTooltip', { - defaultMessage: 'Your search reached the max size and contains partial data.', - }) - ); - } - - if (this.warnings?.length) { - warnings.push(...this.warnings); - } - - if (warnings.length) { - return ( -
    - {warnings.map((w, i) => { - return
  • {w}
  • ; - })} -
- ); - } + const warnings: string[] = []; + if (this.isDeprecated) { + warnings.push( + i18n.translate('xpack.reporting.jobWarning.exportTypeDeprecated', { + defaultMessage: + 'This is a deprecated export type. Automation of this report will need to be re-created for compatibility with future versions of Kibana.', + }) + ); + } + + if (this.csv_contains_formulas) { + warnings.push( + i18n.translate('xpack.reporting.jobWarning.csvContainsFormulas', { + defaultMessage: + 'Your CSV contains characters that spreadsheet applications might interpret as formulas.', + }) + ); + } + if (this.max_size_reached) { + warnings.push( + i18n.translate('xpack.reporting.jobWarning.maxSizeReachedTooltip', { + defaultMessage: 'Your search reached the max size and contains partial data.', + }) + ); + } + + // warnings could contain the failure message + if (this.status !== FAILED && this.warnings?.length) { + warnings.push(...this.warnings); + } + + if (warnings.length) { + return ( +
    + {warnings.map((w, i) => { + return
  • {w}
  • ; + })} +
+ ); } } diff --git a/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap index 926ca6e0b53dc..352c4dddb9f32 100644 --- a/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap +++ b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap @@ -614,8 +614,8 @@ exports[`ReportListing Report job listing with some items 1`] = ` "index": ".reporting-2020.04.12", "isDeprecated": false, "jobtype": "printable_pdf", - "kibana_id": undefined, - "kibana_name": undefined, + "kibana_id": "5b2de169-2785-441b-ae8c-186a1936b17d", + "kibana_name": "spicy.local", "layout": Object { "dimensions": Object { "height": 720, @@ -660,8 +660,8 @@ exports[`ReportListing Report job listing with some items 1`] = ` "index": ".reporting-2020.04.12", "isDeprecated": false, "jobtype": "printable_pdf", - "kibana_id": undefined, - "kibana_name": undefined, + "kibana_id": "5b2de169-2785-441b-ae8c-186a1936b17d", + "kibana_name": "spicy.local", "layout": Object { "dimensions": Object { "height": 720, @@ -832,8 +832,8 @@ exports[`ReportListing Report job listing with some items 1`] = ` "index": ".reporting-2020.04.12", "isDeprecated": false, "jobtype": "printable_pdf", - "kibana_id": undefined, - "kibana_name": undefined, + "kibana_id": "5b2de169-2785-441b-ae8c-186a1936b17d", + "kibana_name": "spicy.local", "layout": Object { "dimensions": Object { "height": 720, @@ -1033,8 +1033,8 @@ exports[`ReportListing Report job listing with some items 1`] = ` "index": ".reporting-2020.04.12", "isDeprecated": false, "jobtype": "printable_pdf", - "kibana_id": undefined, - "kibana_name": undefined, + "kibana_id": "5b2de169-2785-441b-ae8c-186a1936b17d", + "kibana_name": "spicy.local", "layout": Object { "dimensions": Object { "height": 720, @@ -1234,8 +1234,8 @@ exports[`ReportListing Report job listing with some items 1`] = ` "index": ".reporting-2020.04.12", "isDeprecated": false, "jobtype": "printable_pdf", - "kibana_id": undefined, - "kibana_name": undefined, + "kibana_id": "5b2de169-2785-441b-ae8c-186a1936b17d", + "kibana_name": "spicy.local", "layout": Object { "dimensions": Object { "height": 720, @@ -7821,6 +7821,43 @@ exports[`ReportListing Report job listing with some items 1`] = ` + +
+ + + See report info for warnings. + + +
+
+ +
+ + + + This is a deprecated export type. Automation of this report will need to be re-created for compatibility with future versions of Kibana. + + +
+
@@ -7865,7 +7902,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` "csv_contains_formulas": undefined, "id": "k905zdw11d34cbae0c3y6tzh", "index": ".reporting-2020.04.12", - "isDeprecated": false, + "isDeprecated": true, "jobtype": "printable_pdf", "kibana_id": "5b2de169-2785-441b-ae8c-186a1936b17d", "kibana_name": "spicy.local", @@ -7911,7 +7948,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` "csv_contains_formulas": undefined, "id": "k905zdw11d34cbae0c3y6tzh", "index": ".reporting-2020.04.12", - "isDeprecated": false, + "isDeprecated": true, "jobtype": "printable_pdf", "kibana_id": "5b2de169-2785-441b-ae8c-186a1936b17d", "kibana_name": "spicy.local", @@ -8083,7 +8120,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` "csv_contains_formulas": undefined, "id": "k905zdw11d34cbae0c3y6tzh", "index": ".reporting-2020.04.12", - "isDeprecated": false, + "isDeprecated": true, "jobtype": "printable_pdf", "kibana_id": "5b2de169-2785-441b-ae8c-186a1936b17d", "kibana_name": "spicy.local", @@ -8145,7 +8182,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` } > @@ -8331,7 +8368,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` "csv_contains_formulas": undefined, "id": "k905zdw11d34cbae0c3y6tzh", "index": ".reporting-2020.04.12", - "isDeprecated": false, + "isDeprecated": true, "jobtype": "printable_pdf", "kibana_id": "5b2de169-2785-441b-ae8c-186a1936b17d", "kibana_name": "spicy.local", @@ -8532,7 +8569,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` "csv_contains_formulas": undefined, "id": "k905zdw11d34cbae0c3y6tzh", "index": ".reporting-2020.04.12", - "isDeprecated": false, + "isDeprecated": true, "jobtype": "printable_pdf", "kibana_id": "5b2de169-2785-441b-ae8c-186a1936b17d", "kibana_name": "spicy.local", @@ -8594,7 +8631,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` } > @@ -8606,16 +8643,16 @@ exports[`ReportListing Report job listing with some items 1`] = ` > + + + ); + }; + + const onIsModifiedChange = jest.fn(); + const isFormModified = () => + onIsModifiedChange.mock.calls[onIsModifiedChange.mock.calls.length - 1][0]; + + const setup = registerTestBed(TestComp, { + defaultProps: { onIsModifiedChange }, + memoryRouter: { wrapComponent: false }, + }); + + test('should return true **only** when the field value differs from its initial value', async () => { + const { form } = await setup(); + + expect(isFormModified()).toBe(false); + + await act(async () => { + form.setInputValue('nameField', 'changed'); + }); + + expect(isFormModified()).toBe(true); + + // Put back to the initial value --> isModified should be false + await act(async () => { + form.setInputValue('nameField', 'initialValue'); + }); + expect(isFormModified()).toBe(false); + }); + + test('should accepts a list of field to discard', async () => { + const { form } = await setup({ discard: ['toDiscard'] }); + + expect(isFormModified()).toBe(false); + + await act(async () => { + form.setInputValue('toDiscardField', 'changed'); + }); + + // It should still not be modififed + expect(isFormModified()).toBe(false); + }); + + test('should take into account if a field is removed from the DOM **and** it existed on the form "defaultValue"', async () => { + const { find } = await setup(); + + expect(isFormModified()).toBe(false); + + await act(async () => { + find('hideNameButton').simulate('click'); + }); + expect(isFormModified()).toBe(true); + + // Put back the name + await act(async () => { + find('hideNameButton').simulate('click'); + }); + expect(isFormModified()).toBe(false); + + // Hide the lastname which is **not** in the form defaultValue + // this it won't set the form isModified to true + await act(async () => { + find('hideLastNameButton').simulate('click'); + }); + expect(isFormModified()).toBe(false); + }); +}); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts new file mode 100644 index 0000000000000..d87c44e614c04 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts @@ -0,0 +1,97 @@ +/* + * 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 { useMemo } from 'react'; +import { get } from 'lodash'; + +import { FieldHook, FormHook } from '../types'; +import { useFormContext } from '../form_context'; +import { useFormData } from './use_form_data'; + +interface Options { + form?: FormHook; + /** List of field paths to discard when checking if a field has been modified */ + discard?: string[]; +} + +/** + * Hook to detect if any of the form fields have been modified by the user. + * If a field is modified and then the value is changed back to the initial value + * the form **won't be marked as modified**. + * This is useful to detect if a form has changed and we need to display a confirm modal + * to the user before he navigates away and loses his changes. + * + * @param options - Optional options object + * @returns flag to indicate if the form has been modified + */ +export const useFormIsModified = ({ + form: formFromOptions, + discard = [], +}: Options = {}): boolean => { + // As hook calls can not be conditional we first try to access the form through context + let form = useFormContext({ throwIfNotFound: false }); + + if (formFromOptions) { + form = formFromOptions; + } + + if (!form) { + throw new Error( + `useFormIsModified() used outside the form context and no form was provided in the options.` + ); + } + + const { getFields, __getFieldsRemoved, __getFormDefaultValue } = form; + + const discardToString = JSON.stringify(discard); + + // Create a map of the fields to discard to optimize look up + const fieldsToDiscard = useMemo(() => { + if (discard.length === 0) { + return; + } + + return discard.reduce((acc, path) => ({ ...acc, [path]: {} }), {} as { [key: string]: {} }); + + // discardToString === discard, we don't want to add it to the deps so we + // the coansumer does not need to memoize the array he provides. + }, [discardToString]); // eslint-disable-line react-hooks/exhaustive-deps + + // We listen to all the form data change to trigger a re-render + // and update our derived "isModified" state + useFormData({ form }); + + let predicate: (arg: [string, FieldHook]) => boolean = () => true; + + if (fieldsToDiscard) { + predicate = ([path]) => fieldsToDiscard[path] === undefined; + } + + let isModified = Object.entries(getFields()) + .filter(predicate) + .some(([_, field]) => field.isModified); + + if (isModified) { + return isModified; + } + + // Check if any field has been removed. + // If somme field has been removed **and** they were originaly present on the + // form "defaultValue" then the form has been modified. + const formDefaultValue = __getFormDefaultValue(); + const fieldOnFormDefaultValue = (path: string) => Boolean(get(formDefaultValue, path)); + + const fieldsRemovedFromDOM: string[] = fieldsToDiscard + ? Object.keys(__getFieldsRemoved()) + .filter((path) => fieldsToDiscard[path] === undefined) + .filter(fieldOnFormDefaultValue) + : Object.keys(__getFieldsRemoved()).filter(fieldOnFormDefaultValue); + + isModified = fieldsRemovedFromDOM.length > 0; + + return isModified; +}; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts index 72dbea3b14cce..19121bb6753a0 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts @@ -6,9 +6,9 @@ * Side Public License, v 1. */ -// Only export the useForm hook. The "useField" hook is for internal use -// as the consumer of the library must use the component -export { useForm, useFormData } from './hooks'; +// We don't export the "useField" hook as it is for internal use. +// The consumer of the library must use the component to create a field +export { useForm, useFormData, useFormIsModified } from './hooks'; export { getFieldValidityAndErrorMessage } from './helpers'; export * from './form_context'; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts index 4e9ff29f0cdd3..151adea30c4f1 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts @@ -62,6 +62,8 @@ export interface FormHook __updateFormDataAt: (field: string, value: unknown) => void; __updateDefaultValueAt: (field: string, value: unknown) => void; __readFieldConfigFromSchema: (field: string) => FieldConfig; + __getFormDefaultValue: () => FormData; + __getFieldsRemoved: () => FieldsMap; } export type FormSchema = { @@ -109,6 +111,8 @@ export interface FieldHook { readonly errors: ValidationError[]; readonly isValid: boolean; readonly isPristine: boolean; + readonly isDirty: boolean; + readonly isModified: boolean; readonly isValidating: boolean; readonly isValidated: boolean; readonly isChangingValue: boolean; diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts new file mode 100644 index 0000000000000..0d58b2ce89358 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts @@ -0,0 +1,42 @@ +/* + * 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 { act } from 'react-dom/test-utils'; +import { registerTestBed, TestBed } from '@kbn/test/jest'; + +import { Context } from '../../public/components/field_editor_context'; +import { FieldEditor, Props } from '../../public/components/field_editor/field_editor'; +import { WithFieldEditorDependencies, getCommonActions } from './helpers'; + +export const defaultProps: Props = { + onChange: jest.fn(), + syntaxError: { + error: null, + clear: () => {}, + }, +}; + +export type FieldEditorTestBed = TestBed & { actions: ReturnType }; + +export const setup = async (props?: Partial, deps?: Partial) => { + let testBed: TestBed; + + await act(async () => { + testBed = await registerTestBed(WithFieldEditorDependencies(FieldEditor, deps), { + memoryRouter: { + wrapComponent: false, + }, + })({ ...defaultProps, ...props }); + }); + testBed!.component.update(); + + const actions = { + ...getCommonActions(testBed!), + }; + + return { ...testBed!, actions }; +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.test.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx similarity index 72% rename from src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.test.tsx rename to src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx index dfea1a94de7fa..4a4c42f69fc8e 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.test.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx @@ -5,65 +5,25 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import React, { useState, useMemo } from 'react'; import { act } from 'react-dom/test-utils'; - -import '../../test_utils/setup_environment'; -import { registerTestBed, TestBed, getCommonActions } from '../../test_utils'; -import { RuntimeFieldPainlessError } from '../../lib'; -import { Field } from '../../types'; -import { FieldEditor, Props, FieldEditorFormState } from './field_editor'; -import { docLinksServiceMock } from '../../../../../core/public/mocks'; - -const defaultProps: Props = { - onChange: jest.fn(), - links: docLinksServiceMock.createStartContract() as any, - ctx: { - existingConcreteFields: [], - namesNotAllowed: [], - fieldTypeToProcess: 'runtime', - }, - indexPattern: { fields: [] } as any, - fieldFormatEditors: { - getAll: () => [], - getById: () => undefined, - }, - fieldFormats: {} as any, - uiSettings: {} as any, - syntaxError: { - error: null, - clear: () => {}, - }, -}; - -const setup = (props?: Partial) => { - const testBed = registerTestBed(FieldEditor, { - memoryRouter: { - wrapComponent: false, - }, - })({ ...defaultProps, ...props }) as TestBed; - - const actions = { - ...getCommonActions(testBed), - }; - - return { - ...testBed, - actions, - }; -}; +import { registerTestBed, TestBed } from '@kbn/test/jest'; + +// This import needs to come first as it contains the jest.mocks +import { setupEnvironment, getCommonActions, WithFieldEditorDependencies } from './helpers'; +import { + FieldEditor, + FieldEditorFormState, + Props, +} from '../../public/components/field_editor/field_editor'; +import type { Field } from '../../public/types'; +import type { RuntimeFieldPainlessError } from '../../public/lib'; +import { setup, FieldEditorTestBed, defaultProps } from './field_editor.helpers'; describe('', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - }); + const { server, httpRequestsMockHelpers } = setupEnvironment(); - let testBed: TestBed & { actions: ReturnType }; + let testBed: FieldEditorTestBed; let onChange: jest.Mock = jest.fn(); const lastOnChangeCall = (): FieldEditorFormState[] => @@ -104,12 +64,22 @@ describe('', () => { return formState!; }; - beforeEach(() => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + server.restore(); + }); + + beforeEach(async () => { onChange = jest.fn(); + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['mockedScriptValue'] }); }); - test('initial state should have "set custom label", "set value" and "set format" turned off', () => { - testBed = setup(); + test('initial state should have "set custom label", "set value" and "set format" turned off', async () => { + testBed = await setup(); ['customLabel', 'value', 'format'].forEach((row) => { const testSubj = `${row}Row.toggle`; @@ -132,7 +102,7 @@ describe('', () => { script: { source: 'emit("hello")' }, }; - testBed = setup({ onChange, field }); + testBed = await setup({ onChange, field }); expect(onChange).toHaveBeenCalled(); @@ -153,25 +123,22 @@ describe('', () => { describe('validation', () => { test('should accept an optional list of existing fields and prevent creating duplicates', async () => { const existingFields = ['myRuntimeField']; - testBed = setup({ - onChange, - ctx: { + testBed = await setup( + { + onChange, + }, + { namesNotAllowed: existingFields, existingConcreteFields: [], fieldTypeToProcess: 'runtime', - }, - }); + } + ); const { form, component, actions } = testBed; - await act(async () => { - actions.toggleFormRow('value'); - }); - - await act(async () => { - form.setInputValue('nameField.input', existingFields[0]); - form.setInputValue('scriptField', 'echo("hello")'); - }); + await actions.toggleFormRow('value'); + await actions.fields.updateName(existingFields[0]); + await actions.fields.updateScript('echo("hello")'); await act(async () => { jest.advanceTimersByTime(1000); // Make sure our debounced error message is in the DOM @@ -192,20 +159,23 @@ describe('', () => { script: { source: 'emit("hello"' }, }; - testBed = setup({ - field, - onChange, - ctx: { + testBed = await setup( + { + field, + onChange, + }, + { namesNotAllowed: existingRuntimeFieldNames, existingConcreteFields: [], fieldTypeToProcess: 'runtime', - }, - }); + } + ); const { form, component } = testBed; const lastState = getLastStateUpdate(); await submitFormAndGetData(lastState); component.update(); + expect(getLastStateUpdate().isValid).toBe(true); expect(form.getErrorsMessages()).toEqual([]); }); @@ -217,13 +187,14 @@ describe('', () => { script: { source: 'emit(6)' }, }; - const TestComponent = () => { - const dummyError = { - reason: 'Awwww! Painless syntax error', - message: '', - position: { offset: 0, start: 0, end: 0 }, - scriptStack: [''], - }; + const dummyError = { + reason: 'Awwww! Painless syntax error', + message: '', + position: { offset: 0, start: 0, end: 0 }, + scriptStack: [''], + }; + + const ComponentToProvidePainlessSyntaxErrors = () => { const [error, setError] = useState(null); const clearError = useMemo(() => () => setError(null), []); const syntaxError = useMemo(() => ({ error, clear: clearError }), [error, clearError]); @@ -240,22 +211,29 @@ describe('', () => { ); }; - const customTestbed = registerTestBed(TestComponent, { - memoryRouter: { - wrapComponent: false, - }, - })() as TestBed; + let testBedToCapturePainlessErrors: TestBed; + + await act(async () => { + testBedToCapturePainlessErrors = await registerTestBed( + WithFieldEditorDependencies(ComponentToProvidePainlessSyntaxErrors), + { + memoryRouter: { + wrapComponent: false, + }, + } + )(); + }); testBed = { - ...customTestbed, - actions: getCommonActions(customTestbed), + ...testBedToCapturePainlessErrors!, + actions: getCommonActions(testBedToCapturePainlessErrors!), }; const { form, component, find, - actions: { changeFieldType }, + actions: { fields }, } = testBed; // We set some dummy painless error @@ -267,7 +245,7 @@ describe('', () => { expect(form.getErrorsMessages()).toEqual(['Awwww! Painless syntax error']); // We change the type and expect the form error to not be there anymore - await changeFieldType('keyword'); + await fields.updateType('keyword'); expect(form.getErrorsMessages()).toEqual([]); }); }); diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts new file mode 100644 index 0000000000000..5b916c1cd9960 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts @@ -0,0 +1,46 @@ +/* + * 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 { act } from 'react-dom/test-utils'; +import { registerTestBed, TestBed } from '@kbn/test/jest'; + +import { Context } from '../../public/components/field_editor_context'; +import { + FieldEditorFlyoutContent, + Props, +} from '../../public/components/field_editor_flyout_content'; +import { WithFieldEditorDependencies, getCommonActions } from './helpers'; + +const defaultProps: Props = { + onSave: () => {}, + onCancel: () => {}, + runtimeFieldValidator: () => Promise.resolve(null), + isSavingField: false, +}; + +const getActions = (testBed: TestBed) => { + return { + ...getCommonActions(testBed), + }; +}; + +export const setup = async (props?: Partial, deps?: Partial) => { + let testBed: TestBed; + + // Setup testbed + await act(async () => { + testBed = await registerTestBed(WithFieldEditorDependencies(FieldEditorFlyoutContent, deps), { + memoryRouter: { + wrapComponent: false, + }, + })({ ...defaultProps, ...props }); + }); + + testBed!.component.update(); + + return { ...testBed!, actions: getActions(testBed!) }; +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.test.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts similarity index 66% rename from src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.test.ts rename to src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts index ed71e40fc80a9..9b00ff762fe8f 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.test.ts +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts @@ -7,58 +7,30 @@ */ import { act } from 'react-dom/test-utils'; -import '../test_utils/setup_environment'; -import { registerTestBed, TestBed, noop, getCommonActions } from '../test_utils'; - -import { FieldEditor } from './field_editor'; -import { FieldEditorFlyoutContent, Props } from './field_editor_flyout_content'; -import { docLinksServiceMock } from '../../../../core/public/mocks'; - -const defaultProps: Props = { - onSave: noop, - onCancel: noop, - docLinks: docLinksServiceMock.createStartContract() as any, - FieldEditor, - indexPattern: { fields: [] } as any, - uiSettings: {} as any, - fieldFormats: {} as any, - fieldFormatEditors: {} as any, - fieldTypeToProcess: 'runtime', - runtimeFieldValidator: () => Promise.resolve(null), - isSavingField: false, -}; - -const setup = (props: Props = defaultProps) => { - const testBed = registerTestBed(FieldEditorFlyoutContent, { - memoryRouter: { wrapComponent: false }, - })(props) as TestBed; - - const actions = { - ...getCommonActions(testBed), - }; - - return { - ...testBed, - actions, - }; -}; +import type { Props } from '../../public/components/field_editor_flyout_content'; +import { setupEnvironment } from './helpers'; +import { setup } from './field_editor_flyout_content.helpers'; describe('', () => { + const { server, httpRequestsMockHelpers } = setupEnvironment(); + beforeAll(() => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['foo'] }); jest.useFakeTimers(); }); afterAll(() => { jest.useRealTimers(); + server.restore(); }); - test('should have the correct title', () => { - const { exists, find } = setup(); + test('should have the correct title', async () => { + const { exists, find } = await setup(); expect(exists('flyoutTitle')).toBe(true); expect(find('flyoutTitle').text()).toBe('Create field'); }); - test('should allow a field to be provided', () => { + test('should allow a field to be provided', async () => { const field = { name: 'foo', type: 'ip', @@ -67,7 +39,7 @@ describe('', () => { }, }; - const { find } = setup({ ...defaultProps, field }); + const { find } = await setup({ field }); expect(find('flyoutTitle').text()).toBe(`Edit field 'foo'`); expect(find('nameField.input').props().value).toBe(field.name); @@ -83,7 +55,7 @@ describe('', () => { }; const onSave: jest.Mock = jest.fn(); - const { find } = setup({ ...defaultProps, onSave, field }); + const { find } = await setup({ onSave, field }); await act(async () => { find('fieldSaveButton').simulate('click'); @@ -100,9 +72,9 @@ describe('', () => { expect(fieldReturned).toEqual(field); }); - test('should accept an onCancel prop', () => { + test('should accept an onCancel prop', async () => { const onCancel = jest.fn(); - const { find } = setup({ ...defaultProps, onCancel }); + const { find } = await setup({ onCancel }); find('closeFlyoutButton').simulate('click'); @@ -113,7 +85,7 @@ describe('', () => { test('should validate the fields and prevent saving invalid form', async () => { const onSave: jest.Mock = jest.fn(); - const { find, exists, form, component } = setup({ ...defaultProps, onSave }); + const { find, exists, form, component } = await setup({ onSave }); expect(find('fieldSaveButton').props().disabled).toBe(false); @@ -139,20 +111,12 @@ describe('', () => { const { find, - component, - form, - actions: { toggleFormRow, changeFieldType }, - } = setup({ ...defaultProps, onSave }); - - act(() => { - form.setInputValue('nameField.input', 'someName'); - toggleFormRow('value'); - }); - component.update(); + actions: { toggleFormRow, fields }, + } = await setup({ onSave }); - await act(async () => { - form.setInputValue('scriptField', 'echo("hello")'); - }); + await fields.updateName('someName'); + await toggleFormRow('value'); + await fields.updateScript('echo("hello")'); await act(async () => { // Let's make sure that validation has finished running @@ -174,7 +138,7 @@ describe('', () => { }); // Change the type and make sure it is forwarded - await changeFieldType('other_type', 'Other type'); + await fields.updateType('other_type', 'Other type'); await act(async () => { find('fieldSaveButton').simulate('click'); diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts new file mode 100644 index 0000000000000..068ebce638aa1 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts @@ -0,0 +1,185 @@ +/* + * 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 { act } from 'react-dom/test-utils'; +import { ReactWrapper } from 'enzyme'; +import { registerTestBed, TestBed } from '@kbn/test/jest'; + +import { API_BASE_PATH } from '../../common/constants'; +import { Context } from '../../public/components/field_editor_context'; +import { + FieldEditorFlyoutContent, + Props, +} from '../../public/components/field_editor_flyout_content'; +import { + WithFieldEditorDependencies, + getCommonActions, + spyIndexPatternGetAllFields, + spySearchQuery, + spySearchQueryResponse, +} from './helpers'; + +const defaultProps: Props = { + onSave: () => {}, + onCancel: () => {}, + runtimeFieldValidator: () => Promise.resolve(null), + isSavingField: false, +}; + +/** + * This handler lets us mock the fields present on the index pattern during our test + * @param fields The fields of the index pattern + */ +export const setIndexPatternFields = (fields: Array<{ name: string; displayName: string }>) => { + spyIndexPatternGetAllFields.mockReturnValue(fields); +}; + +export interface TestDoc { + title: string; + subTitle: string; + description: string; +} + +export const getSearchCallMeta = () => { + const totalCalls = spySearchQuery.mock.calls.length; + const lastCall = spySearchQuery.mock.calls[totalCalls - 1] ?? null; + let lastCallParams = null; + + if (lastCall) { + lastCallParams = lastCall[0]; + } + + return { + totalCalls, + lastCall, + lastCallParams, + }; +}; + +export const setSearchResponse = ( + documents: Array<{ _id: string; _index: string; _source: TestDoc }> +) => { + spySearchQueryResponse.mockResolvedValue({ + rawResponse: { + hits: { + total: documents.length, + hits: documents, + }, + }, + }); +}; + +const getActions = (testBed: TestBed) => { + const getWrapperRenderedIndexPatternFields = (): ReactWrapper | null => { + if (testBed.find('indexPatternFieldList').length === 0) { + return null; + } + return testBed.find('indexPatternFieldList.listItem'); + }; + + const getRenderedIndexPatternFields = (): Array<{ key: string; value: string }> => { + const allFields = getWrapperRenderedIndexPatternFields(); + + if (allFields === null) { + return []; + } + + return allFields.map((field) => { + const key = testBed.find('key', field).text(); + const value = testBed.find('value', field).text(); + return { key, value }; + }); + }; + + const getRenderedFieldsPreview = () => { + if (testBed.find('fieldPreviewItem').length === 0) { + return []; + } + + const previewFields = testBed.find('fieldPreviewItem.listItem'); + + return previewFields.map((field) => { + const key = testBed.find('key', field).text(); + const value = testBed.find('value', field).text(); + return { key, value }; + }); + }; + + const setFilterFieldsValue = async (value: string) => { + await act(async () => { + testBed.form.setInputValue('filterFieldsInput', value); + }); + + testBed.component.update(); + }; + + // Need to set "server: any" (instead of SinonFakeServer) to avoid a TS error :( + // Error: Exported variable 'setup' has or is using name 'Document' from external module "/dev/shm/workspace/parallel/14/kibana/node_modules/@types/sinon/ts3.1/index" + const getLatestPreviewHttpRequest = (server: any) => { + let i = server.requests.length - 1; + + while (i >= 0) { + const request = server.requests[i]; + if (request.method === 'POST' && request.url === `${API_BASE_PATH}/field_preview`) { + return { + ...request, + requestBody: JSON.parse(JSON.parse(request.requestBody).body), + }; + } + i--; + } + + throw new Error(`Can't access the latest preview HTTP request as it hasn't been called.`); + }; + + const goToNextDocument = async () => { + await act(async () => { + testBed.find('goToNextDocButton').simulate('click'); + }); + testBed.component.update(); + }; + + const goToPreviousDocument = async () => { + await act(async () => { + testBed.find('goToPrevDocButton').simulate('click'); + }); + testBed.component.update(); + }; + + const loadCustomDocument = (docId: string) => {}; + + return { + ...getCommonActions(testBed), + getWrapperRenderedIndexPatternFields, + getRenderedIndexPatternFields, + getRenderedFieldsPreview, + setFilterFieldsValue, + getLatestPreviewHttpRequest, + goToNextDocument, + goToPreviousDocument, + loadCustomDocument, + }; +}; + +export const setup = async (props?: Partial, deps?: Partial) => { + let testBed: TestBed; + + // Setup testbed + await act(async () => { + testBed = await registerTestBed(WithFieldEditorDependencies(FieldEditorFlyoutContent, deps), { + memoryRouter: { + wrapComponent: false, + }, + })({ ...defaultProps, ...props }); + }); + + testBed!.component.update(); + + return { ...testBed!, actions: getActions(testBed!) }; +}; + +export type FieldEditorFlyoutContentTestBed = TestBed & { actions: ReturnType }; diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts new file mode 100644 index 0000000000000..65089bc24317b --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts @@ -0,0 +1,890 @@ +/* + * 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 { act } from 'react-dom/test-utils'; + +import { setupEnvironment, fieldFormatsOptions, indexPatternNameForTest } from './helpers'; +import { + setup, + setIndexPatternFields, + getSearchCallMeta, + setSearchResponse, + FieldEditorFlyoutContentTestBed, + TestDoc, +} from './field_editor_flyout_preview.helpers'; +import { createPreviewError } from './helpers/mocks'; + +interface EsDoc { + _id: string; + _index: string; + _source: TestDoc; +} + +describe('Field editor Preview panel', () => { + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + server.restore(); + }); + + let testBed: FieldEditorFlyoutContentTestBed; + + const mockDocuments: EsDoc[] = [ + { + _id: '001', + _index: 'testIndex', + _source: { + title: 'First doc - title', + subTitle: 'First doc - subTitle', + description: 'First doc - description', + }, + }, + { + _id: '002', + _index: 'testIndex', + _source: { + title: 'Second doc - title', + subTitle: 'Second doc - subTitle', + description: 'Second doc - description', + }, + }, + { + _id: '003', + _index: 'testIndex', + _source: { + title: 'Third doc - title', + subTitle: 'Third doc - subTitle', + description: 'Third doc - description', + }, + }, + ]; + + const [doc1, doc2, doc3] = mockDocuments; + + const indexPatternFields: Array<{ name: string; displayName: string }> = [ + { + name: 'title', + displayName: 'title', + }, + { + name: 'subTitle', + displayName: 'subTitle', + }, + { + name: 'description', + displayName: 'description', + }, + ]; + + beforeEach(async () => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['mockedScriptValue'] }); + setIndexPatternFields(indexPatternFields); + setSearchResponse(mockDocuments); + + testBed = await setup(); + }); + + test('should display the preview panel when either "set value" or "set format" is activated', async () => { + const { + exists, + actions: { toggleFormRow }, + } = testBed; + + expect(exists('previewPanel')).toBe(false); + + await toggleFormRow('value'); + expect(exists('previewPanel')).toBe(true); + + await toggleFormRow('value', 'off'); + expect(exists('previewPanel')).toBe(false); + + await toggleFormRow('format'); + expect(exists('previewPanel')).toBe(true); + + await toggleFormRow('format', 'off'); + expect(exists('previewPanel')).toBe(false); + }); + + test('should correctly set the title and subtitle of the panel', async () => { + const { + find, + actions: { toggleFormRow, fields, waitForUpdates }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await waitForUpdates(); + + expect(find('previewPanel.title').text()).toBe('Preview'); + expect(find('previewPanel.subTitle').text()).toBe(`From: ${indexPatternNameForTest}`); + }); + + test('should list the list of fields of the index pattern', async () => { + const { + actions: { toggleFormRow, fields, getRenderedIndexPatternFields, waitForUpdates }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await waitForUpdates(); + + expect(getRenderedIndexPatternFields()).toEqual([ + { + key: 'title', + value: mockDocuments[0]._source.title, + }, + { + key: 'subTitle', + value: mockDocuments[0]._source.subTitle, + }, + { + key: 'description', + value: mockDocuments[0]._source.description, + }, + ]); + }); + + test('should filter down the field in the list', async () => { + const { + exists, + find, + component, + actions: { + toggleFormRow, + fields, + setFilterFieldsValue, + getRenderedIndexPatternFields, + waitForUpdates, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await waitForUpdates(); + + // Should find a single field + await setFilterFieldsValue('descr'); + expect(getRenderedIndexPatternFields()).toEqual([ + { key: 'description', value: 'First doc - description' }, + ]); + + // Should be case insensitive + await setFilterFieldsValue('title'); + expect(exists('emptySearchResult')).toBe(false); + expect(getRenderedIndexPatternFields()).toEqual([ + { key: 'title', value: 'First doc - title' }, + { key: 'subTitle', value: 'First doc - subTitle' }, + ]); + + // Should display an empty search result with a button to clear + await setFilterFieldsValue('doesNotExist'); + expect(exists('emptySearchResult')).toBe(true); + expect(getRenderedIndexPatternFields()).toEqual([]); + expect(exists('emptySearchResult.clearSearchButton')); + + find('emptySearchResult.clearSearchButton').simulate('click'); + component.update(); + expect(getRenderedIndexPatternFields()).toEqual([ + { + key: 'title', + value: mockDocuments[0]._source.title, + }, + { + key: 'subTitle', + value: mockDocuments[0]._source.subTitle, + }, + { + key: 'description', + value: mockDocuments[0]._source.description, + }, + ]); + }); + + test('should pin the field to the top of the list', async () => { + const { + find, + component, + actions: { + toggleFormRow, + fields, + getWrapperRenderedIndexPatternFields, + getRenderedIndexPatternFields, + waitForUpdates, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await waitForUpdates(); + + const fieldsRendered = getWrapperRenderedIndexPatternFields(); + + if (fieldsRendered === null) { + throw new Error('No index pattern field rendered.'); + } + + expect(fieldsRendered.length).toBe(Object.keys(doc1._source).length); + // make sure that the last one if the "description" field + expect(fieldsRendered.at(2).text()).toBe('descriptionFirst doc - description'); + + // Click the third field in the list ("description") + const descriptionField = fieldsRendered.at(2); + find('pinFieldButton', descriptionField).simulate('click'); + component.update(); + + expect(getRenderedIndexPatternFields()).toEqual([ + { key: 'description', value: 'First doc - description' }, // Pinned! + { key: 'title', value: 'First doc - title' }, + { key: 'subTitle', value: 'First doc - subTitle' }, + ]); + }); + + describe('empty prompt', () => { + test('should display an empty prompt if no name and no script are defined', async () => { + const { + exists, + actions: { toggleFormRow, fields, waitForUpdates }, + } = testBed; + + await toggleFormRow('value'); + expect(exists('previewPanel')).toBe(true); + expect(exists('previewPanel.emptyPrompt')).toBe(true); + + await fields.updateName('someName'); + await waitForUpdates(); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + + await fields.updateName(' '); + await waitForUpdates(); + expect(exists('previewPanel.emptyPrompt')).toBe(true); + + // The name is empty and the empty prompt is displayed, let's now add a script... + await fields.updateScript('echo("hello")'); + await waitForUpdates(); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + + await fields.updateScript(' '); + await waitForUpdates(); + expect(exists('previewPanel.emptyPrompt')).toBe(true); + }); + + test('should **not** display an empty prompt editing a document with a script', async () => { + const field = { + name: 'foo', + type: 'ip', + script: { + source: 'emit("hello world")', + }, + }; + + // We open the editor with a field to edit. The preview panel should be open + // and the empty prompt should not be there as we have a script and we'll load + // the preview. + await act(async () => { + testBed = await setup({ field }); + }); + + const { exists, component } = testBed; + component.update(); + + expect(exists('previewPanel')).toBe(true); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + }); + + test('should **not** display an empty prompt editing a document with format defined', async () => { + const field = { + name: 'foo', + type: 'ip', + format: { + id: 'upper', + params: {}, + }, + }; + + // We open the editor with a field to edit. The preview panel should be open + // and the empty prompt should not be there as we have a script and we'll load + // the preview. + await act(async () => { + testBed = await setup({ field }); + }); + + const { exists, component } = testBed; + component.update(); + + expect(exists('previewPanel')).toBe(true); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + }); + }); + + describe('key & value', () => { + test('should set an empty value when no script is provided', async () => { + const { + actions: { toggleFormRow, fields, getRenderedFieldsPreview, waitForUpdates }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await waitForUpdates(); + + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: '-' }]); + }); + + test('should set the value returned by the painless _execute API', async () => { + const scriptEmitResponse = 'Field emit() response'; + httpRequestsMockHelpers.setFieldPreviewResponse({ values: [scriptEmitResponse] }); + + const { + actions: { + toggleFormRow, + fields, + waitForDocumentsAndPreviewUpdate, + getLatestPreviewHttpRequest, + getRenderedFieldsPreview, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello")'); + await waitForDocumentsAndPreviewUpdate(); + const request = getLatestPreviewHttpRequest(server); + + // Make sure the payload sent is correct + expect(request.requestBody).toEqual({ + context: 'keyword_field', + document: { + description: 'First doc - description', + subTitle: 'First doc - subTitle', + title: 'First doc - title', + }, + index: 'testIndex', + script: { + source: 'echo("hello")', + }, + }); + + // And that we display the response + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'myRuntimeField', value: scriptEmitResponse }, + ]); + }); + + test('should display an updating indicator while fetching the preview', async () => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] }); + + const { + exists, + actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate }, + } = testBed; + + await toggleFormRow('value'); + await waitForUpdates(); // wait for docs to be fetched + expect(exists('isUpdatingIndicator')).toBe(false); + + await fields.updateScript('echo("hello")'); + expect(exists('isUpdatingIndicator')).toBe(true); + + await waitForDocumentsAndPreviewUpdate(); + expect(exists('isUpdatingIndicator')).toBe(false); + }); + + test('should not display the updating indicator when neither the type nor the script has changed', async () => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] }); + + const { + exists, + actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate }, + } = testBed; + + await toggleFormRow('value'); + await waitForUpdates(); // wait for docs to be fetched + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello")'); + expect(exists('isUpdatingIndicator')).toBe(true); + await waitForDocumentsAndPreviewUpdate(); + expect(exists('isUpdatingIndicator')).toBe(false); + + await fields.updateName('nameChanged'); + // We haven't changed the type nor the script so there should not be any updating indicator + expect(exists('isUpdatingIndicator')).toBe(false); + }); + + describe('read from _source', () => { + test('should display the _source value when no script is provided and the name matched one of the fields in _source', async () => { + const { + actions: { + toggleFormRow, + fields, + getRenderedFieldsPreview, + waitForDocumentsAndPreviewUpdate, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('subTitle'); + await waitForDocumentsAndPreviewUpdate(); + + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'subTitle', value: 'First doc - subTitle' }, + ]); + }); + + test('should display the value returned by the _execute API and fallback to _source if "Set value" is turned off', async () => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueFromExecuteAPI'] }); + + const { + actions: { toggleFormRow, fields, waitForUpdates, getRenderedFieldsPreview }, + } = testBed; + + await toggleFormRow('value'); + await waitForUpdates(); // fetch documents + await fields.updateName('description'); // Field name is a field in _source + await fields.updateScript('echo("hello")'); + await waitForUpdates(); // fetch preview + + // We render the value from the _execute API + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'description', value: 'valueFromExecuteAPI' }, + ]); + + await toggleFormRow('format', 'on'); + await toggleFormRow('value', 'off'); + + // Fallback to _source value when "Set value" is turned off and we have a format + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'description', value: 'First doc - description' }, + ]); + }); + }); + }); + + describe('format', () => { + test('should apply the format to the value', async () => { + /** + * Each of the formatter has already its own test. Here we are simply + * doing a smoke test to make sure that the preview panel applies the formatter + * to the runtime field value. + * We do that by mocking (in "setup_environment.tsx") the implementation of the + * the fieldFormats.getInstance() handler. + */ + const scriptEmitResponse = 'hello'; + httpRequestsMockHelpers.setFieldPreviewResponse({ values: [scriptEmitResponse] }); + + const { + actions: { + toggleFormRow, + fields, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, + getRenderedFieldsPreview, + }, + } = testBed; + + await fields.updateName('myRuntimeField'); + await toggleFormRow('value'); + await fields.updateScript('echo("hello")'); + await waitForDocumentsAndPreviewUpdate(); + + // before + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'hello' }]); + + // after + await toggleFormRow('format'); + await fields.updateFormat(fieldFormatsOptions[0].id); // select 'upper' format + await waitForUpdates(); + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'HELLO' }]); + }); + }); + + describe('error handling', () => { + test('should display the error returned by the Painless _execute API', async () => { + const error = createPreviewError({ reason: 'Houston we got a problem' }); + httpRequestsMockHelpers.setFieldPreviewResponse({ values: [], error, status: 400 }); + + const { + exists, + find, + actions: { + toggleFormRow, + fields, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, + getRenderedFieldsPreview, + }, + } = testBed; + + await fields.updateName('myRuntimeField'); + await toggleFormRow('value'); + await fields.updateScript('bad()'); + await waitForDocumentsAndPreviewUpdate(); + + expect(exists('fieldPreviewItem')).toBe(false); + expect(exists('indexPatternFieldList')).toBe(false); + expect(exists('previewError')).toBe(true); + expect(find('previewError.reason').text()).toBe(error.caused_by.reason); + + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] }); + await fields.updateScript('echo("ok")'); + await waitForUpdates(); + + expect(exists('fieldPreviewItem')).toBe(true); + expect(find('indexPatternFieldList.listItem').length).toBeGreaterThan(0); + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'ok' }]); + }); + + test('should handle error when a document is not found', async () => { + const { + exists, + find, + form, + actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate }, + } = testBed; + + await fields.updateName('myRuntimeField'); + await toggleFormRow('value'); + await waitForDocumentsAndPreviewUpdate(); + + // We will return no document from the search + setSearchResponse([]); + + await act(async () => { + form.setInputValue('documentIdField', 'wrongID'); + }); + await waitForUpdates(); + + expect(exists('previewError')).toBe(true); + expect(find('previewError').text()).toContain('Document ID not found'); + expect(exists('isUpdatingIndicator')).toBe(false); + }); + }); + + describe('Cluster document load and navigation', () => { + const customLoadedDoc: EsDoc = { + _id: '123456', + _index: 'otherIndex', + _source: { + title: 'loaded doc - title', + subTitle: 'loaded doc - subTitle', + description: 'loaded doc - description', + }, + }; + + test('should update the field list when the document changes', async () => { + const { + actions: { + toggleFormRow, + fields, + getRenderedIndexPatternFields, + goToNextDocument, + goToPreviousDocument, + waitForUpdates, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await waitForUpdates(); + + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: doc1._source.title, + }); + + await goToNextDocument(); + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: doc2._source.title, + }); + + await goToNextDocument(); + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: doc3._source.title, + }); + + // Going next we circle back to the first document of the list + await goToNextDocument(); + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: doc1._source.title, + }); + + // Let's go backward + await goToPreviousDocument(); + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: doc3._source.title, + }); + + await goToPreviousDocument(); + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: doc2._source.title, + }); + }); + + test('should update the field preview value when the document changes', async () => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueDoc1'] }); + const { + actions: { + toggleFormRow, + fields, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, + getRenderedFieldsPreview, + goToNextDocument, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello world")'); + await waitForDocumentsAndPreviewUpdate(); + + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc1' }]); + + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueDoc2'] }); + await goToNextDocument(); + await waitForUpdates(); + + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc2' }]); + }); + + test('should load a custom document when an ID is passed', async () => { + const { + component, + form, + exists, + actions: { + toggleFormRow, + fields, + getRenderedIndexPatternFields, + getRenderedFieldsPreview, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello world")'); + await waitForDocumentsAndPreviewUpdate(); + + // First make sure that we have the original cluster data is loaded + // and the preview value rendered. + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: doc1._source.title, + }); + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'myRuntimeField', value: 'mockedScriptValue' }, + ]); + + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['loadedDocPreview'] }); + setSearchResponse([customLoadedDoc]); + + await act(async () => { + form.setInputValue('documentIdField', '123456'); + }); + component.update(); + // We immediately remove the index pattern fields + expect(getRenderedIndexPatternFields()).toEqual([]); + + await waitForDocumentsAndPreviewUpdate(); + + expect(getRenderedIndexPatternFields()).toEqual([ + { + key: 'title', + value: 'loaded doc - title', + }, + { + key: 'subTitle', + value: 'loaded doc - subTitle', + }, + { + key: 'description', + value: 'loaded doc - description', + }, + ]); + + await waitForUpdates(); // Then wait for the preview HTTP request + + // The preview should have updated + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'myRuntimeField', value: 'loadedDocPreview' }, + ]); + + // The nav should not be there when loading a single document + expect(exists('documentsNav')).toBe(false); + // There should be a link to load back the cluster data + expect(exists('loadDocsFromClusterButton')).toBe(true); + }); + + test('should load back the cluster data after providing a custom ID', async () => { + const { + form, + component, + find, + actions: { + toggleFormRow, + fields, + getRenderedFieldsPreview, + getRenderedIndexPatternFields, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, + }, + } = testBed; + + await toggleFormRow('value'); + await waitForUpdates(); // fetch documents + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello world")'); + await waitForUpdates(); // fetch preview + + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['loadedDocPreview'] }); + setSearchResponse([customLoadedDoc]); + + // Load a custom document ID + await act(async () => { + form.setInputValue('documentIdField', '123456'); + }); + await waitForDocumentsAndPreviewUpdate(); + + // Load back the cluster data + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['clusterDataDocPreview'] }); + setSearchResponse(mockDocuments); + + await act(async () => { + find('loadDocsFromClusterButton').simulate('click'); + }); + component.update(); + // We immediately remove the index pattern fields + expect(getRenderedIndexPatternFields()).toEqual([]); + + await waitForDocumentsAndPreviewUpdate(); + + // The preview should be updated with the cluster data preview + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'myRuntimeField', value: 'clusterDataDocPreview' }, + ]); + }); + + test('should not lose the state of single document vs cluster data after displaying the empty prompt', async () => { + const { + form, + component, + exists, + actions: { + toggleFormRow, + fields, + getRenderedIndexPatternFields, + waitForDocumentsAndPreviewUpdate, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await waitForDocumentsAndPreviewUpdate(); + + // Initial state where we have the cluster data loaded and the doc navigation + expect(exists('documentsNav')).toBe(true); + expect(exists('loadDocsFromClusterButton')).toBe(false); + + setSearchResponse([customLoadedDoc]); + + await act(async () => { + form.setInputValue('documentIdField', '123456'); + }); + component.update(); + await waitForDocumentsAndPreviewUpdate(); + + expect(exists('documentsNav')).toBe(false); + expect(exists('loadDocsFromClusterButton')).toBe(true); + + // Clearing the name will display the empty prompt as we don't have any script + await fields.updateName(''); + expect(exists('previewPanel.emptyPrompt')).toBe(true); + + // Give another name to hide the empty prompt and show the preview panel back + await fields.updateName('newName'); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + + // We should still display the single document state + expect(exists('documentsNav')).toBe(false); + expect(exists('loadDocsFromClusterButton')).toBe(true); + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: 'loaded doc - title', + }); + }); + + test('should send the correct params to the data plugin search() handler', async () => { + const { + form, + component, + find, + actions: { toggleFormRow, fields, waitForUpdates }, + } = testBed; + + const expectedParamsToFetchClusterData = { + params: { index: 'testIndexPattern', body: { size: 50 } }, + }; + + // Initial state + let searchMeta = getSearchCallMeta(); + const initialCount = searchMeta.totalCalls; + + // Open the preview panel. This will trigger document fetchint + await fields.updateName('myRuntimeField'); + await toggleFormRow('value'); + await waitForUpdates(); + + searchMeta = getSearchCallMeta(); + expect(searchMeta.totalCalls).toBe(initialCount + 1); + expect(searchMeta.lastCallParams).toEqual(expectedParamsToFetchClusterData); + + // Load single doc + setSearchResponse([customLoadedDoc]); + const nextId = '123456'; + await act(async () => { + form.setInputValue('documentIdField', nextId); + }); + component.update(); + await waitForUpdates(); + + searchMeta = getSearchCallMeta(); + expect(searchMeta.totalCalls).toBe(initialCount + 2); + expect(searchMeta.lastCallParams).toEqual({ + params: { + body: { + query: { + ids: { + values: [nextId], + }, + }, + size: 1, + }, + index: 'testIndexPattern', + }, + }); + + // Back to cluster data + setSearchResponse(mockDocuments); + await act(async () => { + find('loadDocsFromClusterButton').simulate('click'); + }); + searchMeta = getSearchCallMeta(); + expect(searchMeta.totalCalls).toBe(initialCount + 3); + expect(searchMeta.lastCallParams).toEqual(expectedParamsToFetchClusterData); + }); + }); +}); diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/common_actions.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/common_actions.ts new file mode 100644 index 0000000000000..ca061968dae20 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/common_actions.ts @@ -0,0 +1,111 @@ +/* + * 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 { act } from 'react-dom/test-utils'; +import { TestBed } from '@kbn/test/jest'; + +export const getCommonActions = (testBed: TestBed) => { + const toggleFormRow = async ( + row: 'customLabel' | 'value' | 'format', + value: 'on' | 'off' = 'on' + ) => { + const testSubj = `${row}Row.toggle`; + const toggle = testBed.find(testSubj); + const isOn = toggle.props()['aria-checked']; + + if ((value === 'on' && isOn) || (value === 'off' && isOn === false)) { + return; + } + + await act(async () => { + testBed.form.toggleEuiSwitch(testSubj); + }); + + testBed.component.update(); + }; + + // Fields + const updateName = async (value: string) => { + await act(async () => { + testBed.form.setInputValue('nameField.input', value); + }); + + testBed.component.update(); + }; + + const updateScript = async (value: string) => { + await act(async () => { + testBed.form.setInputValue('scriptField', value); + }); + + testBed.component.update(); + }; + + const updateType = async (value: string, label?: string) => { + await act(async () => { + testBed.find('typeField').simulate('change', [ + { + value, + label: label ?? value, + }, + ]); + }); + + testBed.component.update(); + }; + + const updateFormat = async (value: string, label?: string) => { + await act(async () => { + testBed.find('editorSelectedFormatId').simulate('change', { target: { value } }); + }); + + testBed.component.update(); + }; + + /** + * Allows us to bypass the debounce time of 500ms before updating the preview. We also simulate + * a 2000ms latency when searching ES documents (see setup_environment.tsx). + */ + const waitForUpdates = async () => { + await act(async () => { + jest.runAllTimers(); + }); + + testBed.component.update(); + }; + + /** + * When often need to both wait for the documents to be fetched and + * the preview to be fetched. We can't increase the `jest.advanceTimersByTime` time + * as those are 2 different operations that occur in sequence. + */ + const waitForDocumentsAndPreviewUpdate = async () => { + // Wait for documents to be fetched + await act(async () => { + jest.runAllTimers(); + }); + + // Wait for preview to update + await act(async () => { + jest.runAllTimers(); + }); + + testBed.component.update(); + }; + + return { + toggleFormRow, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, + fields: { + updateName, + updateType, + updateScript, + updateFormat, + }, + }; +}; diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/http_requests.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/http_requests.ts new file mode 100644 index 0000000000000..4b03db247bad1 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/http_requests.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import sinon, { SinonFakeServer } from 'sinon'; +import { API_BASE_PATH } from '../../../common/constants'; + +type HttpResponse = Record | any[]; + +// Register helpers to mock HTTP Requests +const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { + const setFieldPreviewResponse = (response?: HttpResponse, error?: any) => { + const status = error ? error.body.status || 400 : 200; + const body = error ? JSON.stringify(error.body) : JSON.stringify(response); + + server.respondWith('POST', `${API_BASE_PATH}/field_preview`, [ + status, + { 'Content-Type': 'application/json' }, + body, + ]); + }; + + return { + setFieldPreviewResponse, + }; +}; + +export const init = () => { + const server = sinon.fakeServer.create(); + server.respondImmediately = true; + + // Define default response for unhandled requests. + // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry, + // and we can mock them all with a 200 instead of mocking each one individually. + server.respondWith([200, {}, 'DefaultSinonMockServerResponse']); + + const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server); + + return { + server, + httpRequestsMockHelpers, + }; +}; diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/index.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/index.ts new file mode 100644 index 0000000000000..6a1f1aa74036a --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { findTestSubject, TestBed } from '@kbn/test/jest'; + +export { + setupEnvironment, + WithFieldEditorDependencies, + spySearchQuery, + spySearchQueryResponse, + spyIndexPatternGetAllFields, + fieldFormatsOptions, + indexPatternNameForTest, +} from './setup_environment'; + +export { getCommonActions } from './common_actions'; diff --git a/src/plugins/index_pattern_field_editor/public/test_utils/setup_environment.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx similarity index 78% rename from src/plugins/index_pattern_field_editor/public/test_utils/setup_environment.tsx rename to src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx index 885bcc87f89df..e291ec7b4ca08 100644 --- a/src/plugins/index_pattern_field_editor/public/test_utils/setup_environment.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx @@ -5,44 +5,13 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import React from 'react'; const EDITOR_ID = 'testEditor'; -jest.mock('../../../kibana_react/public', () => { - const original = jest.requireActual('../../../kibana_react/public'); - - /** - * We mock the CodeEditor because it requires the - * with the uiSettings passed down. Let's use a simple in our tests. - */ - const CodeEditorMock = (props: any) => { - // Forward our deterministic ID to the consumer - // We need below for the PainlessLang.getSyntaxErrors mock - props.editorDidMount({ - getModel() { - return { - id: EDITOR_ID, - }; - }, - }); - - return ( - ) => { - props.onChange(e.target.value); - }} - /> - ); - }; - +jest.mock('@elastic/eui/lib/services/accessibility', () => { return { - ...original, - CodeEditor: CodeEditorMock, + htmlIdGenerator: () => () => `generated-id`, }; }); @@ -61,6 +30,16 @@ jest.mock('@elastic/eui', () => { }} /> ), + EuiResizeObserver: ({ + onResize, + children, + }: { + onResize(data: { height: number }): void; + children(): JSX.Element; + }) => { + onResize({ height: 1000 }); + return children(); + }, }; }); @@ -78,3 +57,40 @@ jest.mock('@kbn/monaco', () => { }, }; }); + +jest.mock('../../../../kibana_react/public', () => { + const original = jest.requireActual('../../../../kibana_react/public'); + + /** + * We mock the CodeEditor because it requires the + * with the uiSettings passed down. Let's use a simple in our tests. + */ + const CodeEditorMock = (props: any) => { + // Forward our deterministic ID to the consumer + // We need below for the PainlessLang.getSyntaxErrors mock + props.editorDidMount({ + getModel() { + return { + id: EDITOR_ID, + }; + }, + }); + + return ( + ) => { + props.onChange(e.target.value); + }} + /> + ); + }; + + return { + ...original, + toMountPoint: (node: React.ReactNode) => node, + CodeEditor: CodeEditorMock, + }; +}); diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/mocks.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/mocks.ts new file mode 100644 index 0000000000000..8dfdd13e8338d --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/mocks.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +interface PreviewErrorArgs { + reason: string; + scriptStack?: string[]; + position?: { offset: number; start: number; end: number } | null; +} + +export const createPreviewError = ({ + reason, + scriptStack = [], + position = null, +}: PreviewErrorArgs) => { + return { + caused_by: { reason }, + position, + script_stack: scriptStack, + }; +}; diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx new file mode 100644 index 0000000000000..d87b49d35c68e --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx @@ -0,0 +1,122 @@ +/* + * 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 './jest.mocks'; + +import React, { FunctionComponent } from 'react'; +import axios from 'axios'; +import axiosXhrAdapter from 'axios/lib/adapters/xhr'; +import { merge } from 'lodash'; + +import { notificationServiceMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { FieldEditorProvider, Context } from '../../../public/components/field_editor_context'; +import { FieldPreviewProvider } from '../../../public/components/preview'; +import { initApi, ApiService } from '../../../public/lib'; +import { init as initHttpRequests } from './http_requests'; + +const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); +const dataStart = dataPluginMock.createStartContract(); +const { search, fieldFormats } = dataStart; + +export const spySearchQuery = jest.fn(); +export const spySearchQueryResponse = jest.fn(); +export const spyIndexPatternGetAllFields = jest.fn().mockImplementation(() => []); + +spySearchQuery.mockImplementation((params) => { + return { + toPromise: () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(undefined); + }, 2000); // simulate 2s latency for the HTTP request + }).then(() => spySearchQueryResponse()); + }, + }; +}); +search.search = spySearchQuery; + +let apiService: ApiService; + +export const setupEnvironment = () => { + // @ts-expect-error Axios does not fullfill HttpSetupn from core but enough for our tests + apiService = initApi(mockHttpClient); + const { server, httpRequestsMockHelpers } = initHttpRequests(); + + return { + server, + httpRequestsMockHelpers, + }; +}; + +// The format options available in the dropdown select for our tests. +export const fieldFormatsOptions = [{ id: 'upper', title: 'UpperCaseString' } as any]; + +export const indexPatternNameForTest = 'testIndexPattern'; + +export const WithFieldEditorDependencies = ( + Comp: FunctionComponent, + overridingDependencies?: Partial +) => (props: T) => { + // Setup mocks + (fieldFormats.getByFieldType as jest.MockedFunction< + typeof fieldFormats['getByFieldType'] + >).mockReturnValue(fieldFormatsOptions); + + (fieldFormats.getDefaultType as jest.MockedFunction< + typeof fieldFormats['getDefaultType'] + >).mockReturnValue({ id: 'testDefaultFormat', title: 'TestDefaultFormat' } as any); + + (fieldFormats.getInstance as jest.MockedFunction< + typeof fieldFormats['getInstance'] + >).mockImplementation((id: string) => { + if (id === 'upper') { + return { + convertObject: { + html(value: string = '') { + return `${value.toUpperCase()}`; + }, + }, + } as any; + } + }); + + const dependencies: Context = { + indexPattern: { + title: indexPatternNameForTest, + fields: { getAll: spyIndexPatternGetAllFields }, + } as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + fieldTypeToProcess: 'runtime', + existingConcreteFields: [], + namesNotAllowed: [], + links: { + runtimePainless: 'https://elastic.co', + }, + services: { + notifications: notificationServiceMock.createStartContract(), + search, + api: apiService, + }, + fieldFormatEditors: { + getAll: () => [], + getById: () => undefined, + }, + fieldFormats, + }; + + const mergedDependencies = merge({}, dependencies, overridingDependencies); + + return ( + + + + + + ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/test_utils/index.ts b/src/plugins/index_pattern_field_editor/common/constants.ts similarity index 80% rename from src/plugins/index_pattern_field_editor/public/test_utils/index.ts rename to src/plugins/index_pattern_field_editor/common/constants.ts index b5d943281cd79..ecd6b1ddd408b 100644 --- a/src/plugins/index_pattern_field_editor/public/test_utils/index.ts +++ b/src/plugins/index_pattern_field_editor/common/constants.ts @@ -6,8 +6,4 @@ * Side Public License, v 1. */ -export * from './test_utils'; - -export * from './mocks'; - -export * from './helpers'; +export const API_BASE_PATH = '/api/index_pattern_field_editor'; diff --git a/src/plugins/index_pattern_field_editor/kibana.json b/src/plugins/index_pattern_field_editor/kibana.json index 02308b349d4ca..898e7c564e57f 100644 --- a/src/plugins/index_pattern_field_editor/kibana.json +++ b/src/plugins/index_pattern_field_editor/kibana.json @@ -1,7 +1,7 @@ { "id": "indexPatternFieldEditor", "version": "kibana", - "server": false, + "server": true, "ui": true, "requiredPlugins": ["data"], "optionalPlugins": ["usageCollection"], diff --git a/src/plugins/index_pattern_field_editor/public/components/delete_field_modal.tsx b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/delete_field_modal.tsx similarity index 100% rename from src/plugins/index_pattern_field_editor/public/components/delete_field_modal.tsx rename to src/plugins/index_pattern_field_editor/public/components/confirm_modals/delete_field_modal.tsx diff --git a/src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts new file mode 100644 index 0000000000000..2283070f6f727 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { DeleteFieldModal } from './delete_field_modal'; + +export { ModifiedFieldModal } from './modified_field_modal'; + +export { SaveFieldTypeOrNameChangedModal } from './save_field_type_or_name_changed_modal'; diff --git a/src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx new file mode 100644 index 0000000000000..c9fabbaa73561 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx @@ -0,0 +1,46 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiConfirmModal } from '@elastic/eui'; + +const i18nTexts = { + title: i18n.translate('indexPatternFieldEditor.cancelField.confirmationModal.title', { + defaultMessage: 'Discard changes', + }), + description: i18n.translate('indexPatternFieldEditor.cancelField.confirmationModal.description', { + defaultMessage: `Changes that you've made to your field will be discarded, are you sure you want to continue?`, + }), + cancelButton: i18n.translate( + 'indexPatternFieldEditor.cancelField.confirmationModal.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } + ), +}; + +interface Props { + onConfirm: () => void; + onCancel: () => void; +} + +export const ModifiedFieldModal: React.FC = ({ onCancel, onConfirm }) => { + return ( + +

{i18nTexts.description}

+
+ ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/confirm_modals/save_field_type_or_name_changed_modal.tsx b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/save_field_type_or_name_changed_modal.tsx new file mode 100644 index 0000000000000..51af86868c632 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/save_field_type_or_name_changed_modal.tsx @@ -0,0 +1,86 @@ +/* + * 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 } from 'react'; +import { EuiCallOut, EuiSpacer, EuiConfirmModal, EuiFieldText, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const geti18nTexts = (fieldName: string) => ({ + cancelButtonText: i18n.translate( + 'indexPatternFieldEditor.saveRuntimeField.confirmationModal.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } + ), + confirmButtonText: i18n.translate( + 'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.saveButtonLabel', + { + defaultMessage: 'Save changes', + } + ), + warningChangingFields: i18n.translate( + 'indexPatternFieldEditor.deleteRuntimeField.confirmModal.warningChangingFields', + { + defaultMessage: + 'Changing name or type can break searches and visualizations that rely on this field.', + } + ), + typeConfirm: i18n.translate('indexPatternFieldEditor.saveRuntimeField.confirmModal.typeConfirm', { + defaultMessage: 'Enter CHANGE to continue', + }), + titleConfirmChanges: i18n.translate( + 'indexPatternFieldEditor.saveRuntimeField.confirmModal.title', + { + defaultMessage: `Save changes to '{name}'`, + values: { + name: fieldName, + }, + } + ), +}); + +interface Props { + fieldName: string; + onConfirm: () => void; + onCancel: () => void; +} + +export const SaveFieldTypeOrNameChangedModal: React.FC = ({ + fieldName, + onCancel, + onConfirm, +}) => { + const i18nTexts = geti18nTexts(fieldName); + const [confirmContent, setConfirmContent] = useState(''); + + return ( + + + + + setConfirmContent(e.target.value)} + data-test-subj="saveModalConfirmText" + /> + + + ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/constants.ts b/src/plugins/index_pattern_field_editor/public/components/field_editor/constants.ts index 82711f707fa19..e262d3ecbfe45 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/constants.ts +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/constants.ts @@ -34,4 +34,8 @@ export const RUNTIME_FIELD_OPTIONS: Array> label: 'Boolean', value: 'boolean', }, + { + label: 'Geo point', + value: 'geo_point', + }, ]; diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx index 77ef0903bc6fc..b46d587dc4146 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx @@ -9,6 +9,7 @@ import React, { useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { get } from 'lodash'; import { EuiFlexGroup, EuiFlexItem, @@ -17,20 +18,20 @@ import { EuiCode, EuiCallOut, } from '@elastic/eui'; -import type { CoreStart } from 'src/core/public'; import { Form, useForm, useFormData, + useFormIsModified, FormHook, UseField, TextField, RuntimeType, - IndexPattern, - DataPublicPluginStart, } from '../../shared_imports'; -import { Field, InternalFieldType, PluginStart } from '../../types'; +import { Field } from '../../types'; +import { useFieldEditorContext } from '../field_editor_context'; +import { useFieldPreviewContext } from '../preview'; import { RUNTIME_FIELD_OPTIONS } from './constants'; import { schema } from './form_schema'; @@ -63,36 +64,12 @@ export interface FieldFormInternal extends Omit } export interface Props { - /** Link URLs to our doc site */ - links: { - runtimePainless: string; - }; /** Optional field to edit */ field?: Field; /** Handler to receive state changes updates */ onChange?: (state: FieldEditorFormState) => void; - indexPattern: IndexPattern; - fieldFormatEditors: PluginStart['fieldFormatEditors']; - fieldFormats: DataPublicPluginStart['fieldFormats']; - uiSettings: CoreStart['uiSettings']; - /** Context object */ - ctx: { - /** The internal field type we are dealing with (concrete|runtime)*/ - fieldTypeToProcess: InternalFieldType; - /** - * An array of field names not allowed. - * e.g we probably don't want a user to give a name of an existing - * runtime field (for that the user should edit the existing runtime field). - */ - namesNotAllowed: string[]; - /** - * An array of existing concrete fields. If the user gives a name to the runtime - * field that matches one of the concrete fields, a callout will be displayed - * to indicate that this runtime field will shadow the concrete field. - * It is also used to provide the list of field autocomplete suggestions to the code editor. - */ - existingConcreteFields: Array<{ name: string; type: string }>; - }; + /** Handler to receive update on the form "isModified" state */ + onFormModifiedChange?: (isModified: boolean) => void; syntaxError: ScriptSyntaxError; } @@ -173,31 +150,53 @@ const formSerializer = (field: FieldFormInternal): Field => { }; }; -const FieldEditorComponent = ({ - field, - onChange, - links, - indexPattern, - fieldFormatEditors, - fieldFormats, - uiSettings, - syntaxError, - ctx: { fieldTypeToProcess, namesNotAllowed, existingConcreteFields }, -}: Props) => { +const FieldEditorComponent = ({ field, onChange, onFormModifiedChange, syntaxError }: Props) => { + const { + links, + namesNotAllowed, + existingConcreteFields, + fieldTypeToProcess, + } = useFieldEditorContext(); + const { + params: { update: updatePreviewParams }, + panel: { setIsVisible: setIsPanelVisible }, + } = useFieldPreviewContext(); const { form } = useForm({ defaultValue: field, schema, deserializer: formDeserializer, serializer: formSerializer, }); - const { submit, isValid: isFormValid, isSubmitted } = form; + const { submit, isValid: isFormValid, isSubmitted, getFields } = form; const { clear: clearSyntaxError } = syntaxError; - const [{ type }] = useFormData({ form }); - const nameFieldConfig = getNameFieldConfig(namesNotAllowed, field); const i18nTexts = geti18nTexts(); + const [formData] = useFormData({ form }); + const isFormModified = useFormIsModified({ + form, + discard: [ + '__meta__.isCustomLabelVisible', + '__meta__.isValueVisible', + '__meta__.isFormatVisible', + '__meta__.isPopularityVisible', + ], + }); + + const { + name: updatedName, + type: updatedType, + script: updatedScript, + format: updatedFormat, + } = formData; + const { name: nameField, type: typeField } = getFields(); + const nameHasChanged = (Boolean(field?.name) && nameField?.isModified) ?? false; + const typeHasChanged = (Boolean(field?.type) && typeField?.isModified) ?? false; + + const isValueVisible = get(formData, '__meta__.isValueVisible'); + const isFormatVisible = get(formData, '__meta__.isFormatVisible'); + useEffect(() => { if (onChange) { onChange({ isValid: isFormValid, isSubmitted, submit }); @@ -208,18 +207,39 @@ const FieldEditorComponent = ({ // Whenever the field "type" changes we clear any possible painless syntax // error as it is possibly stale. clearSyntaxError(); - }, [type, clearSyntaxError]); + }, [updatedType, clearSyntaxError]); - const [{ name: updatedName, type: updatedType }] = useFormData({ form }); - const nameHasChanged = Boolean(field?.name) && field?.name !== updatedName; - const typeHasChanged = - Boolean(field?.type) && field?.type !== (updatedType && updatedType[0].value); + useEffect(() => { + updatePreviewParams({ + name: Boolean(updatedName?.trim()) ? updatedName : null, + type: updatedType?.[0].value, + script: + isValueVisible === false || Boolean(updatedScript?.source.trim()) === false + ? null + : updatedScript, + format: updatedFormat?.id !== undefined ? updatedFormat : null, + }); + }, [updatedName, updatedType, updatedScript, isValueVisible, updatedFormat, updatePreviewParams]); + + useEffect(() => { + if (isValueVisible || isFormatVisible) { + setIsPanelVisible(true); + } else { + setIsPanelVisible(false); + } + }, [isValueVisible, isFormatVisible, setIsPanelVisible]); + + useEffect(() => { + if (onFormModifiedChange) { + onFormModifiedChange(isFormModified); + } + }, [isFormModified, onFormModifiedChange]); return (
{/* Name */} @@ -296,12 +316,7 @@ const FieldEditorComponent = ({ data-test-subj="formatRow" withDividerRule > - + {/* Advanced settings */} @@ -320,4 +335,4 @@ const FieldEditorComponent = ({ ); }; -export const FieldEditor = React.memo(FieldEditorComponent); +export const FieldEditor = React.memo(FieldEditorComponent) as typeof FieldEditorComponent; diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/format_field.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/format_field.tsx index db98e4a159162..2ff4a48477def 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/format_field.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/format_field.tsx @@ -9,16 +9,13 @@ import React, { useState, useEffect, useRef } from 'react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { UseField, useFormData, ES_FIELD_TYPES, useFormContext } from '../../../shared_imports'; -import { FormatSelectEditor, FormatSelectEditorProps } from '../../field_format_editor'; -import { FieldFormInternal } from '../field_editor'; -import { FieldFormatConfig } from '../../../types'; +import { useFieldEditorContext } from '../../field_editor_context'; +import { FormatSelectEditor } from '../../field_format_editor'; +import type { FieldFormInternal } from '../field_editor'; +import type { FieldFormatConfig } from '../../../types'; -export const FormatField = ({ - indexPattern, - fieldFormatEditors, - fieldFormats, - uiSettings, -}: Omit) => { +export const FormatField = () => { + const { indexPattern, uiSettings, fieldFormats, fieldFormatEditors } = useFieldEditorContext(); const isMounted = useRef(false); const [{ type }] = useFormData({ watch: ['name', 'type'] }); const { getFields, isSubmitted } = useFormContext(); diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/script_field.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/script_field.tsx index 29945e15874b7..d73e8046e5db7 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/script_field.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/script_field.tsx @@ -139,6 +139,7 @@ export const ScriptField = React.memo(({ existingConcreteFields, links, syntaxEr <> { defaultMessage: 'Type select', } )} + aria-controls="runtimeFieldScript" fullWidth /> diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/lib.ts b/src/plugins/index_pattern_field_editor/public/components/field_editor/lib.ts index 2d324804c9e43..ba44682ba65e0 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/lib.ts +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/lib.ts @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { ValidationFunc, FieldConfig } from '../../shared_imports'; import { Field } from '../../types'; import { schema } from './form_schema'; -import { Props } from './field_editor'; +import type { Props } from './field_editor'; const createNameNotAllowedValidator = ( namesNotAllowed: string[] diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_context.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_context.tsx new file mode 100644 index 0000000000000..74bf2657ba3de --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_context.tsx @@ -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 React, { createContext, useContext, FunctionComponent, useMemo } from 'react'; +import { NotificationsStart, CoreStart } from 'src/core/public'; +import type { IndexPattern, DataPublicPluginStart } from '../shared_imports'; +import { ApiService } from '../lib/api'; +import type { InternalFieldType, PluginStart } from '../types'; + +export interface Context { + indexPattern: IndexPattern; + fieldTypeToProcess: InternalFieldType; + uiSettings: CoreStart['uiSettings']; + links: { + runtimePainless: string; + }; + services: { + search: DataPublicPluginStart['search']; + api: ApiService; + notifications: NotificationsStart; + }; + fieldFormatEditors: PluginStart['fieldFormatEditors']; + fieldFormats: DataPublicPluginStart['fieldFormats']; + /** + * An array of field names not allowed. + * e.g we probably don't want a user to give a name of an existing + * runtime field (for that the user should edit the existing runtime field). + */ + namesNotAllowed: string[]; + /** + * An array of existing concrete fields. If the user gives a name to the runtime + * field that matches one of the concrete fields, a callout will be displayed + * to indicate that this runtime field will shadow the concrete field. + * It is also used to provide the list of field autocomplete suggestions to the code editor. + */ + existingConcreteFields: Array<{ name: string; type: string }>; +} + +const fieldEditorContext = createContext(undefined); + +export const FieldEditorProvider: FunctionComponent = ({ + services, + indexPattern, + links, + uiSettings, + fieldTypeToProcess, + fieldFormats, + fieldFormatEditors, + namesNotAllowed, + existingConcreteFields, + children, +}) => { + const ctx = useMemo( + () => ({ + indexPattern, + fieldTypeToProcess, + links, + uiSettings, + services, + fieldFormats, + fieldFormatEditors, + namesNotAllowed, + existingConcreteFields, + }), + [ + indexPattern, + fieldTypeToProcess, + services, + links, + uiSettings, + fieldFormats, + fieldFormatEditors, + namesNotAllowed, + existingConcreteFields, + ] + ); + + return {children}; +}; + +export const useFieldEditorContext = (): Context => { + const ctx = useContext(fieldEditorContext); + + if (ctx === undefined) { + throw new Error('useFieldEditorContext must be used within a '); + } + + return ctx; +}; + +export const useFieldEditorServices = () => useFieldEditorContext().services; diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx index 13830f9233b5e..19015aa9d0d10 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx @@ -6,13 +6,10 @@ * Side Public License, v 1. */ -import React, { useState, useCallback, useMemo } from 'react'; +import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiFlyoutHeader, - EuiFlyoutBody, - EuiFlyoutFooter, EuiTitle, EuiFlexGroup, EuiFlexItem, @@ -21,64 +18,32 @@ import { EuiCallOut, EuiSpacer, EuiText, - EuiConfirmModal, - EuiFieldText, - EuiFormRow, } from '@elastic/eui'; -import { DocLinksStart, CoreStart } from 'src/core/public'; +import type { Field, EsRuntimeField } from '../types'; +import { RuntimeFieldPainlessError } from '../lib'; +import { euiFlyoutClassname } from '../constants'; +import { FlyoutPanels } from './flyout_panels'; +import { useFieldEditorContext } from './field_editor_context'; +import { FieldEditor, FieldEditorFormState } from './field_editor/field_editor'; +import { FieldPreview, useFieldPreviewContext } from './preview'; +import { ModifiedFieldModal, SaveFieldTypeOrNameChangedModal } from './confirm_modals'; -import { Field, InternalFieldType, PluginStart, EsRuntimeField } from '../types'; -import { getLinks, RuntimeFieldPainlessError } from '../lib'; -import type { IndexPattern, DataPublicPluginStart } from '../shared_imports'; -import type { Props as FieldEditorProps, FieldEditorFormState } from './field_editor/field_editor'; +const i18nTexts = { + cancelButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutCancelButtonLabel', { + defaultMessage: 'Cancel', + }), + saveButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutSaveButtonLabel', { + defaultMessage: 'Save', + }), + formErrorsCalloutTitle: i18n.translate('indexPatternFieldEditor.editor.validationErrorTitle', { + defaultMessage: 'Fix errors in form before continuing.', + }), +}; -const geti18nTexts = (field?: Field) => { - return { - closeButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutCloseButtonLabel', { - defaultMessage: 'Close', - }), - saveButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutSaveButtonLabel', { - defaultMessage: 'Save', - }), - formErrorsCalloutTitle: i18n.translate('indexPatternFieldEditor.editor.validationErrorTitle', { - defaultMessage: 'Fix errors in form before continuing.', - }), - cancelButtonText: i18n.translate( - 'indexPatternFieldEditor.saveRuntimeField.confirmationModal.cancelButtonLabel', - { - defaultMessage: 'Cancel', - } - ), - confirmButtonText: i18n.translate( - 'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.saveButtonLabel', - { - defaultMessage: 'Save changes', - } - ), - warningChangingFields: i18n.translate( - 'indexPatternFieldEditor.deleteRuntimeField.confirmModal.warningChangingFields', - { - defaultMessage: - 'Changing name or type can break searches and visualizations that rely on this field.', - } - ), - typeConfirm: i18n.translate( - 'indexPatternFieldEditor.saveRuntimeField.confirmModal.typeConfirm', - { - defaultMessage: 'Enter CHANGE to continue', - } - ), - titleConfirmChanges: i18n.translate( - 'indexPatternFieldEditor.saveRuntimeField.confirmModal.title', - { - defaultMessage: `Save changes to '{name}'`, - values: { - name: field?.name, - }, - } - ), - }; +const defaultModalVisibility = { + confirmChangeNameOrType: false, + confirmUnsavedChanges: false, }; export interface Props { @@ -90,44 +55,30 @@ export interface Props { * Handler for the "cancel" footer button */ onCancel: () => void; - /** - * The docLinks start service from core - */ - docLinks: DocLinksStart; - /** - * The Field editor component that contains the form to create or edit a field - */ - FieldEditor: React.ComponentType | null; - /** The internal field type we are dealing with (concrete|runtime)*/ - fieldTypeToProcess: InternalFieldType; /** Handler to validate the script */ runtimeFieldValidator: (field: EsRuntimeField) => Promise; /** Optional field to process */ field?: Field; - - indexPattern: IndexPattern; - fieldFormatEditors: PluginStart['fieldFormatEditors']; - fieldFormats: DataPublicPluginStart['fieldFormats']; - uiSettings: CoreStart['uiSettings']; isSavingField: boolean; + /** Handler to call when the component mounts. + * We will pass "up" data that the parent component might need + */ + onMounted?: (args: { canCloseValidator: () => boolean }) => void; } const FieldEditorFlyoutContentComponent = ({ field, onSave, onCancel, - FieldEditor, - docLinks, - indexPattern, - fieldFormatEditors, - fieldFormats, - uiSettings, - fieldTypeToProcess, runtimeFieldValidator, isSavingField, + onMounted, }: Props) => { const isEditingExistingField = !!field; - const i18nTexts = geti18nTexts(field); + const { indexPattern } = useFieldEditorContext(); + const { + panel: { isVisible: isPanelVisible }, + } = useFieldPreviewContext(); const [formState, setFormState] = useState({ isSubmitted: false, @@ -142,12 +93,11 @@ const FieldEditorFlyoutContentComponent = ({ ); const [isValidating, setIsValidating] = useState(false); - const [isModalVisible, setIsModalVisible] = useState(false); - const [confirmContent, setConfirmContent] = useState(''); + const [modalVisibility, setModalVisibility] = useState(defaultModalVisibility); + const [isFormModified, setIsFormModified] = useState(false); const { submit, isValid: isFormValid, isSubmitted } = formState; - const { fields } = indexPattern; - const isSaveButtonDisabled = isFormValid === false || painlessSyntaxError !== null; + const hasErrors = isFormValid === false || painlessSyntaxError !== null; const clearSyntaxError = useCallback(() => setPainlessSyntaxError(null), []); @@ -159,6 +109,16 @@ const FieldEditorFlyoutContentComponent = ({ [painlessSyntaxError, clearSyntaxError] ); + const canCloseValidator = useCallback(() => { + if (isFormModified) { + setModalVisibility({ + ...defaultModalVisibility, + confirmUnsavedChanges: true, + }); + } + return !isFormModified; + }, [isFormModified]); + const onClickSave = useCallback(async () => { const { isValid, data } = await submit(); const nameChange = field?.name !== data.name; @@ -182,167 +142,177 @@ const FieldEditorFlyoutContentComponent = ({ } if (isEditingExistingField && (nameChange || typeChange)) { - setIsModalVisible(true); + setModalVisibility({ + ...defaultModalVisibility, + confirmChangeNameOrType: true, + }); } else { onSave(data); } } }, [onSave, submit, runtimeFieldValidator, field, isEditingExistingField]); - const namesNotAllowed = useMemo(() => fields.map((fld) => fld.name), [fields]); + const onClickCancel = useCallback(() => { + const canClose = canCloseValidator(); + + if (canClose) { + onCancel(); + } + }, [onCancel, canCloseValidator]); - const existingConcreteFields = useMemo(() => { - const existing: Array<{ name: string; type: string }> = []; + const renderModal = () => { + if (modalVisibility.confirmChangeNameOrType) { + return ( + { + const { data } = await submit(); + onSave(data); + }} + onCancel={() => { + setModalVisibility(defaultModalVisibility); + }} + /> + ); + } - fields - .filter((fld) => { - const isFieldBeingEdited = field?.name === fld.name; - return !isFieldBeingEdited && fld.isMapped; - }) - .forEach((fld) => { - existing.push({ - name: fld.name, - type: (fld.esTypes && fld.esTypes[0]) || '', - }); - }); + if (modalVisibility.confirmUnsavedChanges) { + return ( + { + setModalVisibility(defaultModalVisibility); + onCancel(); + }} + onCancel={() => { + setModalVisibility(defaultModalVisibility); + }} + /> + ); + } - return existing; - }, [fields, field]); + return null; + }; - const ctx = useMemo( - () => ({ - fieldTypeToProcess, - namesNotAllowed, - existingConcreteFields, - }), - [fieldTypeToProcess, namesNotAllowed, existingConcreteFields] - ); + useEffect(() => { + if (onMounted) { + // When the flyout mounts we send to the parent the validator to check + // if we can close the flyout or not (and display a confirm modal if needed). + // This is required to display the confirm modal when clicking outside the flyout. + onMounted({ canCloseValidator }); + + return () => { + onMounted({ canCloseValidator: () => true }); + }; + } + }, [onMounted, canCloseValidator]); - const modal = isModalVisible ? ( - { - setIsModalVisible(false); - setConfirmContent(''); - }} - onConfirm={async () => { - const { data } = await submit(); - onSave(data); - }} - > - - - - setConfirmContent(e.target.value)} - data-test-subj="saveModalConfirmText" - /> - - - ) : null; return ( <> - - -

- {field ? ( - - ) : ( - - )} -

-
- -

- {indexPattern.title}, - }} + + {/* Editor panel */} + + + + +

+ {field ? ( + + ) : ( + + )} +

+ + +

+ {indexPattern.title}, + }} + /> +

+
+ + + -

- - - - - {FieldEditor && ( - - )} - - - - {FieldEditor && ( - <> - {isSubmitted && isSaveButtonDisabled && ( - <> - - - - )} - - - - {i18nTexts.closeButtonLabel} - - - - - - {i18nTexts.saveButtonLabel} - - - - + + + + <> + {isSubmitted && hasErrors && ( + <> + + + + )} + + + + {i18nTexts.cancelButtonLabel} + + + + + + {i18nTexts.saveButtonLabel} + + + + + + + + {/* Preview panel */} + {isPanelVisible && ( + + + )} - - {modal} + + + {renderModal()} ); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx index e01b3f9bb422c..cf2b29bbc97e8 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useCallback, useEffect, useState, useMemo } from 'react'; +import React, { useCallback, useState, useMemo } from 'react'; import { DocLinksStart, NotificationsStart, CoreStart } from 'src/core/public'; import { i18n } from '@kbn/i18n'; import { METRIC_TYPE } from '@kbn/analytics'; @@ -18,53 +18,40 @@ import { RuntimeType, UsageCollectionStart, } from '../shared_imports'; -import { Field, PluginStart, InternalFieldType } from '../types'; +import type { Field, PluginStart, InternalFieldType } from '../types'; import { pluginName } from '../constants'; -import { deserializeField, getRuntimeFieldValidator } from '../lib'; -import { Props as FieldEditorProps } from './field_editor/field_editor'; -import { FieldEditorFlyoutContent } from './field_editor_flyout_content'; - -export interface FieldEditorContext { - indexPattern: IndexPattern; - /** - * The Kibana field type of the field to create or edit - * Default: "runtime" - */ - fieldTypeToProcess: InternalFieldType; - /** The search service from the data plugin */ - search: DataPublicPluginStart['search']; -} +import { deserializeField, getRuntimeFieldValidator, getLinks, ApiService } from '../lib'; +import { + FieldEditorFlyoutContent, + Props as FieldEditorFlyoutContentProps, +} from './field_editor_flyout_content'; +import { FieldEditorProvider } from './field_editor_context'; +import { FieldPreviewProvider } from './preview'; export interface Props { - /** - * Handler for the "save" footer button - */ + /** Handler for the "save" footer button */ onSave: (field: IndexPatternField) => void; - /** - * Handler for the "cancel" footer button - */ + /** Handler for the "cancel" footer button */ onCancel: () => void; - /** - * The docLinks start service from core - */ + onMounted?: FieldEditorFlyoutContentProps['onMounted']; + /** The docLinks start service from core */ docLinks: DocLinksStart; - /** - * The context object specific to where the editor is currently being consumed - */ - ctx: FieldEditorContext; - /** - * Optional field to edit - */ + /** The index pattern where the field will be added */ + indexPattern: IndexPattern; + /** The Kibana field type of the field to create or edit (default: "runtime") */ + fieldTypeToProcess: InternalFieldType; + /** Optional field to edit */ field?: IndexPatternField; - /** - * Services - */ + /** Services */ indexPatternService: DataPublicPluginStart['indexPatterns']; notifications: NotificationsStart; + search: DataPublicPluginStart['search']; + usageCollection: UsageCollectionStart; + apiService: ApiService; + /** Field format */ fieldFormatEditors: PluginStart['fieldFormatEditors']; fieldFormats: DataPublicPluginStart['fieldFormats']; uiSettings: CoreStart['uiSettings']; - usageCollection: UsageCollectionStart; } /** @@ -78,19 +65,58 @@ export const FieldEditorFlyoutContentContainer = ({ field, onSave, onCancel, + onMounted, docLinks, + fieldTypeToProcess, + indexPattern, indexPatternService, - ctx: { indexPattern, fieldTypeToProcess, search }, + search, notifications, + usageCollection, + apiService, fieldFormatEditors, fieldFormats, uiSettings, - usageCollection, }: Props) => { const fieldToEdit = deserializeField(indexPattern, field); - const [Editor, setEditor] = useState | null>(null); const [isSaving, setIsSaving] = useState(false); + const { fields } = indexPattern; + + const namesNotAllowed = useMemo(() => fields.map((fld) => fld.name), [fields]); + + const existingConcreteFields = useMemo(() => { + const existing: Array<{ name: string; type: string }> = []; + + fields + .filter((fld) => { + const isFieldBeingEdited = field?.name === fld.name; + return !isFieldBeingEdited && fld.isMapped; + }) + .forEach((fld) => { + existing.push({ + name: fld.name, + type: (fld.esTypes && fld.esTypes[0]) || '', + }); + }); + + return existing; + }, [fields, field]); + + const validateRuntimeField = useMemo(() => getRuntimeFieldValidator(indexPattern.title, search), [ + search, + indexPattern, + ]); + + const services = useMemo( + () => ({ + api: apiService, + search, + notifications, + }), + [apiService, search, notifications] + ); + const saveField = useCallback( async (updatedField: Field) => { setIsSaving(true); @@ -163,36 +189,28 @@ export const FieldEditorFlyoutContentContainer = ({ ] ); - const validateRuntimeField = useMemo(() => getRuntimeFieldValidator(indexPattern.title, search), [ - search, - indexPattern, - ]); - - const loadEditor = useCallback(async () => { - const { FieldEditor } = await import('./field_editor'); - - setEditor(() => FieldEditor); - }, []); - - useEffect(() => { - // On mount: load the editor asynchronously - loadEditor(); - }, [loadEditor]); - return ( - + services={services} + fieldFormatEditors={fieldFormatEditors} + fieldFormats={fieldFormats} + namesNotAllowed={namesNotAllowed} + existingConcreteFields={existingConcreteFields} + > + + + + ); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_loader.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_loader.tsx new file mode 100644 index 0000000000000..f77db7e407caa --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_loader.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useCallback, useEffect } from 'react'; +import { EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter } from '@elastic/eui'; + +import type { Props } from './field_editor_flyout_content_container'; + +export const FieldEditorLoader: React.FC = (props) => { + const [Editor, setEditor] = useState | null>(null); + + const loadEditor = useCallback(async () => { + const { FieldEditorFlyoutContentContainer } = await import( + './field_editor_flyout_content_container' + ); + setEditor(() => FieldEditorFlyoutContentContainer); + }, []); + + useEffect(() => { + // On mount: load the editor asynchronously + loadEditor(); + }, [loadEditor]); + + return Editor ? ( + + ) : ( + <> + + + + + ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/default/default.tsx b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/default/default.tsx index 129049e1b0565..fcf73f397b3fe 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/default/default.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/default/default.tsx @@ -10,8 +10,8 @@ import React, { PureComponent, ReactText } from 'react'; import { i18n } from '@kbn/i18n'; import type { FieldFormatsContentType } from 'src/plugins/field_formats/common'; -import { Sample, SampleInput } from '../../types'; -import { FormatEditorProps } from '../types'; +import type { Sample, SampleInput } from '../../types'; +import type { FormatEditorProps } from '../types'; import { formatId } from './constants'; export const convertSampleInput = ( diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx new file mode 100644 index 0000000000000..05f127c09c996 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx @@ -0,0 +1,144 @@ +/* + * 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, { + CSSProperties, + useState, + useLayoutEffect, + useCallback, + createContext, + useContext, + useMemo, +} from 'react'; +import classnames from 'classnames'; +import { EuiFlexItem } from '@elastic/eui'; + +import { useFlyoutPanelsContext } from './flyout_panels'; + +interface Context { + registerFooter: () => void; + registerContent: () => void; +} + +const flyoutPanelContext = createContext({ + registerFooter: () => {}, + registerContent: () => {}, +}); + +export interface Props { + /** Width of the panel (in percent % or in px if the "fixedPanelWidths" prop is set to true on the panels group) */ + width?: number; + /** EUI sass background */ + backgroundColor?: 'euiPageBackground' | 'euiEmptyShade'; + /** Add a border to the panel */ + border?: 'left' | 'right'; + 'data-test-subj'?: string; +} + +export const Panel: React.FC> = ({ + children, + width, + className = '', + backgroundColor, + border, + 'data-test-subj': dataTestSubj, + ...rest +}) => { + const [config, setConfig] = useState<{ hasFooter: boolean; hasContent: boolean }>({ + hasContent: false, + hasFooter: false, + }); + + const [styles, setStyles] = useState({}); + + /* eslint-disable @typescript-eslint/naming-convention */ + const classes = classnames('fieldEditor__flyoutPanel', className, { + 'fieldEditor__flyoutPanel--pageBackground': backgroundColor === 'euiPageBackground', + 'fieldEditor__flyoutPanel--emptyShade': backgroundColor === 'euiEmptyShade', + 'fieldEditor__flyoutPanel--leftBorder': border === 'left', + 'fieldEditor__flyoutPanel--rightBorder': border === 'right', + 'fieldEditor__flyoutPanel--withContent': config.hasContent, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + + const { addPanel } = useFlyoutPanelsContext(); + + const registerContent = useCallback(() => { + setConfig((prev) => { + return { + ...prev, + hasContent: true, + }; + }); + }, []); + + const registerFooter = useCallback(() => { + setConfig((prev) => { + if (!prev.hasContent) { + throw new Error( + 'You need to add a when you add a ' + ); + } + return { + ...prev, + hasFooter: true, + }; + }); + }, []); + + const ctx = useMemo(() => ({ registerContent, registerFooter }), [ + registerFooter, + registerContent, + ]); + + useLayoutEffect(() => { + const { removePanel, isFixedWidth } = addPanel({ width }); + + if (width) { + setStyles((prev) => { + if (isFixedWidth) { + return { + ...prev, + width: `${width}px`, + }; + } + return { + ...prev, + minWidth: `${width}%`, + }; + }); + } + + return removePanel; + }, [width, addPanel]); + + return ( + + +
+ {children} +
+
+
+ ); +}; + +export const useFlyoutPanelContext = (): Context => { + const ctx = useContext(flyoutPanelContext); + + if (ctx === undefined) { + throw new Error('useFlyoutPanel() must be used within a '); + } + + return ctx; +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss new file mode 100644 index 0000000000000..29a62a16db213 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss @@ -0,0 +1,48 @@ +.fieldEditor__flyoutPanels { + height: 100%; + + &__column { + height: 100%; + overflow: hidden; + } +} + +.fieldEditor__flyoutPanel { + height: 100%; + overflow-y: auto; + padding: $euiSizeL; + + &--pageBackground { + background-color: $euiPageBackgroundColor; + } + &--emptyShade { + background-color: $euiColorEmptyShade; + } + &--leftBorder { + border-left: $euiBorderThin; + } + &--rightBorder { + border-right: $euiBorderThin; + } + &--withContent { + padding: 0; + overflow-y: hidden; + display: flex; + flex-direction: column; + } + + &__header { + padding: 0 !important; + } + + &__content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: $euiSizeL; + } + + &__footer { + flex: 0; + } +} diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx new file mode 100644 index 0000000000000..95fb44b293e00 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx @@ -0,0 +1,145 @@ +/* + * 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, + createContext, + useContext, + useCallback, + useMemo, + useLayoutEffect, +} from 'react'; +import { EuiFlexGroup, EuiFlexGroupProps } from '@elastic/eui'; + +import './flyout_panels.scss'; + +interface Panel { + width?: number; +} + +interface Context { + addPanel: (panel: Panel) => { removePanel: () => void; isFixedWidth: boolean }; +} + +let idx = 0; + +const panelId = () => idx++; + +const flyoutPanelsContext = createContext({ + addPanel() { + return { + removePanel: () => {}, + isFixedWidth: false, + }; + }, +}); + +const limitWidthToWindow = (width: number, { innerWidth }: Window): number => + Math.min(width, innerWidth * 0.8); + +export interface Props { + /** + * The total max width with all the panels in the DOM + * Corresponds to the "maxWidth" prop passed to the EuiFlyout + */ + maxWidth: number; + /** The className selector of the flyout */ + flyoutClassName: string; + /** The size between the panels. Corresponds to EuiFlexGroup gutterSize */ + gutterSize?: EuiFlexGroupProps['gutterSize']; + /** Flag to indicate if the panels width are declared as fixed pixel width instead of percent */ + fixedPanelWidths?: boolean; +} + +export const Panels: React.FC = ({ + maxWidth, + flyoutClassName, + fixedPanelWidths = false, + ...props +}) => { + const flyoutDOMelement = useMemo(() => { + const el = document.getElementsByClassName(flyoutClassName); + + if (el.length === 0) { + return null; + } + + return el.item(0) as HTMLDivElement; + }, [flyoutClassName]); + + const [panels, setPanels] = useState<{ [id: number]: Panel }>({}); + + const removePanel = useCallback((id: number) => { + setPanels((prev) => { + const { [id]: panelToRemove, ...rest } = prev; + return rest; + }); + }, []); + + const addPanel = useCallback( + (panel: Panel) => { + const nextId = panelId(); + setPanels((prev) => { + return { ...prev, [nextId]: panel }; + }); + + return { + removePanel: removePanel.bind(null, nextId), + isFixedWidth: fixedPanelWidths, + }; + }, + [removePanel, fixedPanelWidths] + ); + + const ctx: Context = useMemo( + () => ({ + addPanel, + }), + [addPanel] + ); + + useLayoutEffect(() => { + if (!flyoutDOMelement) { + return; + } + + let currentWidth: number; + + if (fixedPanelWidths) { + const totalWidth = Object.values(panels).reduce((acc, { width = 0 }) => acc + width, 0); + currentWidth = Math.min(maxWidth, totalWidth); + // As EUI declares both min-width and max-width on the .euiFlyout CSS class + // we need to override both values + flyoutDOMelement.style.minWidth = `${limitWidthToWindow(currentWidth, window)}px`; + flyoutDOMelement.style.maxWidth = `${limitWidthToWindow(currentWidth, window)}px`; + } else { + const totalPercentWidth = Math.min( + 100, + Object.values(panels).reduce((acc, { width = 0 }) => acc + width, 0) + ); + currentWidth = (maxWidth * totalPercentWidth) / 100; + flyoutDOMelement.style.maxWidth = `${limitWidthToWindow(currentWidth, window)}px`; + } + }, [panels, maxWidth, fixedPanelWidths, flyoutClassName, flyoutDOMelement]); + + return ( + + + + ); +}; + +export const useFlyoutPanelsContext = (): Context => { + const ctx = useContext(flyoutPanelsContext); + + if (ctx === undefined) { + throw new Error(' must be used within a wrapper'); + } + + return ctx; +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_content.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_content.tsx new file mode 100644 index 0000000000000..ef2f7498a4c22 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_content.tsx @@ -0,0 +1,21 @@ +/* + * 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, { useEffect } from 'react'; + +import { useFlyoutPanelContext } from './flyout_panel'; + +export const PanelContent: React.FC = (props) => { + const { registerContent } = useFlyoutPanelContext(); + + useEffect(() => { + registerContent(); + }, [registerContent]); + + // Adding a tabIndex prop to the div as it is the body of the flyout which is scrollable. + return
; +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_footer.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_footer.tsx new file mode 100644 index 0000000000000..8a987420dd84b --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_footer.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 + * 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, { useEffect } from 'react'; +import { EuiFlyoutFooter, EuiFlyoutFooterProps } from '@elastic/eui'; + +import { useFlyoutPanelContext } from './flyout_panel'; + +export const PanelFooter: React.FC< + { children: React.ReactNode } & Omit +> = (props) => { + const { registerFooter } = useFlyoutPanelContext(); + + useEffect(() => { + registerFooter(); + }, [registerFooter]); + + return ; +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_header.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_header.tsx new file mode 100644 index 0000000000000..00edf1c637fc1 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_header.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiSpacer, EuiFlyoutHeader, EuiFlyoutHeaderProps } from '@elastic/eui'; + +export const PanelHeader: React.FunctionComponent< + { children: React.ReactNode } & Omit +> = (props) => ( + <> + + + +); diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/index.ts b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/index.ts new file mode 100644 index 0000000000000..0380a0bfefe72 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PanelFooter } from './flyout_panels_footer'; +import { PanelHeader } from './flyout_panels_header'; +import { PanelContent } from './flyout_panels_content'; +import { Panel } from './flyout_panel'; +import { Panels } from './flyout_panels'; + +export { useFlyoutPanelContext } from './flyout_panel'; + +export const FlyoutPanels = { + Group: Panels, + Item: Panel, + Content: PanelContent, + Header: PanelHeader, + Footer: PanelFooter, +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/index.ts b/src/plugins/index_pattern_field_editor/public/components/index.ts index 9f7f40fcadec7..927e28a8e3adf 100644 --- a/src/plugins/index_pattern_field_editor/public/components/index.ts +++ b/src/plugins/index_pattern_field_editor/public/components/index.ts @@ -6,17 +6,6 @@ * Side Public License, v 1. */ -export { - FieldEditorFlyoutContent, - Props as FieldEditorFlyoutContentProps, -} from './field_editor_flyout_content'; - -export { - FieldEditorFlyoutContentContainer, - Props as FieldEditorFlyoutContentContainerProps, - FieldEditorContext, -} from './field_editor_flyout_content_container'; - export { getDeleteFieldProvider, Props as DeleteFieldProviderProps } from './delete_field_provider'; export * from './field_format_editor'; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx new file mode 100644 index 0000000000000..fa4097725cde1 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx @@ -0,0 +1,132 @@ +/* + * 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 } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldText, + EuiButtonIcon, + EuiButtonEmpty, +} from '@elastic/eui'; + +import { useFieldPreviewContext } from './field_preview_context'; + +export const DocumentsNavPreview = () => { + const { + currentDocument: { id: documentId, isCustomId }, + documents: { loadSingle, loadFromCluster }, + navigation: { prev, next }, + error, + } = useFieldPreviewContext(); + + const errorMessage = + error !== null && error.code === 'DOC_NOT_FOUND' + ? i18n.translate( + 'indexPatternFieldEditor.fieldPreview.documentIdField.documentNotFoundError', + { + defaultMessage: 'Document not found', + } + ) + : null; + + const isInvalid = error !== null && error.code === 'DOC_NOT_FOUND'; + + // We don't display the nav button when the user has entered a custom + // document ID as at that point there is no more reference to what's "next" + const showNavButtons = isCustomId === false; + + const onDocumentIdChange = useCallback( + (e: React.SyntheticEvent) => { + const nextId = (e.target as HTMLInputElement).value; + loadSingle(nextId); + }, + [loadSingle] + ); + + return ( +
+ + + + + + {isCustomId && ( + + loadFromCluster()} + data-test-subj="loadDocsFromClusterButton" + > + {i18n.translate( + 'indexPatternFieldEditor.fieldPreview.documentIdField.loadDocumentsFromCluster', + { + defaultMessage: 'Load documents from cluster', + } + )} + + + )} + + + {showNavButtons && ( + + + + + + + + + + + )} + +
+ ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss new file mode 100644 index 0000000000000..d1bb8cb5731c9 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss @@ -0,0 +1,63 @@ +/** +[1] This corresponds to the ITEM_HEIGHT declared in "field_list.tsx" +[2] This corresponds to the SHOW_MORE_HEIGHT declared in "field_list.tsx" +[3] We need the tooltip to be 100% to display the text ellipsis of the field value +*/ + +$previewFieldItemHeight: 40px; /* [1] */ +$previewShowMoreHeight: 40px; /* [2] */ + +.indexPatternFieldEditor__previewFieldList { + position: relative; + + &__item { + border-bottom: $euiBorderThin; + height: $previewFieldItemHeight; + align-items: center; + overflow: hidden; + + &--highlighted { + $backgroundColor: tintOrShade($euiColorWarning, 90%, 70%); + background: $backgroundColor; + font-weight: 600; + } + + &__key, &__value { + overflow: hidden; + } + + &__actions { + flex-basis: 24px !important; + } + + &__actionsBtn { + display: none; + } + + &--pinned .indexPatternFieldEditor__previewFieldList__item__actionsBtn, + &:hover .indexPatternFieldEditor__previewFieldList__item__actionsBtn { + display: block; + } + + &__value .euiToolTipAnchor { + width: 100%; /* [3] */ + } + + &__key__wrapper, &__value__wrapper { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; + width: 100%; + } + } + + &__showMore { + position: absolute; + width: 100%; + height: $previewShowMoreHeight; + bottom: $previewShowMoreHeight * -1; + display: flex; + align-items: flex-end; + } +} diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx new file mode 100644 index 0000000000000..aae0f0c74a5f1 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx @@ -0,0 +1,235 @@ +/* + * 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, useMemo, useCallback } from 'react'; +import VirtualList from 'react-tiny-virtual-list'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; +import { EuiButtonEmpty, EuiButton, EuiSpacer, EuiEmptyPrompt, EuiTextColor } from '@elastic/eui'; + +import { useFieldEditorContext } from '../../field_editor_context'; +import { + useFieldPreviewContext, + defaultValueFormatter, + FieldPreview, +} from '../field_preview_context'; +import { PreviewListItem } from './field_list_item'; + +import './field_list.scss'; + +const ITEM_HEIGHT = 40; +const SHOW_MORE_HEIGHT = 40; +const INITIAL_MAX_NUMBER_OF_FIELDS = 7; + +export type DocumentField = FieldPreview & { + isPinned?: boolean; +}; + +interface Props { + height: number; + clearSearch: () => void; + searchValue?: string; +} + +/** + * Escape regex special characters (e.g /, ^, $...) with a "\" + * Copied from https://stackoverflow.com/a/9310752 + */ +function escapeRegExp(text: string) { + return text.replace(/[-\[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); +} + +function fuzzyMatch(searchValue: string, text: string) { + const pattern = `.*${searchValue.split('').map(escapeRegExp).join('.*')}.*`; + const regex = new RegExp(pattern, 'i'); + return regex.test(text); +} + +export const PreviewFieldList: React.FC = ({ height, clearSearch, searchValue = '' }) => { + const { indexPattern } = useFieldEditorContext(); + const { + currentDocument: { value: currentDocument }, + pinnedFields: { value: pinnedFields, set: setPinnedFields }, + } = useFieldPreviewContext(); + + const [showAllFields, setShowAllFields] = useState(false); + + const { + fields: { getAll: getAllFields }, + } = indexPattern; + + const indexPatternFields = useMemo(() => { + return getAllFields(); + }, [getAllFields]); + + const fieldList: DocumentField[] = useMemo( + () => + indexPatternFields + .map(({ name, displayName }) => { + const value = get(currentDocument?._source, name); + const formattedValue = defaultValueFormatter(value); + + return { + key: displayName, + value, + formattedValue, + isPinned: false, + }; + }) + .filter(({ value }) => value !== undefined), + [indexPatternFields, currentDocument?._source] + ); + + const fieldListWithPinnedFields: DocumentField[] = useMemo(() => { + const pinned: DocumentField[] = []; + const notPinned: DocumentField[] = []; + + fieldList.forEach((field) => { + if (pinnedFields[field.key]) { + pinned.push({ ...field, isPinned: true }); + } else { + notPinned.push({ ...field, isPinned: false }); + } + }); + + return [...pinned, ...notPinned]; + }, [fieldList, pinnedFields]); + + const { filteredFields, totalFields } = useMemo(() => { + const list = + searchValue.trim() === '' + ? fieldListWithPinnedFields + : fieldListWithPinnedFields.filter(({ key }) => fuzzyMatch(searchValue, key)); + + const total = list.length; + + if (showAllFields) { + return { + filteredFields: list, + totalFields: total, + }; + } + + return { + filteredFields: list.filter((_, i) => i < INITIAL_MAX_NUMBER_OF_FIELDS), + totalFields: total, + }; + }, [fieldListWithPinnedFields, showAllFields, searchValue]); + + const hasSearchValue = searchValue.trim() !== ''; + const isEmptySearchResultVisible = hasSearchValue && totalFields === 0; + + // "height" corresponds to the total height of the flex item that occupies the remaining + // vertical space up to the bottom of the flyout panel. We don't want to give that height + // to the virtual list because it would mean that the "Show more" button would be pinned to the + // bottom of the panel all the time. Which is not what we want when we render initially a few + // fields. + const listHeight = Math.min(filteredFields.length * ITEM_HEIGHT, height - SHOW_MORE_HEIGHT); + + const toggleShowAllFields = useCallback(() => { + setShowAllFields((prev) => !prev); + }, []); + + const toggleIsPinnedField = useCallback( + (name) => { + setPinnedFields((prev) => { + const isPinned = !prev[name]; + return { + ...prev, + [name]: isPinned, + }; + }); + }, + [setPinnedFields] + ); + + const renderEmptyResult = () => { + return ( + <> + + +

+ {i18n.translate( + 'indexPatternFieldEditor.fieldPreview.searchResult.emptyPromptTitle', + { + defaultMessage: 'No matching fields in this index pattern', + } + )} +

+ + } + titleSize="xs" + actions={ + + {i18n.translate( + 'indexPatternFieldEditor.fieldPreview.searchResult.emptyPrompt.clearSearchButtonLabel', + { + defaultMessage: 'Clear search', + } + )} + + } + data-test-subj="emptySearchResult" + /> + + ); + }; + + const renderToggleFieldsButton = () => + totalFields <= INITIAL_MAX_NUMBER_OF_FIELDS ? null : ( +
+ + {showAllFields + ? i18n.translate('indexPatternFieldEditor.fieldPreview.showLessFieldsButtonLabel', { + defaultMessage: 'Show less', + }) + : i18n.translate('indexPatternFieldEditor.fieldPreview.showMoreFieldsButtonLabel', { + defaultMessage: 'Show more', + })} + +
+ ); + + if (currentDocument === undefined || height === -1) { + return null; + } + + return ( +
+ {isEmptySearchResultVisible ? ( + renderEmptyResult() + ) : ( + { + const field = filteredFields[index]; + + return ( +
+ +
+ ); + }} + /> + )} + + {renderToggleFieldsButton()} +
+ ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx new file mode 100644 index 0000000000000..348c442a1cd37 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx @@ -0,0 +1,125 @@ +/* + * 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 } from 'react'; +import classnames from 'classnames'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiToolTip, EuiButtonIcon, EuiButtonEmpty } from '@elastic/eui'; + +import { ImagePreviewModal } from '../image_preview_modal'; +import type { DocumentField } from './field_list'; + +interface Props { + field: DocumentField; + toggleIsPinned?: (name: string) => void; + highlighted?: boolean; +} + +export const PreviewListItem: React.FC = ({ + field: { key, value, formattedValue, isPinned = false }, + highlighted, + toggleIsPinned, +}) => { + const [isPreviewImageModalVisible, setIsPreviewImageModalVisible] = useState(false); + + /* eslint-disable @typescript-eslint/naming-convention */ + const classes = classnames('indexPatternFieldEditor__previewFieldList__item', { + 'indexPatternFieldEditor__previewFieldList__item--highlighted': highlighted, + 'indexPatternFieldEditor__previewFieldList__item--pinned': isPinned, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + + const doesContainImage = formattedValue?.includes(' { + if (doesContainImage) { + return ( + setIsPreviewImageModalVisible(true)} + iconType="image" + > + {i18n.translate('indexPatternFieldEditor.fieldPreview.viewImageButtonLabel', { + defaultMessage: 'View image', + })} + + ); + } + + if (formattedValue !== undefined) { + return ( + + ); + } + + return ( + + {JSON.stringify(value)} + + ); + }; + + return ( + <> + + +
+ {key} +
+
+ + + {renderValue()} + + + + + {toggleIsPinned && ( + { + toggleIsPinned(key); + }} + color="text" + iconType="pinFilled" + data-test-subj="pinFieldButton" + aria-label={i18n.translate( + 'indexPatternFieldEditor.fieldPreview.pinFieldButtonLabel', + { + defaultMessage: 'Pin field', + } + )} + className="indexPatternFieldEditor__previewFieldList__item__actionsBtn" + /> + )} + +
+ {isPreviewImageModalVisible && ( + setIsPreviewImageModalVisible(false)} + /> + )} + + ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss new file mode 100644 index 0000000000000..2d51cd19bf925 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss @@ -0,0 +1,19 @@ +.indexPatternFieldEditor { + &__previewPannel { + display: flex; + flex-direction: column; + height: 100%; + } + + &__previewImageModal__wrapper { + padding: $euiSize; + + img { + max-width: 100%; + } + } + + &__previewEmptySearchResult__title { + font-weight: 400; + } +} diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx new file mode 100644 index 0000000000000..09bacf2a46096 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -0,0 +1,134 @@ +/* + * 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, useCallback, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSpacer, EuiResizeObserver, EuiFieldSearch } from '@elastic/eui'; + +import { useFieldPreviewContext } from './field_preview_context'; +import { FieldPreviewHeader } from './field_preview_header'; +import { FieldPreviewEmptyPrompt } from './field_preview_empty_prompt'; +import { DocumentsNavPreview } from './documents_nav_preview'; +import { FieldPreviewError } from './field_preview_error'; +import { PreviewListItem } from './field_list/field_list_item'; +import { PreviewFieldList } from './field_list/field_list'; + +import './field_preview.scss'; + +export const FieldPreview = () => { + const [fieldListHeight, setFieldListHeight] = useState(-1); + const [searchValue, setSearchValue] = useState(''); + + const { + params: { + value: { name, script, format }, + }, + fields, + error, + reset, + } = useFieldPreviewContext(); + + // To show the preview we at least need a name to be defined, the script or the format + // and an first response from the _execute API + const isEmptyPromptVisible = + name === null && script === null && format === null + ? true + : // If we have some result from the _execute API call don't show the empty prompt + error !== null || fields.length > 0 + ? false + : name === null && format === null + ? true + : false; + + const onFieldListResize = useCallback(({ height }: { height: number }) => { + setFieldListHeight(height); + }, []); + + const renderFieldsToPreview = () => { + if (fields.length === 0) { + return null; + } + + const [field] = fields; + + return ( +
    +
  • + +
  • +
+ ); + }; + + useEffect(() => { + // When unmounting the preview pannel we make sure to reset + // the state of the preview panel. + return reset; + }, [reset]); + + const doShowFieldList = + error === null || (error.code !== 'DOC_NOT_FOUND' && error.code !== 'ERR_FETCHING_DOC'); + + return ( +
+ {isEmptyPromptVisible ? ( + + ) : ( + <> + + + + + + + setSearchValue(e.target.value)} + placeholder={i18n.translate( + 'indexPatternFieldEditor.fieldPreview.filterFieldsPlaceholder', + { + defaultMessage: 'Filter fields', + } + )} + fullWidth + data-test-subj="filterFieldsInput" + /> + + + + + + {doShowFieldList && ( + <> + {/* The current field(s) the user is creating */} + {renderFieldsToPreview()} + + {/* List of other fields in the document */} + + {(resizeRef) => ( +
+ setSearchValue('')} + searchValue={searchValue} + // We add a key to force rerender the virtual list whenever the window height changes + key={fieldListHeight} + /> +
+ )} +
+ + )} + + )} +
+ ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx new file mode 100644 index 0000000000000..e1fc4b05883f4 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx @@ -0,0 +1,599 @@ +/* + * 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, { + createContext, + useState, + useContext, + useMemo, + useCallback, + useEffect, + useRef, + FunctionComponent, +} from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; + +import type { FieldPreviewContext, FieldFormatConfig } from '../../types'; +import { parseEsError } from '../../lib/runtime_field_validation'; +import { RuntimeType, RuntimeField } from '../../shared_imports'; +import { useFieldEditorContext } from '../field_editor_context'; + +type From = 'cluster' | 'custom'; +interface EsDocument { + _id: string; + [key: string]: any; +} + +interface PreviewError { + code: 'DOC_NOT_FOUND' | 'PAINLESS_SCRIPT_ERROR' | 'ERR_FETCHING_DOC'; + error: Record; +} + +interface ClusterData { + documents: EsDocument[]; + currentIdx: number; +} + +// The parameters required to preview the field +interface Params { + name: string | null; + index: string | null; + type: RuntimeType | null; + script: Required['script'] | null; + format: FieldFormatConfig | null; + document: EsDocument | null; +} + +export interface FieldPreview { + key: string; + value: unknown; + formattedValue?: string; +} + +interface Context { + fields: FieldPreview[]; + error: PreviewError | null; + params: { + value: Params; + update: (updated: Partial) => void; + }; + isLoadingPreview: boolean; + currentDocument: { + value?: EsDocument; + id: string; + isLoading: boolean; + isCustomId: boolean; + }; + documents: { + loadSingle: (id: string) => void; + loadFromCluster: () => Promise; + }; + panel: { + isVisible: boolean; + setIsVisible: (isVisible: boolean) => void; + }; + from: { + value: From; + set: (value: From) => void; + }; + navigation: { + isFirstDoc: boolean; + isLastDoc: boolean; + next: () => void; + prev: () => void; + }; + reset: () => void; + pinnedFields: { + value: { [key: string]: boolean }; + set: React.Dispatch>; + }; +} + +const fieldPreviewContext = createContext(undefined); + +const defaultParams: Params = { + name: null, + index: null, + script: null, + document: null, + type: null, + format: null, +}; + +export const defaultValueFormatter = (value: unknown) => + `${typeof value === 'object' ? JSON.stringify(value) : value ?? '-'}`; + +export const FieldPreviewProvider: FunctionComponent = ({ children }) => { + const previewCount = useRef(0); + const [lastExecutePainlessRequestParams, setLastExecutePainlessReqParams] = useState<{ + type: Params['type']; + script: string | undefined; + documentId: string | undefined; + }>({ + type: null, + script: undefined, + documentId: undefined, + }); + + const { + indexPattern, + fieldTypeToProcess, + services: { + search, + notifications, + api: { getFieldPreview }, + }, + fieldFormats, + } = useFieldEditorContext(); + + /** Response from the Painless _execute API */ + const [previewResponse, setPreviewResponse] = useState<{ + fields: Context['fields']; + error: Context['error']; + }>({ fields: [], error: null }); + /** The parameters required for the Painless _execute API */ + const [params, setParams] = useState(defaultParams); + /** The sample documents fetched from the cluster */ + const [clusterData, setClusterData] = useState({ + documents: [], + currentIdx: 0, + }); + /** Flag to show/hide the preview panel */ + const [isPanelVisible, setIsPanelVisible] = useState(false); + /** Flag to indicate if we are loading document from cluster */ + const [isFetchingDocument, setIsFetchingDocument] = useState(false); + /** Flag to indicate if we are calling the _execute API */ + const [isLoadingPreview, setIsLoadingPreview] = useState(false); + /** Flag to indicate if we are loading a single document by providing its ID */ + const [customDocIdToLoad, setCustomDocIdToLoad] = useState(null); + /** Define if we provide the document to preview from the cluster or from a custom JSON */ + const [from, setFrom] = useState('cluster'); + /** Map of fields pinned to the top of the list */ + const [pinnedFields, setPinnedFields] = useState<{ [key: string]: boolean }>({}); + + const { documents, currentIdx } = clusterData; + const currentDocument: EsDocument | undefined = useMemo(() => documents[currentIdx], [ + documents, + currentIdx, + ]); + + const currentDocIndex = currentDocument?._index; + const currentDocId: string = currentDocument?._id ?? ''; + const totalDocs = documents.length; + const { name, document, script, format, type } = params; + + const updateParams: Context['params']['update'] = useCallback((updated) => { + setParams((prev) => ({ ...prev, ...updated })); + }, []); + + const needToUpdatePreview = useMemo(() => { + const isCurrentDocIdDefined = currentDocId !== ''; + + if (!isCurrentDocIdDefined) { + return false; + } + + const allParamsDefined = (['type', 'script', 'index', 'document'] as Array< + keyof Params + >).every((key) => Boolean(params[key])); + + if (!allParamsDefined) { + return false; + } + + const hasSomeParamsChanged = + lastExecutePainlessRequestParams.type !== type || + lastExecutePainlessRequestParams.script !== script?.source || + lastExecutePainlessRequestParams.documentId !== currentDocId; + + return hasSomeParamsChanged; + }, [type, script?.source, currentDocId, params, lastExecutePainlessRequestParams]); + + const valueFormatter = useCallback( + (value: unknown) => { + if (format?.id) { + const formatter = fieldFormats.getInstance(format.id, format.params); + if (formatter) { + return formatter.convertObject?.html(value) ?? JSON.stringify(value); + } + } + + return defaultValueFormatter(value); + }, + [format, fieldFormats] + ); + + const fetchSampleDocuments = useCallback( + async (limit: number = 50) => { + if (typeof limit !== 'number') { + // We guard ourself from passing an event accidentally + throw new Error('The "limit" option must be a number'); + } + + setIsFetchingDocument(true); + setClusterData({ + documents: [], + currentIdx: 0, + }); + setPreviewResponse({ fields: [], error: null }); + + const [response, error] = await search + .search({ + params: { + index: indexPattern.title, + body: { + size: limit, + }, + }, + }) + .toPromise() + .then((res) => [res, null]) + .catch((err) => [null, err]); + + setIsFetchingDocument(false); + setCustomDocIdToLoad(null); + + setClusterData({ + documents: response ? response.rawResponse.hits.hits : [], + currentIdx: 0, + }); + + setPreviewResponse((prev) => ({ ...prev, error })); + }, + [indexPattern, search] + ); + + const loadDocument = useCallback( + async (id: string) => { + if (!Boolean(id.trim())) { + return; + } + + setIsFetchingDocument(true); + + const [response, searchError] = await search + .search({ + params: { + index: indexPattern.title, + body: { + size: 1, + query: { + ids: { + values: [id], + }, + }, + }, + }, + }) + .toPromise() + .then((res) => [res, null]) + .catch((err) => [null, err]); + + setIsFetchingDocument(false); + + const isDocumentFound = response?.rawResponse.hits.total > 0; + const loadedDocuments: EsDocument[] = isDocumentFound ? response.rawResponse.hits.hits : []; + const error: Context['error'] = Boolean(searchError) + ? { + code: 'ERR_FETCHING_DOC', + error: { + message: searchError.toString(), + }, + } + : isDocumentFound === false + ? { + code: 'DOC_NOT_FOUND', + error: { + message: i18n.translate( + 'indexPatternFieldEditor.fieldPreview.error.documentNotFoundDescription', + { + defaultMessage: 'Document ID not found', + } + ), + }, + } + : null; + + setPreviewResponse((prev) => ({ ...prev, error })); + + setClusterData({ + documents: loadedDocuments, + currentIdx: 0, + }); + + if (error !== null) { + // Make sure we disable the "Updating..." indicator as we have an error + // and we won't fetch the preview + setIsLoadingPreview(false); + } + }, + [indexPattern, search] + ); + + const updatePreview = useCallback(async () => { + setLastExecutePainlessReqParams({ + type: params.type, + script: params.script?.source, + documentId: currentDocId, + }); + + if (!needToUpdatePreview) { + return; + } + + const currentApiCall = ++previewCount.current; + + const response = await getFieldPreview({ + index: currentDocIndex, + document: params.document!, + context: `${params.type!}_field` as FieldPreviewContext, + script: params.script!, + }); + + if (currentApiCall !== previewCount.current) { + // Discard this response as there is another one inflight + // or we have called reset() and don't need the response anymore. + return; + } + + setIsLoadingPreview(false); + + const { error: serverError } = response; + + if (serverError) { + // Server error (not an ES error) + const title = i18n.translate('indexPatternFieldEditor.fieldPreview.errorTitle', { + defaultMessage: 'Failed to load field preview', + }); + notifications.toasts.addError(serverError, { title }); + + return; + } + + const { values, error } = response.data ?? { values: [], error: {} }; + + if (error) { + const fallBackError = { + message: i18n.translate('indexPatternFieldEditor.fieldPreview.defaultErrorTitle', { + defaultMessage: 'Unable to run the provided script', + }), + }; + + setPreviewResponse({ + fields: [], + error: { code: 'PAINLESS_SCRIPT_ERROR', error: parseEsError(error, true) ?? fallBackError }, + }); + } else { + const [value] = values; + const formattedValue = valueFormatter(value); + + setPreviewResponse({ + fields: [{ key: params.name!, value, formattedValue }], + error: null, + }); + } + }, [ + needToUpdatePreview, + params, + currentDocIndex, + currentDocId, + getFieldPreview, + notifications.toasts, + valueFormatter, + ]); + + const goToNextDoc = useCallback(() => { + if (currentIdx >= totalDocs - 1) { + setClusterData((prev) => ({ ...prev, currentIdx: 0 })); + } else { + setClusterData((prev) => ({ ...prev, currentIdx: prev.currentIdx + 1 })); + } + }, [currentIdx, totalDocs]); + + const goToPrevDoc = useCallback(() => { + if (currentIdx === 0) { + setClusterData((prev) => ({ ...prev, currentIdx: totalDocs - 1 })); + } else { + setClusterData((prev) => ({ ...prev, currentIdx: prev.currentIdx - 1 })); + } + }, [currentIdx, totalDocs]); + + const reset = useCallback(() => { + // By resetting the previewCount we will discard any inflight + // API call response coming in after calling reset() was called + previewCount.current = 0; + + setClusterData({ + documents: [], + currentIdx: 0, + }); + setPreviewResponse({ fields: [], error: null }); + setLastExecutePainlessReqParams({ + type: null, + script: undefined, + documentId: undefined, + }); + setFrom('cluster'); + setIsLoadingPreview(false); + setIsFetchingDocument(false); + }, []); + + const ctx = useMemo( + () => ({ + fields: previewResponse.fields, + error: previewResponse.error, + isLoadingPreview, + params: { + value: params, + update: updateParams, + }, + currentDocument: { + value: currentDocument, + id: customDocIdToLoad !== null ? customDocIdToLoad : currentDocId, + isLoading: isFetchingDocument, + isCustomId: customDocIdToLoad !== null, + }, + documents: { + loadSingle: setCustomDocIdToLoad, + loadFromCluster: fetchSampleDocuments, + }, + navigation: { + isFirstDoc: currentIdx === 0, + isLastDoc: currentIdx >= totalDocs - 1, + next: goToNextDoc, + prev: goToPrevDoc, + }, + panel: { + isVisible: isPanelVisible, + setIsVisible: setIsPanelVisible, + }, + from: { + value: from, + set: setFrom, + }, + reset, + pinnedFields: { + value: pinnedFields, + set: setPinnedFields, + }, + }), + [ + previewResponse, + params, + isLoadingPreview, + updateParams, + currentDocument, + currentDocId, + fetchSampleDocuments, + isFetchingDocument, + customDocIdToLoad, + currentIdx, + totalDocs, + goToNextDoc, + goToPrevDoc, + isPanelVisible, + from, + reset, + pinnedFields, + ] + ); + + /** + * In order to immediately display the "Updating..." state indicator and not have to wait + * the 500ms of the debounce, we set the isLoadingPreview state in this effect + */ + useEffect(() => { + if (needToUpdatePreview) { + setIsLoadingPreview(true); + } + }, [needToUpdatePreview, customDocIdToLoad]); + + /** + * Whenever we enter manually a document ID to load we'll clear the + * documents and the preview value. + */ + useEffect(() => { + if (customDocIdToLoad !== null) { + setIsFetchingDocument(true); + + setClusterData({ + documents: [], + currentIdx: 0, + }); + + setPreviewResponse((prev) => { + const { + fields: { 0: field }, + } = prev; + return { + ...prev, + fields: [ + { ...field, value: undefined, formattedValue: defaultValueFormatter(undefined) }, + ], + }; + }); + } + }, [customDocIdToLoad]); + + /** + * Whenever we show the preview panel we will update the documents from the cluster + */ + useEffect(() => { + if (isPanelVisible) { + fetchSampleDocuments(); + } + }, [isPanelVisible, fetchSampleDocuments, fieldTypeToProcess]); + + /** + * Each time the current document changes we update the parameters + * that will be sent in the _execute HTTP request. + */ + useEffect(() => { + updateParams({ + document: currentDocument?._source, + index: currentDocument?._index, + }); + }, [currentDocument, updateParams]); + + /** + * Whenever the name or the format changes we immediately update the preview + */ + useEffect(() => { + setPreviewResponse((prev) => { + const { + fields: { 0: field }, + } = prev; + + const nextValue = + script === null && Boolean(document) + ? get(document, name ?? '') // When there is no script we read the value from _source + : field?.value; + + const formattedValue = valueFormatter(nextValue); + + return { + ...prev, + fields: [{ ...field, key: name ?? '', value: nextValue, formattedValue }], + }; + }); + }, [name, script, document, valueFormatter]); + + useDebounce( + // Whenever updatePreview() changes (meaning whenever any of the params changes) + // we call it to update the preview response with the field(s) value or possible error. + updatePreview, + 500, + [updatePreview] + ); + + useDebounce( + () => { + if (customDocIdToLoad === null) { + return; + } + + loadDocument(customDocIdToLoad); + }, + 500, + [customDocIdToLoad] + ); + + return {children}; +}; + +export const useFieldPreviewContext = (): Context => { + const ctx = useContext(fieldPreviewContext); + + if (ctx === undefined) { + throw new Error('useFieldPreviewContext must be used within a '); + } + + return ctx; +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx new file mode 100644 index 0000000000000..6e4c4626d9dae --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx @@ -0,0 +1,43 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiEmptyPrompt, EuiText, EuiTextColor, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export const FieldPreviewEmptyPrompt = () => { + return ( + + + + {i18n.translate('indexPatternFieldEditor.fieldPreview.emptyPromptTitle', { + defaultMessage: 'Preview', + })} + + } + titleSize="s" + body={ + + +

+ {i18n.translate('indexPatternFieldEditor.fieldPreview.emptyPromptDescription', { + defaultMessage: + 'Enter the name of an existing field or define a script to view a preview of the calculated output.', + })} +

+
+
+ } + /> +
+
+ ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx new file mode 100644 index 0000000000000..7994e649e1ebb --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { useFieldPreviewContext } from './field_preview_context'; + +export const FieldPreviewError = () => { + const { error } = useFieldPreviewContext(); + + if (error === null) { + return null; + } + + return ( + + {error.code === 'PAINLESS_SCRIPT_ERROR' ? ( +

{error.error.reason}

+ ) : ( +

{error.error.message}

+ )} +
+ ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx new file mode 100644 index 0000000000000..2d3d5c20ba7b3 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx @@ -0,0 +1,77 @@ +/* + * 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 { + EuiTitle, + EuiText, + EuiTextColor, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { useFieldEditorContext } from '../field_editor_context'; +import { useFieldPreviewContext } from './field_preview_context'; + +const i18nTexts = { + title: i18n.translate('indexPatternFieldEditor.fieldPreview.title', { + defaultMessage: 'Preview', + }), + customData: i18n.translate('indexPatternFieldEditor.fieldPreview.subTitle.customData', { + defaultMessage: 'Custom data', + }), + updatingLabel: i18n.translate('indexPatternFieldEditor.fieldPreview.updatingPreviewLabel', { + defaultMessage: 'Updating...', + }), +}; + +export const FieldPreviewHeader = () => { + const { indexPattern } = useFieldEditorContext(); + const { + from, + isLoadingPreview, + currentDocument: { isLoading }, + } = useFieldPreviewContext(); + + const isUpdating = isLoadingPreview || isLoading; + + return ( +
+ + + +

{i18nTexts.title}

+
+
+ + {isUpdating && ( + + + + + + {i18nTexts.updatingLabel} + + + )} +
+ + + {i18n.translate('indexPatternFieldEditor.fieldPreview.subTitle', { + defaultMessage: 'From: {from}', + values: { + from: from.value === 'cluster' ? indexPattern.title : i18nTexts.customData, + }, + })} + + +
+ ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/image_preview_modal.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/image_preview_modal.tsx new file mode 100644 index 0000000000000..69be3d144bfda --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/image_preview_modal.tsx @@ -0,0 +1,43 @@ +/* + * 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 { EuiModal, EuiModalBody } from '@elastic/eui'; + +/** + * By default the image formatter sets the max-width to "none" on the tag + * To render nicely the image in the modal we want max_width: 100% + */ +const setMaxWidthImage = (imgHTML: string): string => { + const regex = new RegExp('max-width:[^;]+;', 'gm'); + + if (regex.test(imgHTML)) { + return imgHTML.replace(regex, 'max-width: 100%;'); + } + + return imgHTML; +}; + +interface Props { + imgHTML: string; + closeModal: () => void; +} + +export const ImagePreviewModal = ({ imgHTML, closeModal }: Props) => { + return ( + + +
+ + + ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/index.ts b/src/plugins/index_pattern_field_editor/public/components/preview/index.ts new file mode 100644 index 0000000000000..5d3b4bb41fc5f --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { useFieldPreviewContext, FieldPreviewProvider } from './field_preview_context'; + +export { FieldPreview } from './field_preview'; diff --git a/src/plugins/index_pattern_field_editor/public/constants.ts b/src/plugins/index_pattern_field_editor/public/constants.ts index 69d231f375848..5a16e805a3fc9 100644 --- a/src/plugins/index_pattern_field_editor/public/constants.ts +++ b/src/plugins/index_pattern_field_editor/public/constants.ts @@ -7,3 +7,5 @@ */ export const pluginName = 'index_pattern_field_editor'; + +export const euiFlyoutClassname = 'indexPatternFieldEditorFlyout'; diff --git a/src/plugins/index_pattern_field_editor/public/index.ts b/src/plugins/index_pattern_field_editor/public/index.ts index a63c9ada52e3d..80ead500c3d9d 100644 --- a/src/plugins/index_pattern_field_editor/public/index.ts +++ b/src/plugins/index_pattern_field_editor/public/index.ts @@ -21,7 +21,7 @@ import { IndexPatternFieldEditorPlugin } from './plugin'; export { PluginStart as IndexPatternFieldEditorStart } from './types'; -export { DefaultFormatEditor } from './components'; +export { DefaultFormatEditor } from './components/field_format_editor/editors/default/default'; export { FieldFormatEditorFactory, FieldFormatEditor, FormatEditorProps } from './components'; export function plugin() { @@ -31,4 +31,3 @@ export function plugin() { // Expose types export type { OpenFieldEditorOptions } from './open_editor'; export type { OpenFieldDeleteModalOptions } from './open_delete_modal'; -export type { FieldEditorContext } from './components/field_editor_flyout_content_container'; diff --git a/src/plugins/index_pattern_field_editor/public/lib/api.ts b/src/plugins/index_pattern_field_editor/public/lib/api.ts new file mode 100644 index 0000000000000..9325b5c2faf47 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/lib/api.ts @@ -0,0 +1,42 @@ +/* + * 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 { HttpSetup } from 'src/core/public'; +import { API_BASE_PATH } from '../../common/constants'; +import { sendRequest } from '../shared_imports'; +import { FieldPreviewContext, FieldPreviewResponse } from '../types'; + +export const initApi = (httpClient: HttpSetup) => { + const getFieldPreview = ({ + index, + context, + script, + document, + }: { + index: string; + context: FieldPreviewContext; + script: { source: string } | null; + document: Record; + }) => { + return sendRequest(httpClient, { + path: `${API_BASE_PATH}/field_preview`, + method: 'post', + body: { + index, + context, + script, + document, + }, + }); + }; + + return { + getFieldPreview, + }; +}; + +export type ApiService = ReturnType; diff --git a/src/plugins/index_pattern_field_editor/public/lib/index.ts b/src/plugins/index_pattern_field_editor/public/lib/index.ts index 5d5b3d881e976..336de9574c460 100644 --- a/src/plugins/index_pattern_field_editor/public/lib/index.ts +++ b/src/plugins/index_pattern_field_editor/public/lib/index.ts @@ -10,4 +10,10 @@ export { deserializeField } from './serialization'; export { getLinks } from './documentation'; -export { getRuntimeFieldValidator, RuntimeFieldPainlessError } from './runtime_field_validation'; +export { + getRuntimeFieldValidator, + RuntimeFieldPainlessError, + parseEsError, +} from './runtime_field_validation'; + +export { initApi, ApiService } from './api'; diff --git a/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts b/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts index f1a6fd7f9e8aa..789c4f7fa71fc 100644 --- a/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts +++ b/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { DataPublicPluginStart } from '../shared_imports'; -import { EsRuntimeField } from '../types'; +import type { EsRuntimeField } from '../types'; export interface RuntimeFieldPainlessError { message: string; @@ -60,12 +60,15 @@ const getScriptExceptionError = (error: Error): Error | null => { return scriptExceptionError; }; -const parseEsError = (error?: Error): RuntimeFieldPainlessError | null => { +export const parseEsError = ( + error?: Error, + isScriptError = false +): RuntimeFieldPainlessError | null => { if (error === undefined) { return null; } - const scriptError = getScriptExceptionError(error.caused_by); + const scriptError = isScriptError ? error : getScriptExceptionError(error.caused_by); if (scriptError === null) { return null; diff --git a/src/plugins/index_pattern_field_editor/public/lib/serialization.ts b/src/plugins/index_pattern_field_editor/public/lib/serialization.ts index 9000a34b23cbe..8a0a47e07c9c9 100644 --- a/src/plugins/index_pattern_field_editor/public/lib/serialization.ts +++ b/src/plugins/index_pattern_field_editor/public/lib/serialization.ts @@ -7,7 +7,7 @@ */ import { IndexPatternField, IndexPattern } from '../shared_imports'; -import { Field } from '../types'; +import type { Field } from '../types'; export const deserializeField = ( indexPattern: IndexPattern, diff --git a/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx b/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx index 27aa1d0313a7f..72dbb76863353 100644 --- a/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx +++ b/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx @@ -18,7 +18,7 @@ import { import { CloseEditor } from './types'; -import { DeleteFieldModal } from './components/delete_field_modal'; +import { DeleteFieldModal } from './components/confirm_modals/delete_field_modal'; import { removeFields } from './lib/remove_fields'; export interface OpenFieldDeleteModalOptions { diff --git a/src/plugins/index_pattern_field_editor/public/open_editor.tsx b/src/plugins/index_pattern_field_editor/public/open_editor.tsx index e70ba48cca0a5..946e666bf8205 100644 --- a/src/plugins/index_pattern_field_editor/public/open_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/open_editor.tsx @@ -19,10 +19,10 @@ import { UsageCollectionStart, } from './shared_imports'; -import { InternalFieldType, CloseEditor } from './types'; -import { FieldEditorFlyoutContentContainer } from './components/field_editor_flyout_content_container'; - -import { PluginStart } from './types'; +import type { PluginStart, InternalFieldType, CloseEditor } from './types'; +import type { ApiService } from './lib/api'; +import { euiFlyoutClassname } from './constants'; +import { FieldEditorLoader } from './components/field_editor_loader'; export interface OpenFieldEditorOptions { ctx: { @@ -37,6 +37,7 @@ interface Dependencies { /** The search service from the data plugin */ search: DataPublicPluginStart['search']; indexPatternService: DataPublicPluginStart['indexPatterns']; + apiService: ApiService; fieldFormats: DataPublicPluginStart['fieldFormats']; fieldFormatEditors: PluginStart['fieldFormatEditors']; usageCollection: UsageCollectionStart; @@ -49,6 +50,7 @@ export const getFieldEditorOpener = ({ fieldFormatEditors, search, usageCollection, + apiService, }: Dependencies) => (options: OpenFieldEditorOptions): CloseEditor => { const { uiSettings, overlays, docLinks, notifications } = core; const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ @@ -58,8 +60,19 @@ export const getFieldEditorOpener = ({ }); let overlayRef: OverlayRef | null = null; + const canCloseValidator = { + current: () => true, + }; + + const onMounted = (args: { canCloseValidator: () => boolean }) => { + canCloseValidator.current = args.canCloseValidator; + }; - const openEditor = ({ onSave, fieldName, ctx }: OpenFieldEditorOptions): CloseEditor => { + const openEditor = ({ + onSave, + fieldName, + ctx: { indexPattern }, + }: OpenFieldEditorOptions): CloseEditor => { const closeEditor = () => { if (overlayRef) { overlayRef.close(); @@ -75,7 +88,7 @@ export const getFieldEditorOpener = ({ } }; - const field = fieldName ? ctx.indexPattern.getFieldByName(fieldName) : undefined; + const field = fieldName ? indexPattern.getFieldByName(fieldName) : undefined; if (fieldName && !field) { const err = i18n.translate('indexPatternFieldEditor.noSuchFieldName', { @@ -94,21 +107,48 @@ export const getFieldEditorOpener = ({ overlayRef = overlays.openFlyout( toMountPoint( - - ) + ), + { + className: euiFlyoutClassname, + maxWidth: 708, + size: 'l', + ownFocus: true, + hideCloseButton: true, + 'aria-label': isNewRuntimeField + ? i18n.translate('indexPatternFieldEditor.createField.flyoutAriaLabel', { + defaultMessage: 'Create field', + }) + : i18n.translate('indexPatternFieldEditor.editField.flyoutAriaLabel', { + defaultMessage: 'Edit {fieldName} field', + values: { + fieldName, + }, + }), + onClose: (flyout) => { + const canClose = canCloseValidator.current(); + if (canClose) { + flyout.close(); + } + }, + } ); return closeEditor; diff --git a/src/plugins/index_pattern_field_editor/public/plugin.test.tsx b/src/plugins/index_pattern_field_editor/public/plugin.test.tsx index 2212264427d1a..75bb1322d305e 100644 --- a/src/plugins/index_pattern_field_editor/public/plugin.test.tsx +++ b/src/plugins/index_pattern_field_editor/public/plugin.test.tsx @@ -6,6 +6,7 @@ * Side Public License, v 1. */ import React from 'react'; +import { registerTestBed } from '@kbn/test/jest'; jest.mock('../../kibana_react/public', () => { const original = jest.requireActual('../../kibana_react/public'); @@ -21,11 +22,9 @@ import { coreMock } from 'src/core/public/mocks'; import { dataPluginMock } from '../../data/public/mocks'; import { usageCollectionPluginMock } from '../../usage_collection/public/mocks'; -import { registerTestBed } from './test_utils'; - -import { FieldEditorFlyoutContentContainer } from './components/field_editor_flyout_content_container'; +import { FieldEditorLoader } from './components/field_editor_loader'; import { IndexPatternFieldEditorPlugin } from './plugin'; -import { DeleteFieldModal } from './components/delete_field_modal'; +import { DeleteFieldModal } from './components/confirm_modals/delete_field_modal'; import { IndexPattern } from './shared_imports'; const noop = () => {}; @@ -66,7 +65,7 @@ describe('IndexPatternFieldEditorPlugin', () => { expect(openFlyout).toHaveBeenCalled(); const [[arg]] = openFlyout.mock.calls; - expect(arg.props.children.type).toBe(FieldEditorFlyoutContentContainer); + expect(arg.props.children.type).toBe(FieldEditorLoader); // We force call the "onSave" prop from the component // and make sure that the the spy is being called. diff --git a/src/plugins/index_pattern_field_editor/public/plugin.ts b/src/plugins/index_pattern_field_editor/public/plugin.ts index a46bef92cbbb1..4bf8dd5c1c4e8 100644 --- a/src/plugins/index_pattern_field_editor/public/plugin.ts +++ b/src/plugins/index_pattern_field_editor/public/plugin.ts @@ -8,11 +8,12 @@ import { Plugin, CoreSetup, CoreStart } from 'src/core/public'; -import { PluginSetup, PluginStart, SetupPlugins, StartPlugins } from './types'; +import type { PluginSetup, PluginStart, SetupPlugins, StartPlugins } from './types'; import { getFieldEditorOpener } from './open_editor'; -import { FormatEditorService } from './service'; +import { FormatEditorService } from './service/format_editor_service'; import { getDeleteFieldProvider } from './components/delete_field_provider'; import { getFieldDeleteModalOpener } from './open_delete_modal'; +import { initApi } from './lib/api'; export class IndexPatternFieldEditorPlugin implements Plugin { @@ -30,6 +31,7 @@ export class IndexPatternFieldEditorPlugin const { fieldFormatEditors } = this.formatEditorService.start(); const { application: { capabilities }, + http, } = core; const { data, usageCollection } = plugins; const openDeleteModal = getFieldDeleteModalOpener({ @@ -42,6 +44,7 @@ export class IndexPatternFieldEditorPlugin openEditor: getFieldEditorOpener({ core, indexPatternService: data.indexPatterns, + apiService: initApi(http), fieldFormats: data.fieldFormats, fieldFormatEditors, search: data.search, diff --git a/src/plugins/index_pattern_field_editor/public/shared_imports.ts b/src/plugins/index_pattern_field_editor/public/shared_imports.ts index cfc36543780c1..2827928d1c060 100644 --- a/src/plugins/index_pattern_field_editor/public/shared_imports.ts +++ b/src/plugins/index_pattern_field_editor/public/shared_imports.ts @@ -20,6 +20,7 @@ export { useForm, useFormData, useFormContext, + useFormIsModified, Form, FormSchema, UseField, @@ -31,3 +32,5 @@ export { export { fieldValidators } from '../../es_ui_shared/static/forms/helpers'; export { TextField, ToggleField, NumericField } from '../../es_ui_shared/static/forms/components'; + +export { sendRequest } from '../../es_ui_shared/public'; diff --git a/src/plugins/index_pattern_field_editor/public/test_utils/helpers.ts b/src/plugins/index_pattern_field_editor/public/test_utils/helpers.ts deleted file mode 100644 index b55a59df34545..0000000000000 --- a/src/plugins/index_pattern_field_editor/public/test_utils/helpers.ts +++ /dev/null @@ -1,41 +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 { act } from 'react-dom/test-utils'; -import { TestBed } from './test_utils'; - -export const getCommonActions = (testBed: TestBed) => { - const toggleFormRow = (row: 'customLabel' | 'value' | 'format', value: 'on' | 'off' = 'on') => { - const testSubj = `${row}Row.toggle`; - const toggle = testBed.find(testSubj); - const isOn = toggle.props()['aria-checked']; - - if ((value === 'on' && isOn) || (value === 'off' && isOn === false)) { - return; - } - - testBed.form.toggleEuiSwitch(testSubj); - }; - - const changeFieldType = async (value: string, label?: string) => { - await act(async () => { - testBed.find('typeField').simulate('change', [ - { - value, - label: label ?? value, - }, - ]); - }); - testBed.component.update(); - }; - - return { - toggleFormRow, - changeFieldType, - }; -}; diff --git a/src/plugins/index_pattern_field_editor/public/test_utils/mocks.ts b/src/plugins/index_pattern_field_editor/public/test_utils/mocks.ts deleted file mode 100644 index c6bc24f176858..0000000000000 --- a/src/plugins/index_pattern_field_editor/public/test_utils/mocks.ts +++ /dev/null @@ -1,24 +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 { DocLinksStart } from 'src/core/public'; - -export const noop = () => {}; - -export const docLinks: DocLinksStart = { - ELASTIC_WEBSITE_URL: 'htts://jestTest.elastic.co', - DOC_LINK_VERSION: 'jest', - links: {} as any, -}; - -// TODO check how we can better stub an index pattern format -export const fieldFormats = { - getDefaultInstance: () => ({ - convert: (val: any) => val, - }), -} as any; diff --git a/src/plugins/index_pattern_field_editor/public/types.ts b/src/plugins/index_pattern_field_editor/public/types.ts index e78c0805c51b5..f7efc9d82fc48 100644 --- a/src/plugins/index_pattern_field_editor/public/types.ts +++ b/src/plugins/index_pattern_field_editor/public/types.ts @@ -65,3 +65,17 @@ export interface EsRuntimeField { } export type CloseEditor = () => void; + +export type FieldPreviewContext = + | 'boolean_field' + | 'date_field' + | 'double_field' + | 'geo_point_field' + | 'ip_field' + | 'keyword_field' + | 'long_field'; + +export interface FieldPreviewResponse { + values: unknown[]; + error?: Record; +} diff --git a/src/plugins/index_pattern_field_editor/server/index.ts b/src/plugins/index_pattern_field_editor/server/index.ts new file mode 100644 index 0000000000000..dc6f734a7e503 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/server/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 { PluginInitializerContext } from '../../../../src/core/server'; +import { IndexPatternPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new IndexPatternPlugin(initializerContext); +} diff --git a/src/plugins/index_pattern_field_editor/server/plugin.ts b/src/plugins/index_pattern_field_editor/server/plugin.ts new file mode 100644 index 0000000000000..18601aad85307 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/server/plugin.ts @@ -0,0 +1,31 @@ +/* + * 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 { PluginInitializerContext, CoreSetup, Plugin, Logger } from 'kibana/server'; + +import { ApiRoutes } from './routes'; + +export class IndexPatternPlugin implements Plugin { + private readonly logger: Logger; + private readonly apiRoutes: ApiRoutes; + + constructor({ logger }: PluginInitializerContext) { + this.logger = logger.get(); + this.apiRoutes = new ApiRoutes(); + } + + public setup({ http }: CoreSetup) { + this.logger.debug('index_pattern_field_editor: setup'); + + const router = http.createRouter(); + this.apiRoutes.setup({ router }); + } + + public start() {} + + public stop() {} +} diff --git a/src/plugins/index_pattern_field_editor/server/routes/field_preview.ts b/src/plugins/index_pattern_field_editor/server/routes/field_preview.ts new file mode 100644 index 0000000000000..238701904e22c --- /dev/null +++ b/src/plugins/index_pattern_field_editor/server/routes/field_preview.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; +import { HttpResponsePayload } from 'kibana/server'; + +import { API_BASE_PATH } from '../../common/constants'; +import { RouteDependencies } from '../types'; +import { handleEsError } from '../shared_imports'; + +const bodySchema = schema.object({ + index: schema.string(), + script: schema.object({ source: schema.string() }), + context: schema.oneOf([ + schema.literal('boolean_field'), + schema.literal('date_field'), + schema.literal('double_field'), + schema.literal('geo_point_field'), + schema.literal('ip_field'), + schema.literal('keyword_field'), + schema.literal('long_field'), + ]), + document: schema.object({}, { unknowns: 'allow' }), +}); + +export const registerFieldPreviewRoute = ({ router }: RouteDependencies): void => { + router.post( + { + path: `${API_BASE_PATH}/field_preview`, + validate: { + body: bodySchema, + }, + }, + async (ctx, req, res) => { + const { client } = ctx.core.elasticsearch; + + const body = JSON.stringify({ + script: req.body.script, + context: req.body.context, + context_setup: { + document: req.body.document, + index: req.body.index, + } as any, + }); + + try { + const response = await client.asCurrentUser.scriptsPainlessExecute({ + // @ts-expect-error `ExecutePainlessScriptRequest.body` does not allow `string` + body, + }); + + const fieldValue = (response.body.result as any[]) as HttpResponsePayload; + + return res.ok({ body: { values: fieldValue } }); + } catch (error) { + // Assume invalid painless script was submitted + // Return 200 with error object + const handleCustomError = () => { + return res.ok({ + body: { values: [], ...error.body }, + }); + }; + + return handleEsError({ error, response: res, handleCustomError }); + } + } + ); +}; diff --git a/src/plugins/index_pattern_field_editor/server/routes/index.ts b/src/plugins/index_pattern_field_editor/server/routes/index.ts new file mode 100644 index 0000000000000..c04c5bb3feec4 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/server/routes/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RouteDependencies } from '../types'; +import { registerFieldPreviewRoute } from './field_preview'; + +export class ApiRoutes { + setup(dependencies: RouteDependencies) { + registerFieldPreviewRoute(dependencies); + } +} diff --git a/src/plugins/index_pattern_field_editor/public/test_utils/test_utils.ts b/src/plugins/index_pattern_field_editor/server/shared_imports.ts similarity index 76% rename from src/plugins/index_pattern_field_editor/public/test_utils/test_utils.ts rename to src/plugins/index_pattern_field_editor/server/shared_imports.ts index c8e4aedc26471..d818f11ceefda 100644 --- a/src/plugins/index_pattern_field_editor/public/test_utils/test_utils.ts +++ b/src/plugins/index_pattern_field_editor/server/shared_imports.ts @@ -6,6 +6,4 @@ * Side Public License, v 1. */ -export { getRandomString } from '@kbn/test/jest'; - -export { registerTestBed, TestBed } from '@kbn/test/jest'; +export { handleEsError } from '../../es_ui_shared/server'; diff --git a/src/plugins/index_pattern_field_editor/server/types.ts b/src/plugins/index_pattern_field_editor/server/types.ts new file mode 100644 index 0000000000000..c86708c12a71e --- /dev/null +++ b/src/plugins/index_pattern_field_editor/server/types.ts @@ -0,0 +1,12 @@ +/* + * 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 { IRouter } from 'src/core/server'; + +export interface RouteDependencies { + router: IRouter; +} diff --git a/src/plugins/index_pattern_field_editor/tsconfig.json b/src/plugins/index_pattern_field_editor/tsconfig.json index e5caf463835d0..11a16ace1f2f5 100644 --- a/src/plugins/index_pattern_field_editor/tsconfig.json +++ b/src/plugins/index_pattern_field_editor/tsconfig.json @@ -7,7 +7,11 @@ "declarationMap": true }, "include": [ + "../../../typings/**/*", + "__jest__/**/*", + "common/**/*", "public/**/*", + "server/**/*", ], "references": [ { "path": "../../core/tsconfig.json" }, diff --git a/test/accessibility/apps/management.ts b/test/accessibility/apps/management.ts index 538755b482fbf..2fb3de63a81a7 100644 --- a/test/accessibility/apps/management.ts +++ b/test/accessibility/apps/management.ts @@ -5,11 +5,16 @@ * 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 { FtrProviderContext } from '../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const PageObjects = getPageObjects(['common', 'settings', 'header']); + const PageObjects = getPageObjects([ + 'common', + 'settings', + 'header', + 'indexPatternFieldEditorObjects', + ]); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const a11y = getService('a11y'); @@ -58,10 +63,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.settings.toggleRow('customLabelRow'); await PageObjects.settings.setCustomLabel('custom label'); await testSubjects.click('toggleAdvancedSetting'); + // Let's make sure the field preview is visible before testing the snapshot + const isFieldPreviewVisible = await PageObjects.indexPatternFieldEditorObjects.isFieldPreviewVisible(); + expect(isFieldPreviewVisible).to.be(true); await a11y.testAppSnapshot(); - await testSubjects.click('euiFlyoutCloseButton'); await PageObjects.settings.closeIndexPatternFieldEditor(); }); @@ -83,7 +90,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('Edit field type', async () => { await PageObjects.settings.clickEditFieldFormat(); await a11y.testAppSnapshot(); - await PageObjects.settings.clickCloseEditFieldFormatFlyout(); + await PageObjects.settings.closeIndexPatternFieldEditor(); }); it('Advanced settings', async () => { diff --git a/test/api_integration/apis/index.ts b/test/api_integration/apis/index.ts index 0d87569cb8b97..998c0b834d224 100644 --- a/test/api_integration/apis/index.ts +++ b/test/api_integration/apis/index.ts @@ -14,6 +14,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./core')); loadTestFile(require.resolve('./general')); loadTestFile(require.resolve('./home')); + loadTestFile(require.resolve('./index_pattern_field_editor')); loadTestFile(require.resolve('./index_patterns')); loadTestFile(require.resolve('./kql_telemetry')); loadTestFile(require.resolve('./saved_objects_management')); diff --git a/test/api_integration/apis/index_pattern_field_editor/constants.ts b/test/api_integration/apis/index_pattern_field_editor/constants.ts new file mode 100644 index 0000000000000..ecd6b1ddd408b --- /dev/null +++ b/test/api_integration/apis/index_pattern_field_editor/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const API_BASE_PATH = '/api/index_pattern_field_editor'; diff --git a/test/api_integration/apis/index_pattern_field_editor/field_preview.ts b/test/api_integration/apis/index_pattern_field_editor/field_preview.ts new file mode 100644 index 0000000000000..a84accc8e5f03 --- /dev/null +++ b/test/api_integration/apis/index_pattern_field_editor/field_preview.ts @@ -0,0 +1,130 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; +import { API_BASE_PATH } from './constants'; + +const INDEX_NAME = 'api-integration-test-field-preview'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + + const createIndex = async () => { + await es.indices.create({ + index: INDEX_NAME, + body: { + mappings: { + properties: { + foo: { + type: 'integer', + }, + bar: { + type: 'keyword', + }, + }, + }, + }, + }); + }; + + const deleteIndex = async () => { + await es.indices.delete({ + index: INDEX_NAME, + }); + }; + + describe('Field preview', function () { + before(async () => await createIndex()); + after(async () => await deleteIndex()); + + describe('should return the script value', () => { + const document = { foo: 1, bar: 'hello' }; + + const tests = [ + { + context: 'keyword_field', + script: { + source: 'emit("test")', + }, + expected: 'test', + }, + { + context: 'long_field', + script: { + source: 'emit(doc["foo"].value + 1)', + }, + expected: 2, + }, + { + context: 'keyword_field', + script: { + source: 'emit(doc["bar"].value + " world")', + }, + expected: 'hello world', + }, + ]; + + tests.forEach((test) => { + it(`> ${test.context}`, async () => { + const payload = { + script: test.script, + document, + context: test.context, + index: INDEX_NAME, + }; + + const { body: response } = await supertest + .post(`${API_BASE_PATH}/field_preview`) + .send(payload) + .set('kbn-xsrf', 'xxx') + .expect(200); + + expect(response.values).eql([test.expected]); + }); + }); + }); + + describe('payload validation', () => { + it('should require a script', async () => { + await supertest + .post(`${API_BASE_PATH}/field_preview`) + .send({ + context: 'keyword_field', + index: INDEX_NAME, + }) + .set('kbn-xsrf', 'xxx') + .expect(400); + }); + + it('should require a context', async () => { + await supertest + .post(`${API_BASE_PATH}/field_preview`) + .send({ + script: { source: 'emit("hello")' }, + index: INDEX_NAME, + }) + .set('kbn-xsrf', 'xxx') + .expect(400); + }); + + it('should require an index', async () => { + await supertest + .post(`${API_BASE_PATH}/field_preview`) + .send({ + script: { source: 'emit("hello")' }, + context: 'keyword_field', + }) + .set('kbn-xsrf', 'xxx') + .expect(400); + }); + }); + }); +} diff --git a/test/api_integration/apis/index_pattern_field_editor/index.ts b/test/api_integration/apis/index_pattern_field_editor/index.ts new file mode 100644 index 0000000000000..51e4dfaa6ccee --- /dev/null +++ b/test/api_integration/apis/index_pattern_field_editor/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('index pattern field editor', () => { + loadTestFile(require.resolve('./field_preview')); + }); +} diff --git a/test/functional/apps/management/_field_formatter.ts b/test/functional/apps/management/_field_formatter.ts index 60c1bbe7b3d1d..9231da8209326 100644 --- a/test/functional/apps/management/_field_formatter.ts +++ b/test/functional/apps/management/_field_formatter.ts @@ -53,7 +53,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.settings.setFieldFormat('duration'); await PageObjects.settings.setFieldFormat('bytes'); await PageObjects.settings.setFieldFormat('duration'); - await testSubjects.click('euiFlyoutCloseButton'); await PageObjects.settings.closeIndexPatternFieldEditor(); }); }); diff --git a/test/functional/apps/management/_runtime_fields.js b/test/functional/apps/management/_runtime_fields.js index 105e1a394fecb..9a051bbdef6eb 100644 --- a/test/functional/apps/management/_runtime_fields.js +++ b/test/functional/apps/management/_runtime_fields.js @@ -17,8 +17,7 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['settings']); const testSubjects = getService('testSubjects'); - // Failing: See https://github.com/elastic/kibana/issues/95376 - describe.skip('runtime fields', function () { + describe('runtime fields', function () { this.tags(['skipFirefox']); before(async function () { @@ -44,7 +43,18 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getFieldsTabCount()); await log.debug('add runtime field'); - await PageObjects.settings.addRuntimeField(fieldName, 'Keyword', "emit('hello world')"); + await PageObjects.settings.addRuntimeField( + fieldName, + 'Keyword', + "emit('hello world')", + false + ); + + await log.debug('check that field preview is rendered'); + expect(await testSubjects.exists('fieldPreviewItem', { timeout: 1500 })).to.be(true); + + await PageObjects.settings.clickSaveField(); + await retry.try(async function () { expect(parseInt(await PageObjects.settings.getFieldsTabCount())).to.be(startingCount + 1); }); diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index 7c06344c1a1ad..4c9cb150eca03 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -30,6 +30,7 @@ import { TagCloudPageObject } from './tag_cloud_page'; import { VegaChartPageObject } from './vega_chart_page'; import { SavedObjectsPageObject } from './management/saved_objects_page'; import { LegacyDataTableVisPageObject } from './legacy/data_table_vis'; +import { IndexPatternFieldEditorPageObject } from './management/indexpattern_field_editor_page'; export const pageObjects = { common: CommonPageObject, @@ -56,4 +57,5 @@ export const pageObjects = { tagCloud: TagCloudPageObject, vegaChart: VegaChartPageObject, savedObjects: SavedObjectsPageObject, + indexPatternFieldEditorObjects: IndexPatternFieldEditorPageObject, }; diff --git a/test/functional/page_objects/management/indexpattern_field_editor_page.ts b/test/functional/page_objects/management/indexpattern_field_editor_page.ts new file mode 100644 index 0000000000000..6e122b1da5da2 --- /dev/null +++ b/test/functional/page_objects/management/indexpattern_field_editor_page.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrService } from '../../ftr_provider_context'; + +export class IndexPatternFieldEditorPageObject extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + + public async isFieldPreviewVisible() { + this.log.debug('isFieldPreviewVisible'); + return await this.testSubjects.exists('fieldPreviewItem', { timeout: 1500 }); + } +} diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 5c51a8e76dcad..2645148467d58 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -217,7 +217,9 @@ export class SettingsPageObject extends FtrService { async getFieldsTabCount() { return this.retry.try(async () => { + // We extract the text from the tab (something like "Fields (86)") const text = await this.testSubjects.getVisibleText('tab-indexedFields'); + // And we return the number inside the parenthesis "86" return text.split(' ')[1].replace(/\((.*)\)/, '$1'); }); } @@ -543,15 +545,16 @@ export class SettingsPageObject extends FtrService { await this.clickSaveScriptedField(); } - async addRuntimeField(name: string, type: string, script: string) { + async addRuntimeField(name: string, type: string, script: string, doSaveField = true) { await this.clickAddField(); await this.setFieldName(name); await this.setFieldType(type); if (script) { await this.setFieldScript(script); } - await this.clickSaveField(); - await this.closeIndexPatternFieldEditor(); + if (doSaveField) { + await this.clickSaveField(); + } } public async confirmSave() { @@ -565,8 +568,16 @@ export class SettingsPageObject extends FtrService { } async closeIndexPatternFieldEditor() { + await this.testSubjects.click('closeFlyoutButton'); + + // We might have unsaved changes and we need to confirm inside the modal + if (await this.testSubjects.exists('runtimeFieldModifiedFieldConfirmModal')) { + this.log.debug('Unsaved changes for the field: need to confirm'); + await this.testSubjects.click('confirmModalConfirmButton'); + } + await this.retry.waitFor('field editor flyout to close', async () => { - return !(await this.testSubjects.exists('euiFlyoutCloseButton')); + return !(await this.testSubjects.exists('fieldEditor')); }); } @@ -768,10 +779,6 @@ export class SettingsPageObject extends FtrService { await this.testSubjects.click('editFieldFormat'); } - async clickCloseEditFieldFormatFlyout() { - await this.testSubjects.click('euiFlyoutCloseButton'); - } - async associateIndexPattern(oldIndexPatternId: string, newIndexPatternTitle: string) { await this.find.clickByCssSelector( `select[data-test-subj="managementChangeIndexSelection-${oldIndexPatternId}"] > diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index 15a4f6d992da4..98304808224c7 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -45,6 +45,8 @@ export const useFormFieldMock = (options?: Partial>): FieldHook type: 'type', value: ('mockedValue' as unknown) as T, isPristine: false, + isDirty: false, + isModified: false, isValidating: false, isValidated: false, isChangingValue: false, diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index d0755d05bdb5f..337234bb752f5 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -91,6 +91,8 @@ export const useFormFieldMock = (options?: Partial>): FieldHook type: 'type', value: ('mockedValue' as unknown) as T, isPristine: false, + isDirty: false, + isModified: false, isValidating: false, isValidated: false, isChangingValue: false, diff --git a/x-pack/plugins/transform/common/api_schemas/common.ts b/x-pack/plugins/transform/common/api_schemas/common.ts index b84dcd2a4b749..5354598dc2475 100644 --- a/x-pack/plugins/transform/common/api_schemas/common.ts +++ b/x-pack/plugins/transform/common/api_schemas/common.ts @@ -67,6 +67,7 @@ export const runtimeMappingsSchema = schema.maybe( schema.literal('date'), schema.literal('ip'), schema.literal('boolean'), + schema.literal('geo_point'), ]), script: schema.maybe( schema.oneOf([ diff --git a/x-pack/plugins/transform/common/shared_imports.ts b/x-pack/plugins/transform/common/shared_imports.ts index 38cfb6bc457f1..42e77938d9cec 100644 --- a/x-pack/plugins/transform/common/shared_imports.ts +++ b/x-pack/plugins/transform/common/shared_imports.ts @@ -12,3 +12,5 @@ export { patternValidator, ChartData, } from '../../ml/common'; + +export { RUNTIME_FIELD_TYPES } from '../../../../src/plugins/data/common'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts index 8b3b33fdde3ef..6e5a34fa6ef2b 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts @@ -24,7 +24,7 @@ import { } from '../../../../../../../common/types/transform'; import { LatestFunctionConfig } from '../../../../../../../common/api_schemas/transforms'; -import { isPopulatedObject } from '../../../../../../../common/shared_imports'; +import { isPopulatedObject, RUNTIME_FIELD_TYPES } from '../../../../../../../common/shared_imports'; export interface ErrorMessage { query: string; @@ -36,8 +36,6 @@ export interface Field { type: KBN_FIELD_TYPES; } -// Replace this with import once #88995 is merged -const RUNTIME_FIELD_TYPES = ['keyword', 'long', 'double', 'date', 'ip', 'boolean'] as const; type RuntimeType = typeof RUNTIME_FIELD_TYPES[number]; export interface RuntimeField { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9d880276312f7..7bf0607e27bd5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2810,7 +2810,6 @@ "indexPatternFieldEditor.duration.showSuffixLabel": "接尾辞を表示", "indexPatternFieldEditor.duration.showSuffixLabel.short": "短縮サフィックスを使用", "indexPatternFieldEditor.durationErrorMessage": "小数部分の桁数は0から20までの間で指定する必要があります", - "indexPatternFieldEditor.editor.flyoutCloseButtonLabel": "閉じる", "indexPatternFieldEditor.editor.flyoutDefaultTitle": "フィールドを作成", "indexPatternFieldEditor.editor.flyoutEditFieldSubtitle": "インデックスパターン:{patternName}", "indexPatternFieldEditor.editor.flyoutEditFieldTitle": "「{fieldName}」フィールドの編集", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index addbe289ee990..fb662890571a5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2822,7 +2822,6 @@ "indexPatternFieldEditor.duration.showSuffixLabel": "显示后缀", "indexPatternFieldEditor.duration.showSuffixLabel.short": "使用短后缀", "indexPatternFieldEditor.durationErrorMessage": "小数位数必须介于 0 和 20 之间", - "indexPatternFieldEditor.editor.flyoutCloseButtonLabel": "关闭", "indexPatternFieldEditor.editor.flyoutDefaultTitle": "创建字段", "indexPatternFieldEditor.editor.flyoutEditFieldSubtitle": "索引模式:{patternName}", "indexPatternFieldEditor.editor.flyoutEditFieldTitle": "编辑字段“{fieldName}”", From 1311fe38ae7c5631b55cc1aacac7d2d274b734b3 Mon Sep 17 00:00:00 2001 From: Ross Bell Date: Fri, 13 Aug 2021 17:41:17 -0500 Subject: [PATCH 26/56] Add Workplace Search sync controls UI (#108558) * Wip * Things are more broken, but closer to the end goal * Get patch request working * Update event type * Other two toggles * Force sync button * Remove force sync button for now * Disable the checkbox when globally disabled and introduce click to save * Wip tests * One test down * Test for skipping name alert * Linter * Fix undefined check * Prettier * Apply suggestions from code review Co-authored-by: Scotty Bollinger * Refactor some structures into interfaces * UI tweaks Co-authored-by: Scotty Bollinger --- .../__mocks__/content_sources.mock.ts | 26 +++++- .../applications/workplace_search/types.ts | 15 +++ .../components/source_settings.test.tsx | 78 ++++++++++++++++ .../components/source_settings.tsx | 93 ++++++++++++++++++- .../views/content_sources/constants.ts | 42 +++++++++ .../content_sources/source_logic.test.ts | 15 +++ .../views/content_sources/source_logic.ts | 24 ++++- .../server/routes/workplace_search/sources.ts | 37 ++++++-- 8 files changed, 314 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts index c599a13cc3119..5f515fc99769c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts @@ -28,7 +28,7 @@ export const contentSources = [ }, { id: '124', - serviceType: 'jira', + serviceType: 'jira_cloud', searchable: true, supportedByLicense: true, status: 'synced', @@ -43,6 +43,24 @@ export const contentSources = [ }, ]; +const defaultIndexing = { + enabled: true, + defaultAction: 'include', + rules: [], + schedule: { + intervals: [], + blocked: [], + }, + features: { + contentExtraction: { + enabled: true, + }, + thumbnails: { + enabled: true, + }, + }, +}; + export const fullContentSources = [ { ...contentSources[0], @@ -66,8 +84,11 @@ export const fullContentSources = [ type: 'summary', }, ], + indexing: defaultIndexing, groups, custom: false, + isIndexedSource: true, + areThumbnailsConfigEnabled: true, accessToken: '123token', urlField: 'myLink', titleField: 'heading', @@ -85,7 +106,10 @@ export const fullContentSources = [ details: [], summary: [], groups: [], + indexing: defaultIndexing, custom: true, + isIndexedSource: true, + areThumbnailsConfigEnabled: true, accessToken: '123token', urlField: 'url', titleField: 'title', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index e50b12f781947..8f9528d52195e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -129,12 +129,27 @@ interface SourceActivity { status: string; } +interface IndexingConfig { + enabled: boolean; + features: { + contentExtraction: { + enabled: boolean; + }; + thumbnails: { + enabled: boolean; + }; + }; +} + export interface ContentSourceFullData extends ContentSourceDetails { activities: SourceActivity[]; details: DescriptionList[]; summary: DocumentSummaryItem[]; groups: Group[]; + indexing: IndexingConfig; custom: boolean; + isIndexedSource: boolean; + areThumbnailsConfigEnabled: boolean; accessToken: string; urlField: string; titleField: string; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx index da4346d54727c..0276e75e4d219 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx @@ -105,6 +105,84 @@ describe('SourceSettings', () => { ); }); + it('handles disabling synchronization', () => { + const wrapper = shallow(); + + const synchronizeSwitch = wrapper.find('[data-test-subj="SynchronizeToggle"]').first(); + const event = { target: { checked: false } }; + synchronizeSwitch.prop('onChange')?.(event as any); + + wrapper.find('[data-test-subj="SaveSyncControlsButton"]').simulate('click'); + + expect(updateContentSource).toHaveBeenCalledWith(fullContentSources[0].id, { + indexing: { + enabled: false, + features: { + content_extraction: { enabled: true }, + thumbnails: { enabled: true }, + }, + }, + }); + }); + + it('handles disabling thumbnails', () => { + const wrapper = shallow(); + + const thumbnailsSwitch = wrapper.find('[data-test-subj="ThumbnailsToggle"]').first(); + const event = { target: { checked: false } }; + thumbnailsSwitch.prop('onChange')?.(event as any); + + wrapper.find('[data-test-subj="SaveSyncControlsButton"]').simulate('click'); + + expect(updateContentSource).toHaveBeenCalledWith(fullContentSources[0].id, { + indexing: { + enabled: true, + features: { + content_extraction: { enabled: true }, + thumbnails: { enabled: false }, + }, + }, + }); + }); + + it('handles disabling content extraction', () => { + const wrapper = shallow(); + + const contentExtractionSwitch = wrapper + .find('[data-test-subj="ContentExtractionToggle"]') + .first(); + const event = { target: { checked: false } }; + contentExtractionSwitch.prop('onChange')?.(event as any); + + wrapper.find('[data-test-subj="SaveSyncControlsButton"]').simulate('click'); + + expect(updateContentSource).toHaveBeenCalledWith(fullContentSources[0].id, { + indexing: { + enabled: true, + features: { + content_extraction: { enabled: false }, + thumbnails: { enabled: true }, + }, + }, + }); + }); + + it('disables the thumbnails switch when globally disabled', () => { + setMockValues({ + ...mockValues, + contentSource: { + ...fullContentSources[0], + areThumbnailsConfigEnabled: false, + }, + }); + + const wrapper = shallow(); + + const synchronizeSwitch = wrapper.find('[data-test-subj="ThumbnailsToggle"]'); + + expect(synchronizeSwitch.prop('disabled')).toEqual(true); + }); + describe('DownloadDiagnosticsButton', () => { it('renders for org with correct href', () => { const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index e4f52d94ad9e7..d6f16db4d5129 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -17,6 +17,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, + EuiSpacer, + EuiSwitch, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -50,6 +52,12 @@ import { SYNC_DIAGNOSTICS_TITLE, SYNC_DIAGNOSTICS_DESCRIPTION, SYNC_DIAGNOSTICS_BUTTON, + SYNC_MANAGEMENT_TITLE, + SYNC_MANAGEMENT_DESCRIPTION, + SYNC_MANAGEMENT_SYNCHRONIZE_LABEL, + SYNC_MANAGEMENT_THUMBNAILS_LABEL, + SYNC_MANAGEMENT_THUMBNAILS_GLOBAL_CONFIG_LABEL, + SYNC_MANAGEMENT_CONTENT_EXTRACTION_LABEL, } from '../constants'; import { staticSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; @@ -63,7 +71,21 @@ export const SourceSettings: React.FC = () => { const { getSourceConfigData } = useActions(AddSourceLogic); const { - contentSource: { name, id, serviceType }, + contentSource: { + name, + id, + serviceType, + custom: isCustom, + isIndexedSource, + areThumbnailsConfigEnabled, + indexing: { + enabled, + features: { + contentExtraction: { enabled: contentExtractionEnabled }, + thumbnails: { enabled: thumbnailsEnabled }, + }, + }, + }, buttonLoading, } = useValues(SourceLogic); @@ -88,6 +110,11 @@ export const SourceSettings: React.FC = () => { const hideConfirm = () => setModalVisibility(false); const showConfig = isOrganization && !isEmpty(configuredFields); + const showSyncControls = isOrganization && isIndexedSource && !isCustom; + + const [synchronizeChecked, setSynchronize] = useState(enabled); + const [thumbnailsChecked, setThumbnails] = useState(thumbnailsEnabled); + const [contentExtractionChecked, setContentExtraction] = useState(contentExtractionEnabled); const { clientId, clientSecret, publicKey, consumerKey, baseUrl } = configuredFields || {}; @@ -102,6 +129,18 @@ export const SourceSettings: React.FC = () => { updateContentSource(id, { name: inputValue }); }; + const submitSyncControls = () => { + updateContentSource(id, { + indexing: { + enabled: synchronizeChecked, + features: { + content_extraction: { enabled: contentExtractionChecked }, + thumbnails: { enabled: thumbnailsChecked }, + }, + }, + }); + }; + const handleSourceRemoval = () => { /** * The modal was just hanging while the UI waited for the server to respond. @@ -180,6 +219,58 @@ export const SourceSettings: React.FC = () => { )} + {showSyncControls && ( + + + + setSynchronize(e.target.checked)} + label={SYNC_MANAGEMENT_SYNCHRONIZE_LABEL} + data-test-subj="SynchronizeToggle" + /> + + + + + + setThumbnails(e.target.checked)} + label={ + areThumbnailsConfigEnabled + ? SYNC_MANAGEMENT_THUMBNAILS_LABEL + : SYNC_MANAGEMENT_THUMBNAILS_GLOBAL_CONFIG_LABEL + } + disabled={!areThumbnailsConfigEnabled} + data-test-subj="ThumbnailsToggle" + /> + + + + + setContentExtraction(e.target.checked)} + label={SYNC_MANAGEMENT_CONTENT_EXTRACTION_LABEL} + data-test-subj="ContentExtractionToggle" + /> + + + + + + + {SAVE_CHANGES_BUTTON} + + + + + )} { expect(onUpdateSourceNameSpy).toHaveBeenCalledWith(contentSource.name); }); + it('does not call onUpdateSourceName when the name is not supplied', async () => { + AppLogic.values.isOrganization = true; + + const onUpdateSourceNameSpy = jest.spyOn(SourceLogic.actions, 'onUpdateSourceName'); + const promise = Promise.resolve(contentSource); + http.patch.mockReturnValue(promise); + SourceLogic.actions.updateContentSource(contentSource.id, { indexing: { enabled: true } }); + + expect(http.patch).toHaveBeenCalledWith('/api/workplace_search/org/sources/123/settings', { + body: JSON.stringify({ content_source: { indexing: { enabled: true } } }), + }); + await promise; + expect(onUpdateSourceNameSpy).not.toHaveBeenCalledWith(contentSource.name); + }); + it('calls API and sets values (account)', async () => { AppLogic.values.isOrganization = false; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts index 0fd44e01ae495..4d145bf798160 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts @@ -34,8 +34,11 @@ export interface SourceActions { searchContentSourceDocuments(sourceId: string): { sourceId: string }; updateContentSource( sourceId: string, - source: { name: string } - ): { sourceId: string; source: { name: string } }; + source: SourceUpdatePayload + ): { + sourceId: string; + source: ContentSourceFullData; + }; resetSourceState(): void; removeContentSource(sourceId: string): { sourceId: string }; initializeSource(sourceId: string): { sourceId: string }; @@ -57,6 +60,17 @@ interface SearchResultsResponse { meta: Meta; } +interface SourceUpdatePayload { + name?: string; + indexing?: { + enabled?: boolean; + features?: { + thumbnails?: { enabled: boolean }; + content_extraction?: { enabled: boolean }; + }; + }; +} + export const SourceLogic = kea>({ path: ['enterprise_search', 'workplace_search', 'source_logic'], actions: { @@ -69,7 +83,7 @@ export const SourceLogic = kea>({ initializeSource: (sourceId: string) => ({ sourceId }), initializeFederatedSummary: (sourceId: string) => ({ sourceId }), searchContentSourceDocuments: (sourceId: string) => ({ sourceId }), - updateContentSource: (sourceId: string, source: { name: string }) => ({ sourceId, source }), + updateContentSource: (sourceId: string, source: SourceUpdatePayload) => ({ sourceId, source }), removeContentSource: (sourceId: string) => ({ sourceId, }), @@ -209,7 +223,9 @@ export const SourceLogic = kea>({ const response = await HttpLogic.values.http.patch(route, { body: JSON.stringify({ content_source: source }), }); - actions.onUpdateSourceName(response.name); + if (source.name) { + actions.onUpdateSourceName(response.name); + } } catch (e) { flashAPIErrors(e); } diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index 835ad84ef6853..cb56f54a1df6a 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -57,6 +57,31 @@ const displaySettingsSchema = schema.object({ detailFields: schema.oneOf([schema.arrayOf(displayFieldSchema), displayFieldSchema]), }); +const sourceSettingsSchema = schema.object({ + content_source: schema.object({ + name: schema.maybe(schema.string()), + indexing: schema.maybe( + schema.object({ + enabled: schema.maybe(schema.boolean()), + features: schema.maybe( + schema.object({ + thumbnails: schema.maybe( + schema.object({ + enabled: schema.boolean(), + }) + ), + content_extraction: schema.maybe( + schema.object({ + enabled: schema.boolean(), + }) + ), + }) + ), + }) + ), + }), +}); + // Account routes export function registerAccountSourcesRoute({ router, @@ -217,11 +242,7 @@ export function registerAccountSourceSettingsRoute({ { path: '/api/workplace_search/account/sources/{id}/settings', validate: { - body: schema.object({ - content_source: schema.object({ - name: schema.string(), - }), - }), + body: sourceSettingsSchema, params: schema.object({ id: schema.string(), }), @@ -565,11 +586,7 @@ export function registerOrgSourceSettingsRoute({ { path: '/api/workplace_search/org/sources/{id}/settings', validate: { - body: schema.object({ - content_source: schema.object({ - name: schema.string(), - }), - }), + body: sourceSettingsSchema, params: schema.object({ id: schema.string(), }), From 5ef1f95711377434d14688508d47b4f005b47348 Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Fri, 13 Aug 2021 15:51:23 -0700 Subject: [PATCH 27/56] Add signal.original_event.reason to signal_extra_fields for insertion into old indices (#108594) --- .../routes/index/signal_extra_fields.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json index 7bc20fd540b9b..32c084f927d7b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json @@ -43,6 +43,14 @@ } } }, + "original_event": { + "type": "object", + "properties": { + "reason": { + "type": "keyword" + } + } + }, "reason": { "type": "keyword" }, From bfea4a1c2bab0df1ff56df2f48ddfeda4b15e408 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Fri, 13 Aug 2021 16:49:55 -0700 Subject: [PATCH 28/56] Add EuiCodeEditor to ES UI Shared. (#108318) * Export EuiCodeEditor from es_ui_shared and consume it in Grok Debugger. Remove warning from EuiCodeEditor. * Lazy-load code editor so it doesn't bloat the EsUiShared plugin bundle. * Refactor mocks into a shared jest_mock.tsx file. --- package.json | 2 +- .../__snapshots__/code_editor.test.tsx.snap | 627 ++++++++++++++++++ .../components/code_editor/_code_editor.scss | 38 ++ .../public/components/code_editor/_index.scss | 1 + .../code_editor/code_editor.test.tsx | 117 ++++ .../components/code_editor/code_editor.tsx | 308 +++++++++ .../public/components/code_editor/index.tsx | 32 + .../components/code_editor/jest_mock.tsx | 32 + .../components/json_editor/json_editor.tsx | 3 +- src/plugins/es_ui_shared/public/index.ts | 1 + x-pack/plugins/grokdebugger/kibana.json | 3 +- .../custom_patterns_input.js | 13 +- .../components/event_input/event_input.js | 6 +- .../components/event_output/event_output.js | 4 +- .../components/pattern_input/pattern_input.js | 6 +- .../grokdebugger/public/shared_imports.ts | 8 + .../component_template_create.test.tsx | 5 +- .../helpers/mappings_editor.helpers.tsx | 2 +- .../helpers/setup_environment.tsx | 12 +- .../load_mappings_provider.test.tsx | 18 +- .../__jest__/test_pipeline.helpers.tsx | 22 +- .../load_from_json/modal_provider.test.tsx | 18 +- yarn.lock | 14 +- 23 files changed, 1199 insertions(+), 93 deletions(-) create mode 100644 src/plugins/es_ui_shared/public/components/code_editor/__snapshots__/code_editor.test.tsx.snap create mode 100644 src/plugins/es_ui_shared/public/components/code_editor/_code_editor.scss create mode 100644 src/plugins/es_ui_shared/public/components/code_editor/_index.scss create mode 100644 src/plugins/es_ui_shared/public/components/code_editor/code_editor.test.tsx create mode 100644 src/plugins/es_ui_shared/public/components/code_editor/code_editor.tsx create mode 100644 src/plugins/es_ui_shared/public/components/code_editor/index.tsx create mode 100644 src/plugins/es_ui_shared/public/components/code_editor/jest_mock.tsx create mode 100644 x-pack/plugins/grokdebugger/public/shared_imports.ts diff --git a/package.json b/package.json index 17beadebca91c..db8f9fac9238f 100644 --- a/package.json +++ b/package.json @@ -336,7 +336,7 @@ "re-resizable": "^6.1.1", "re2": "^1.15.4", "react": "^16.12.0", - "react-ace": "^5.9.0", + "react-ace": "^7.0.5", "react-beautiful-dnd": "^13.0.0", "react-color": "^2.13.8", "react-dom": "^16.12.0", diff --git a/src/plugins/es_ui_shared/public/components/code_editor/__snapshots__/code_editor.test.tsx.snap b/src/plugins/es_ui_shared/public/components/code_editor/__snapshots__/code_editor.test.tsx.snap new file mode 100644 index 0000000000000..aeab9a66c7694 --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/code_editor/__snapshots__/code_editor.test.tsx.snap @@ -0,0 +1,627 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiCodeEditor behavior hint element should be disabled when the ui ace box gains focus 1`] = ` + +`; + +exports[`EuiCodeEditor behavior hint element should be enabled when the ui ace box loses focus 1`] = ` + +`; + +exports[`EuiCodeEditor behavior hint element should be tabable 1`] = ` + +`; + +exports[`EuiCodeEditor is rendered 1`] = ` +
+ +
+