From 641a5ad77f5ab727cf61b4fdd97a275f4645e8ac Mon Sep 17 00:00:00 2001 From: Constance Date: Fri, 28 Aug 2020 08:38:23 -0700 Subject: [PATCH 01/72] [Enterprise Search] Request handler refactors/enhancements + update existing routes to use shared handler (#76106) * Refactor enterprise_search_request_handler to a class that stores config/log - So that routes don't have to continuously pass it in - just params that actually change (paths, queries) + misc refactors/lint fixes from code review * Update mocks/dependencies so other tests pass type checks * Update /api/workplace_search/overview endpoint to use new request handler * Update /api/app_search/engines route to use new handler - This required updating the shared handler to accept custom params set by the route, since the engines route transmutes some param names - createRequest mock also had to be updated to correctly reflect a curried function in order to get tests passing * DRY out hasValidData to a reusable/cleaner call * Update shared handler to output specific message for auth errors - Check for /login URL (indicates auth issue) - Refactor final catch return to allow being passed messages from previous throw - Change hasValidData fallback to only log (potentially sensitive?) JSON data to server, not to client - Minor test cleanup - move error tests to single describe block, minor URL/newline cleanup * Update handler to pass method & body requests + pass back response code - This will support future POST requests as well as support (e.g.) 404 responses Minor test refactors: - Set up new request defaults (method, empty body) + expected output & convert KibanaAuthHeader to string since they're set so close to one another - group request pass tests into a describe block, remove response body portion of their tests (since we don't really care about that for those tests) - group response tests into describe block - clarify how passed handler params arg overrides request.params * PR feedback: Update custom params arg to take an object instead of a query string --- .../server/__mocks__/index.ts | 7 +- .../__mocks__/routerDependencies.mock.ts | 8 + .../enterprise_search_request_handler.test.ts | 153 +++++++++++++----- .../lib/enterprise_search_request_handler.ts | 103 ++++++++---- .../enterprise_search/server/plugin.ts | 12 +- .../routes/app_search/credentials.test.ts | 27 +--- .../server/routes/app_search/credentials.ts | 10 +- .../server/routes/app_search/engines.test.ts | 121 ++++---------- .../server/routes/app_search/engines.ts | 47 ++---- .../enterprise_search/telemetry.test.ts | 4 +- .../routes/workplace_search/overview.test.ts | 120 +++----------- .../routes/workplace_search/overview.ts | 39 +---- 12 files changed, 295 insertions(+), 356 deletions(-) diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/index.ts b/x-pack/plugins/enterprise_search/server/__mocks__/index.ts index 3cca5e21ce9c3..69f37856b6f2d 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/index.ts @@ -5,4 +5,9 @@ */ export { MockRouter } from './router.mock'; -export { mockConfig, mockLogger, mockDependencies } from './routerDependencies.mock'; +export { + mockConfig, + mockLogger, + mockRequestHandler, + mockDependencies, +} from './routerDependencies.mock'; diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts index 7a244be96cfc4..b08a14b540e55 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts @@ -9,6 +9,13 @@ import { ConfigType } from '../'; export const mockLogger = loggingSystemMock.createLogger().get(); +export const mockRequestHandler = { + createRequest: jest.fn(() => () => {}), + hasValidData(data: any) { + return (this.createRequest as jest.Mock).mock.calls[0][0].hasValidData(data); + }, +}; + export const mockConfig = { enabled: true, host: 'http://localhost:3002', @@ -24,4 +31,5 @@ export const mockDependencies = { // Mock router should be handled on a per-test basis config: mockConfig, log: mockLogger, + enterpriseSearchRequestHandler: mockRequestHandler as any, }; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts index f0c003936996e..3f3f182433144 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts @@ -6,7 +6,7 @@ import { mockConfig, mockLogger } from '../__mocks__'; -import { createEnterpriseSearchRequestHandler } from './enterprise_search_request_handler'; +import { EnterpriseSearchRequestHandler } from './enterprise_search_request_handler'; jest.mock('node-fetch'); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -14,12 +14,16 @@ const fetchMock = require('node-fetch') as jest.Mock; const { Response } = jest.requireActual('node-fetch'); const responseMock = { - ok: jest.fn(), + custom: jest.fn(), customError: jest.fn(), }; -const KibanaAuthHeader = 'Basic 123'; -describe('createEnterpriseSearchRequestHandler', () => { +describe('EnterpriseSearchRequestHandler', () => { + const enterpriseSearchRequestHandler = new EnterpriseSearchRequestHandler({ + config: mockConfig, + log: mockLogger, + }) as any; + beforeEach(() => { jest.clearAllMocks(); fetchMock.mockReset(); @@ -33,9 +37,7 @@ describe('createEnterpriseSearchRequestHandler', () => { EnterpriseSearchAPI.mockReturn(responseBody); - const requestHandler = createEnterpriseSearchRequestHandler({ - config: mockConfig, - log: mockLogger, + const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/as/credentials/collection', }); @@ -47,82 +49,151 @@ describe('createEnterpriseSearchRequestHandler', () => { }); EnterpriseSearchAPI.shouldHaveBeenCalledWith( - 'http://localhost:3002/as/credentials/collection?type=indexed&pageIndex=1' + 'http://localhost:3002/as/credentials/collection?type=indexed&pageIndex=1', + { method: 'GET' } ); - expect(responseMock.ok).toHaveBeenCalledWith({ + expect(responseMock.custom).toHaveBeenCalledWith({ body: responseBody, + statusCode: 200, }); }); - describe('when an API request fails', () => { - it('should return 502 with a message', async () => { - EnterpriseSearchAPI.mockReturnError(); + describe('request passing', () => { + it('passes route method', async () => { + const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/example' }); - const requestHandler = createEnterpriseSearchRequestHandler({ - config: mockConfig, - log: mockLogger, - path: '/as/credentials/collection', + await makeAPICall(requestHandler, { route: { method: 'POST' } }); + EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/example', { + method: 'POST', }); - await makeAPICall(requestHandler); + await makeAPICall(requestHandler, { route: { method: 'DELETE' } }); + EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/example', { + method: 'DELETE', + }); + }); + + it('passes request body', async () => { + const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/example' }); + await makeAPICall(requestHandler, { body: { bodacious: true } }); + + EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/example', { + body: '{"bodacious":true}', + }); + }); + + it('passes custom params set by the handler, which override request params', async () => { + const requestHandler = enterpriseSearchRequestHandler.createRequest({ + path: '/api/example', + params: { someQuery: true }, + }); + await makeAPICall(requestHandler, { query: { someQuery: false } }); EnterpriseSearchAPI.shouldHaveBeenCalledWith( - 'http://localhost:3002/as/credentials/collection' + 'http://localhost:3002/api/example?someQuery=true' ); + }); + }); - expect(responseMock.customError).toHaveBeenCalledWith({ - body: 'Error connecting or fetching data from Enterprise Search', - statusCode: 502, - }); + describe('response passing', () => { + it('returns the response status code from Enterprise Search', async () => { + EnterpriseSearchAPI.mockReturn({}, { status: 404 }); + + const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/example' }); + await makeAPICall(requestHandler); + + EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/example'); + expect(responseMock.custom).toHaveBeenCalledWith({ body: {}, statusCode: 404 }); }); + + // TODO: It's possible we may also pass back headers at some point + // from Enterprise Search, e.g. the x-read-only mode header }); - describe('when `hasValidData` fails', () => { - it('should return 502 with a message', async () => { - const responseBody = { - foo: 'bar', - }; + describe('error handling', () => { + afterEach(() => { + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Error connecting to Enterprise Search') + ); + }); + + it('returns an error when an API request fails', async () => { + EnterpriseSearchAPI.mockReturnError(); + const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/failed' }); + + await makeAPICall(requestHandler); + EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/failed'); - EnterpriseSearchAPI.mockReturn(responseBody); + expect(responseMock.customError).toHaveBeenCalledWith({ + body: 'Error connecting to Enterprise Search: Failed', + statusCode: 502, + }); + }); - const requestHandler = createEnterpriseSearchRequestHandler({ - config: mockConfig, - log: mockLogger, - path: '/as/credentials/collection', - hasValidData: (body?: any) => - Array.isArray(body?.results) && typeof body?.meta?.page?.total_results === 'number', + it('returns an error when `hasValidData` fails', async () => { + EnterpriseSearchAPI.mockReturn({ results: false }); + const requestHandler = enterpriseSearchRequestHandler.createRequest({ + path: '/api/invalid', + hasValidData: (body?: any) => Array.isArray(body?.results), }); await makeAPICall(requestHandler); + EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/invalid'); - EnterpriseSearchAPI.shouldHaveBeenCalledWith( - 'http://localhost:3002/as/credentials/collection' + expect(responseMock.customError).toHaveBeenCalledWith({ + body: 'Error connecting to Enterprise Search: Invalid data received', + statusCode: 502, + }); + expect(mockLogger.debug).toHaveBeenCalledWith( + 'Invalid data received from : {"results":false}' ); + }); + + it('returns an error when user authentication to Enterprise Search fails', async () => { + EnterpriseSearchAPI.mockReturn({}, { url: 'http://localhost:3002/login' }); + const requestHandler = enterpriseSearchRequestHandler.createRequest({ + path: '/api/unauthenticated', + }); + + await makeAPICall(requestHandler); + EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/unauthenticated'); expect(responseMock.customError).toHaveBeenCalledWith({ - body: 'Error connecting or fetching data from Enterprise Search', + body: 'Error connecting to Enterprise Search: Cannot authenticate Enterprise Search user', statusCode: 502, }); }); }); + + it('has a helper for checking empty objects', async () => { + expect(enterpriseSearchRequestHandler.isEmptyObj({})).toEqual(true); + expect(enterpriseSearchRequestHandler.isEmptyObj({ empty: false })).toEqual(false); + }); }); const makeAPICall = (handler: Function, params = {}) => { - const request = { headers: { authorization: KibanaAuthHeader }, ...params }; + const request = { + headers: { authorization: 'Basic 123' }, + route: { method: 'GET' }, + body: {}, + ...params, + }; return handler(null, request, responseMock); }; const EnterpriseSearchAPI = { shouldHaveBeenCalledWith(expectedUrl: string, expectedParams = {}) { expect(fetchMock).toHaveBeenCalledWith(expectedUrl, { - headers: { Authorization: KibanaAuthHeader }, + headers: { Authorization: 'Basic 123' }, + method: 'GET', + body: undefined, ...expectedParams, }); }, - mockReturn(response: object) { + mockReturn(response: object, options?: object) { fetchMock.mockImplementation(() => { - return Promise.resolve(new Response(JSON.stringify(response))); + return Promise.resolve(new Response(JSON.stringify(response), options)); }); }, mockReturnError() { diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts index 11152aa651743..8f31bd9063d4a 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts @@ -7,6 +7,7 @@ import fetch from 'node-fetch'; import querystring from 'querystring'; import { + RequestHandler, RequestHandlerContext, KibanaRequest, KibanaResponseFactory, @@ -14,56 +15,90 @@ import { } from 'src/core/server'; import { ConfigType } from '../index'; -interface IEnterpriseSearchRequestParams { +interface IConstructorDependencies { config: ConfigType; log: Logger; +} +interface IRequestParams { path: string; + params?: object; hasValidData?: (body?: ResponseBody) => boolean; } +export interface IEnterpriseSearchRequestHandler { + createRequest(requestParams?: object): RequestHandler, unknown>; +} /** - * This helper function creates a single standard DRY way of handling + * This helper lib creates a single standard DRY way of handling * Enterprise Search API requests. * * This handler assumes that it will essentially just proxy the * Enterprise Search API request, so the request body and request * parameters are simply passed through. */ -export function createEnterpriseSearchRequestHandler({ - config, - log, - path, - hasValidData = () => true, -}: IEnterpriseSearchRequestParams) { - return async ( - _context: RequestHandlerContext, - request: KibanaRequest, unknown>, - response: KibanaResponseFactory - ) => { - try { - const enterpriseSearchUrl = config.host as string; - const params = request.query ? `?${querystring.stringify(request.query)}` : ''; - const url = `${encodeURI(enterpriseSearchUrl)}${path}${params}`; +export class EnterpriseSearchRequestHandler { + private enterpriseSearchUrl: string; + private log: Logger; + + constructor({ config, log }: IConstructorDependencies) { + this.log = log; + this.enterpriseSearchUrl = config.host as string; + } + + createRequest({ + path, + params = {}, + hasValidData = () => true, + }: IRequestParams) { + return async ( + _context: RequestHandlerContext, + request: KibanaRequest, unknown>, + response: KibanaResponseFactory + ) => { + try { + // Set up API URL + const queryParams = { ...request.query, ...params }; + const queryString = !this.isEmptyObj(queryParams) + ? `?${querystring.stringify(queryParams)}` + : ''; + const url = encodeURI(this.enterpriseSearchUrl + path + queryString); + + // Set up API options + const { method } = request.route; + const headers = { Authorization: request.headers.authorization as string }; + const body = !this.isEmptyObj(request.body as object) + ? JSON.stringify(request.body) + : undefined; + + // Call the Enterprise Search API and pass back response to the front-end + const apiResponse = await fetch(url, { method, headers, body }); + + if (apiResponse.url.endsWith('/login')) { + throw new Error('Cannot authenticate Enterprise Search user'); + } + + const { status } = apiResponse; + const json = await apiResponse.json(); - const apiResponse = await fetch(url, { - headers: { Authorization: request.headers.authorization as string }, - }); + if (hasValidData(json)) { + return response.custom({ statusCode: status, body: json }); + } else { + this.log.debug(`Invalid data received from <${url}>: ${JSON.stringify(json)}`); + throw new Error('Invalid data received'); + } + } catch (e) { + const errorMessage = `Error connecting to Enterprise Search: ${e?.message || e.toString()}`; - const body = await apiResponse.json(); + this.log.error(errorMessage); + if (e instanceof Error) this.log.debug(e.stack as string); - if (hasValidData(body)) { - return response.ok({ body }); - } else { - throw new Error(`Invalid data received: ${JSON.stringify(body)}`); + return response.customError({ statusCode: 502, body: errorMessage }); } - } catch (e) { - log.error(`Cannot connect to Enterprise Search: ${e.toString()}`); - if (e instanceof Error) log.debug(e.stack as string); + }; + } - return response.customError({ - statusCode: 502, - body: 'Error connecting or fetching data from Enterprise Search', - }); - } - }; + // Small helper + isEmptyObj(obj: object) { + return Object.keys(obj).length === 0; + } } diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index ef8c72f0cbca5..617210a544262 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -26,6 +26,11 @@ import { } from '../common/constants'; import { ConfigType } from './'; import { checkAccess } from './lib/check_access'; +import { + EnterpriseSearchRequestHandler, + IEnterpriseSearchRequestHandler, +} from './lib/enterprise_search_request_handler'; + import { registerConfigDataRoute } from './routes/enterprise_search/config_data'; import { registerTelemetryRoute } from './routes/enterprise_search/telemetry'; @@ -48,6 +53,7 @@ export interface IRouteDependencies { router: IRouter; config: ConfigType; log: Logger; + enterpriseSearchRequestHandler: IEnterpriseSearchRequestHandler; getSavedObjectsService?(): SavedObjectsServiceStart; } @@ -65,6 +71,7 @@ export class EnterpriseSearchPlugin implements Plugin { { usageCollection, security, features }: PluginsSetup ) { const config = await this.config.pipe(first()).toPromise(); + const log = this.logger; /** * Register space/feature control @@ -84,7 +91,7 @@ export class EnterpriseSearchPlugin implements Plugin { * Register user access to the Enterprise Search plugins */ capabilities.registerSwitcher(async (request: KibanaRequest) => { - const dependencies = { config, security, request, log: this.logger }; + const dependencies = { config, security, request, log }; const { hasAppSearchAccess, hasWorkplaceSearchAccess } = await checkAccess(dependencies); @@ -105,7 +112,8 @@ export class EnterpriseSearchPlugin implements Plugin { * Register routes */ const router = http.createRouter(); - const dependencies = { router, config, log: this.logger }; + const enterpriseSearchRequestHandler = new EnterpriseSearchRequestHandler({ config, log }); + const dependencies = { router, config, log, enterpriseSearchRequestHandler }; registerConfigDataRoute(dependencies); registerEnginesRoute(dependencies); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts index 682c17aea6d52..000e6d63b5999 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts @@ -4,15 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MockRouter, mockConfig, mockLogger } from '../../__mocks__'; +import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; import { registerCredentialsRoutes } from './credentials'; -jest.mock('../../lib/enterprise_search_request_handler', () => ({ - createEnterpriseSearchRequestHandler: jest.fn(), -})); -import { createEnterpriseSearchRequestHandler } from '../../lib/enterprise_search_request_handler'; - describe('credentials routes', () => { describe('GET /api/app_search/credentials', () => { let mockRouter: MockRouter; @@ -22,16 +17,13 @@ describe('credentials routes', () => { mockRouter = new MockRouter({ method: 'get', payload: 'query' }); registerCredentialsRoutes({ + ...mockDependencies, router: mockRouter.router, - log: mockLogger, - config: mockConfig, }); }); - it('creates a handler with createEnterpriseSearchRequestHandler', () => { - expect(createEnterpriseSearchRequestHandler).toHaveBeenCalledWith({ - config: mockConfig, - log: mockLogger, + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ path: '/as/credentials/collection', hasValidData: expect.any(Function), }); @@ -59,11 +51,7 @@ describe('credentials routes', () => { ], }; - const { - hasValidData, - } = (createEnterpriseSearchRequestHandler as jest.Mock).mock.calls[0][0]; - - expect(hasValidData(response)).toBe(true); + expect(mockRequestHandler.hasValidData(response)).toBe(true); }); it('should correctly validate that a response does not have data', () => { @@ -71,10 +59,7 @@ describe('credentials routes', () => { foo: 'bar', }; - const hasValidData = (createEnterpriseSearchRequestHandler as jest.Mock).mock.calls[0][0] - .hasValidData; - - expect(hasValidData(response)).toBe(false); + expect(mockRequestHandler.hasValidData(response)).toBe(false); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.ts index d9539692069f0..432f54c8e5b1c 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.ts @@ -7,7 +7,6 @@ import { schema } from '@kbn/config-schema'; import { IRouteDependencies } from '../../plugin'; -import { createEnterpriseSearchRequestHandler } from '../../lib/enterprise_search_request_handler'; interface ICredential { id: string; @@ -28,7 +27,10 @@ interface ICredentialsResponse { }; } -export function registerCredentialsRoutes({ router, config, log }: IRouteDependencies) { +export function registerCredentialsRoutes({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { router.get( { path: '/api/app_search/credentials', @@ -38,9 +40,7 @@ export function registerCredentialsRoutes({ router, config, log }: IRouteDepende }), }, }, - createEnterpriseSearchRequestHandler({ - config, - log, + enterpriseSearchRequestHandler.createRequest({ path: '/as/credentials/collection', hasValidData: (body?: ICredentialsResponse) => { return Array.isArray(body?.results) && typeof body?.meta?.page?.total_results === 'number'; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index 03edab89d1b99..cd22ff98b01ce 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -4,16 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MockRouter, mockConfig, mockLogger } from '../../__mocks__'; +import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; import { registerEnginesRoute } from './engines'; -jest.mock('node-fetch'); -const fetch = jest.requireActual('node-fetch'); -const { Response } = fetch; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const fetchMock = require('node-fetch') as jest.Mocked; - describe('engine routes', () => { describe('GET /api/app_search/engines', () => { const AUTH_HEADER = 'Basic 123'; @@ -34,71 +28,51 @@ describe('engine routes', () => { mockRouter = new MockRouter({ method: 'get', payload: 'query' }); registerEnginesRoute({ + ...mockDependencies, router: mockRouter.router, - log: mockLogger, - config: mockConfig, }); }); - describe('when the underlying App Search API returns a 200', () => { - beforeEach(() => { - AppSearchAPI.shouldBeCalledWith( - `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, - { headers: { Authorization: AUTH_HEADER } } - ).andReturn({ - results: [{ name: 'engine1' }], - meta: { page: { total_results: 1 } }, - }); - }); - - it('should return 200 with a list of engines from the App Search API', async () => { - await mockRouter.callRoute(mockRequest); + it('creates a request handler', () => { + mockRouter.callRoute(mockRequest); - expect(mockRouter.response.ok).toHaveBeenCalledWith({ - body: { results: [{ name: 'engine1' }], meta: { page: { total_results: 1 } } }, - }); + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/collection', + params: { type: 'indexed', 'page[current]': 1, 'page[size]': 10 }, + hasValidData: expect.any(Function), }); }); - describe('when the App Search URL is invalid', () => { - beforeEach(() => { - AppSearchAPI.shouldBeCalledWith( - `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, - { headers: { Authorization: AUTH_HEADER } } - ).andReturnError(); - }); - - it('should return 502 with a message', async () => { - await mockRouter.callRoute(mockRequest); + it('passes custom parameters to enterpriseSearchRequestHandler', () => { + mockRouter.callRoute({ query: { type: 'meta', pageIndex: 99 } }); - expect(mockRouter.response.customError).toHaveBeenCalledWith({ - statusCode: 502, - body: 'cannot-connect', - }); - expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to App Search: Failed'); - expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/collection', + params: { type: 'meta', 'page[current]': 99, 'page[size]': 10 }, + hasValidData: expect.any(Function), }); }); - describe('when the App Search API returns invalid data', () => { - beforeEach(() => { - AppSearchAPI.shouldBeCalledWith( - `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, - { headers: { Authorization: AUTH_HEADER } } - ).andReturnInvalidData(); + describe('hasValidData', () => { + it('should correctly validate that the response has data', () => { + const response = { + meta: { + page: { + total_results: 1, + }, + }, + results: [], + }; + + mockRouter.callRoute(mockRequest); + expect(mockRequestHandler.hasValidData(response)).toBe(true); }); - it('should return 502 with a message', async () => { - await mockRouter.callRoute(mockRequest); - - expect(mockRouter.response.customError).toHaveBeenCalledWith({ - statusCode: 502, - body: 'cannot-connect', - }); - expect(mockLogger.error).toHaveBeenCalledWith( - 'Cannot connect to App Search: Error: Invalid data received from App Search: {"foo":"bar"}' - ); - expect(mockLogger.debug).toHaveBeenCalled(); + it('should correctly validate that a response does not have data', () => { + const response = {}; + + mockRouter.callRoute(mockRequest); + expect(mockRequestHandler.hasValidData(response)).toBe(false); }); }); @@ -128,36 +102,5 @@ describe('engine routes', () => { mockRouter.shouldThrow(request); }); }); - - const AppSearchAPI = { - shouldBeCalledWith(expectedUrl: string, expectedParams: object) { - return { - andReturn(response: object) { - fetchMock.mockImplementation((url: string, params: object) => { - expect(url).toEqual(expectedUrl); - expect(params).toEqual(expectedParams); - - return Promise.resolve(new Response(JSON.stringify(response))); - }); - }, - andReturnInvalidData() { - fetchMock.mockImplementation((url: string, params: object) => { - expect(url).toEqual(expectedUrl); - expect(params).toEqual(expectedParams); - - return Promise.resolve(new Response(JSON.stringify({ foo: 'bar' }))); - }); - }, - andReturnError() { - fetchMock.mockImplementation((url: string, params: object) => { - expect(url).toEqual(expectedUrl); - expect(params).toEqual(expectedParams); - - return Promise.reject('Failed'); - }); - }, - }; - }, - }; }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index 7190772fb92bb..49fc5b100e0d1 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -4,14 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import fetch from 'node-fetch'; -import querystring from 'querystring'; import { schema } from '@kbn/config-schema'; import { IRouteDependencies } from '../../plugin'; import { ENGINES_PAGE_SIZE } from '../../../common/constants'; -export function registerEnginesRoute({ router, config, log }: IRouteDependencies) { +interface IEnginesResponse { + results: object[]; + meta: { page: { total_results: number } }; +} + +export function registerEnginesRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { router.get( { path: '/api/app_search/engines', @@ -23,37 +29,18 @@ export function registerEnginesRoute({ router, config, log }: IRouteDependencies }, }, async (context, request, response) => { - try { - const enterpriseSearchUrl = config.host as string; - const { type, pageIndex } = request.query; + const { type, pageIndex } = request.query; - const params = querystring.stringify({ + return enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/collection', + params: { type, 'page[current]': pageIndex, 'page[size]': ENGINES_PAGE_SIZE, - }); - const url = `${encodeURI(enterpriseSearchUrl)}/as/engines/collection?${params}`; - - const enginesResponse = await fetch(url, { - headers: { Authorization: request.headers.authorization as string }, - }); - - const engines = await enginesResponse.json(); - const hasValidData = - Array.isArray(engines?.results) && typeof engines?.meta?.page?.total_results === 'number'; - - if (hasValidData) { - return response.ok({ body: engines }); - } else { - // Either a completely incorrect Enterprise Search host URL was configured, or App Search is returning bad data - throw new Error(`Invalid data received from App Search: ${JSON.stringify(engines)}`); - } - } catch (e) { - log.error(`Cannot connect to App Search: ${e.toString()}`); - if (e instanceof Error) log.debug(e.stack as string); - - return response.customError({ statusCode: 502, body: 'cannot-connect' }); - } + }, + hasValidData: (body?: IEnginesResponse) => + Array.isArray(body?.results) && typeof body?.meta?.page?.total_results === 'number', + })(context, request, response); } ); } diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts index daf0a1e895a61..acddd3539965a 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts @@ -5,7 +5,7 @@ */ import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks'; -import { MockRouter, mockConfig, mockLogger } from '../../__mocks__'; +import { MockRouter, mockLogger, mockDependencies } from '../../__mocks__'; jest.mock('../../collectors/lib/telemetry', () => ({ incrementUICounter: jest.fn(), @@ -28,10 +28,10 @@ describe('Enterprise Search Telemetry API', () => { mockRouter = new MockRouter({ method: 'put', payload: 'body' }); registerTelemetryRoute({ + ...mockDependencies, router: mockRouter.router, getSavedObjectsService: () => savedObjectsServiceMock.createStartContract(), log: mockLogger, - config: mockConfig, }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts index 69e8354e8b2f7..a9bd4020e74b7 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts @@ -4,127 +4,47 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MockRouter, mockConfig, mockLogger } from '../../__mocks__'; +import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; import { registerWSOverviewRoute } from './overview'; -jest.mock('node-fetch'); -const fetch = jest.requireActual('node-fetch'); -const { Response } = fetch; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const fetchMock = require('node-fetch') as jest.Mocked; - -const ORG_ROUTE = 'http://localhost:3002/ws/org'; - -describe('engine routes', () => { +describe('Overview route', () => { describe('GET /api/workplace_search/overview', () => { - const AUTH_HEADER = 'Basic 123'; - const mockRequest = { - headers: { - authorization: AUTH_HEADER, - }, - query: {}, - }; - - const mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + let mockRouter: MockRouter; beforeEach(() => { jest.clearAllMocks(); - mockRouter.createRouter(); + mockRouter = new MockRouter({ method: 'get', payload: 'query' }); registerWSOverviewRoute({ + ...mockDependencies, router: mockRouter.router, - log: mockLogger, - config: mockConfig, }); }); - describe('when the underlying Workplace Search API returns a 200', () => { - beforeEach(() => { - WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, { - headers: { Authorization: AUTH_HEADER }, - }).andReturn({ accountsCount: 1 }); - }); - - it('should return 200 with a list of overview from the Workplace Search API', async () => { - await mockRouter.callRoute(mockRequest); - - expect(mockRouter.response.ok).toHaveBeenCalledWith({ - body: { accountsCount: 1 }, - headers: { 'content-type': 'application/json' }, - }); + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org', + hasValidData: expect.any(Function), }); }); - describe('when the Workplace Search URL is invalid', () => { - beforeEach(() => { - WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, { - headers: { Authorization: AUTH_HEADER }, - }).andReturnError(); - }); - - it('should return 502 with a message', async () => { - await mockRouter.callRoute(mockRequest); - - expect(mockRouter.response.customError).toHaveBeenCalledWith({ - statusCode: 502, - body: 'cannot-connect', - }); - expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to Workplace Search: Failed'); - expect(mockLogger.debug).not.toHaveBeenCalled(); - }); - }); + describe('hasValidData', () => { + it('should correctly validate that the response has data', () => { + const response = { + accountsCount: 1, + }; - describe('when the Workplace Search API returns invalid data', () => { - beforeEach(() => { - WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, { - headers: { Authorization: AUTH_HEADER }, - }).andReturnInvalidData(); + expect(mockRequestHandler.hasValidData(response)).toBe(true); }); - it('should return 502 with a message', async () => { - await mockRouter.callRoute(mockRequest); + it('should correctly validate that a response does not have data', () => { + const response = { + foo: 'bar', + }; - expect(mockRouter.response.customError).toHaveBeenCalledWith({ - statusCode: 502, - body: 'cannot-connect', - }); - expect(mockLogger.error).toHaveBeenCalledWith( - 'Cannot connect to Workplace Search: Error: Invalid data received from Workplace Search: {"foo":"bar"}' - ); - expect(mockLogger.debug).toHaveBeenCalled(); + expect(mockRequestHandler.hasValidData(response)).toBe(false); }); }); - - const WorkplaceSearchAPI = { - shouldBeCalledWith(expectedUrl: string, expectedParams: object) { - return { - andReturn(response: object) { - fetchMock.mockImplementation((url: string, params: object) => { - expect(url).toEqual(expectedUrl); - expect(params).toEqual(expectedParams); - - return Promise.resolve(new Response(JSON.stringify(response))); - }); - }, - andReturnInvalidData() { - fetchMock.mockImplementation((url: string, params: object) => { - expect(url).toEqual(expectedUrl); - expect(params).toEqual(expectedParams); - - return Promise.resolve(new Response(JSON.stringify({ foo: 'bar' }))); - }); - }, - andReturnError() { - fetchMock.mockImplementation((url: string, params: object) => { - expect(url).toEqual(expectedUrl); - expect(params).toEqual(expectedParams); - - return Promise.reject('Failed'); - }); - }, - }; - }, - }; }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts index 9e5d94ac1b4fe..8cfd65725a23a 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts @@ -4,43 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import fetch from 'node-fetch'; - import { IRouteDependencies } from '../../plugin'; -export function registerWSOverviewRoute({ router, config, log }: IRouteDependencies) { +export function registerWSOverviewRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { router.get( { path: '/api/workplace_search/overview', validate: false, }, - async (context, request, response) => { - try { - const entSearchUrl = config.host as string; - const url = `${encodeURI(entSearchUrl)}/ws/org`; - - const overviewResponse = await fetch(url, { - headers: { Authorization: request.headers.authorization as string }, - }); - - const body = await overviewResponse.json(); - const hasValidData = typeof body?.accountsCount === 'number'; - - if (hasValidData) { - return response.ok({ - body, - headers: { 'content-type': 'application/json' }, - }); - } else { - // Either a completely incorrect Enterprise Search host URL was configured, or Workplace Search is returning bad data - throw new Error(`Invalid data received from Workplace Search: ${JSON.stringify(body)}`); - } - } catch (e) { - log.error(`Cannot connect to Workplace Search: ${e.toString()}`); - if (e instanceof Error) log.debug(e.stack as string); - - return response.customError({ statusCode: 502, body: 'cannot-connect' }); - } - } + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org', + hasValidData: (body: { accountsCount: number }) => typeof body?.accountsCount === 'number', + }) ); } From 1e20cd4cf44fe1395cc99277a6ac940adb236f49 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 28 Aug 2020 11:04:24 -0600 Subject: [PATCH 02/72] [Maps] add drilldown support map embeddable (#75598) * [Maps] add drilldown support * filter actions view * use i18n getter * remove unused changed * tslint fixes * more tslint changes * clean-up and API doc changes * update snapshots * do not show first drilldown in tooltip * add light grey line to seperate feature property rows * Improving tooltip row styles (#36) * Improving tooltip actions row styles * Removind unecessary comment * update snapshot and add functional test * switch UiActionsActionDefinition to Action and remove unneeded checks Co-authored-by: Elastic Machine Co-authored-by: Elizabet Oliveira --- ...-data-public.action_global_apply_filter.md | 11 ++ .../kibana-plugin-plugins-data-public.md | 1 + src/plugins/data/public/index.ts | 2 +- src/plugins/data/public/public.api.md | 5 + .../common/descriptor_types/map_descriptor.ts | 1 + .../geometry_filter_form.test.js.snap | 16 ++ .../public/components/_action_select.scss | 3 + .../maps/public/components/_index.scss | 1 + .../maps/public/components/action_select.tsx | 87 +++++++++++ .../components/distance_filter_form.tsx | 23 ++- .../public/components/geometry_filter_form.js | 17 ++ .../feature_properties.test.js.snap | 96 +++++++++++- .../map/features_tooltip/_index.scss | 12 +- .../feature_geometry_filter_form.js | 38 +---- .../features_tooltip/feature_properties.js | 145 +++++++++++++++--- .../feature_properties.test.js | 30 +++- .../map/features_tooltip/features_tooltip.js | 73 +++++++-- .../map/mb/draw_control/draw_control.js | 25 ++- .../map/mb/tooltip_control/tooltip_control.js | 2 + .../map/mb/tooltip_control/tooltip_popover.js | 2 + .../connected_components/map/mb/view.js | 2 + .../map_container/map_container.tsx | 16 +- .../toolbar_overlay/toolbar_overlay.js | 8 +- .../tools_control/tools_control.js | 6 + .../maps/public/embeddable/map_embeddable.tsx | 42 ++++- .../maps/embeddable/tooltip_filter_actions.js | 57 +++++-- .../es_archives/maps/kibana/data.json | 6 +- 27 files changed, 608 insertions(+), 119 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.action_global_apply_filter.md create mode 100644 x-pack/plugins/maps/public/components/_action_select.scss create mode 100644 x-pack/plugins/maps/public/components/action_select.tsx diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.action_global_apply_filter.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.action_global_apply_filter.md new file mode 100644 index 0000000000000..14075ba1beba0 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.action_global_apply_filter.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ACTION\_GLOBAL\_APPLY\_FILTER](./kibana-plugin-plugins-data-public.action_global_apply_filter.md) + +## ACTION\_GLOBAL\_APPLY\_FILTER variable + +Signature: + +```typescript +ACTION_GLOBAL_APPLY_FILTER = "ACTION_GLOBAL_APPLY_FILTER" +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index a5453c7c51d5b..09702df4fdb54 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -89,6 +89,7 @@ | Variable | Description | | --- | --- | +| [ACTION\_GLOBAL\_APPLY\_FILTER](./kibana-plugin-plugins-data-public.action_global_apply_filter.md) | | | [AggGroupLabels](./kibana-plugin-plugins-data-public.agggrouplabels.md) | | | [AggGroupNames](./kibana-plugin-plugins-data-public.agggroupnames.md) | | | [baseFormattersPublic](./kibana-plugin-plugins-data-public.baseformatterspublic.md) | | diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index eb5703f1c63c1..27b16c57ffecf 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -440,7 +440,7 @@ export { export { isTimeRange, isQuery, isFilter, isFilters } from '../common'; -export { ApplyGlobalFilterActionContext } from './actions'; +export { ACTION_GLOBAL_APPLY_FILTER, ApplyGlobalFilterActionContext } from './actions'; export * from '../common/field_mapping'; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 9a2a82e8ed206..de4ec58dfdab3 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -78,6 +78,11 @@ import { UnregisterCallback } from 'history'; import { UnwrapPromiseOrReturn } from '@kbn/utility-types'; import { UserProvidedValues } from 'src/core/server/types'; +// Warning: (ae-missing-release-tag) "ACTION_GLOBAL_APPLY_FILTER" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const ACTION_GLOBAL_APPLY_FILTER = "ACTION_GLOBAL_APPLY_FILTER"; + // Warning: (ae-forgotten-export) The symbol "AggConfigSerialized" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "AggConfigOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/x-pack/plugins/maps/common/descriptor_types/map_descriptor.ts b/x-pack/plugins/maps/common/descriptor_types/map_descriptor.ts index ed0656a2fc265..d064dfb1c4a37 100644 --- a/x-pack/plugins/maps/common/descriptor_types/map_descriptor.ts +++ b/x-pack/plugins/maps/common/descriptor_types/map_descriptor.ts @@ -53,6 +53,7 @@ export type TooltipState = { }; export type DrawState = { + actionId: string; drawType: DRAW_TYPE; filterLabel?: string; // point radius filter alias geoFieldName?: string; diff --git a/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap b/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap index 85a073c8d9ace..2d39a52dfe974 100644 --- a/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap +++ b/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap @@ -38,6 +38,10 @@ exports[`should not render relation select when geo field is geo_point 1`] = ` } } /> + @@ -121,6 +125,10 @@ exports[`should not show "within" relation when filter geometry is not closed 1` value="INTERSECTS" /> + @@ -177,6 +185,10 @@ exports[`should render error message 1`] = ` } } /> + @@ -267,6 +279,10 @@ exports[`should render relation select when geo field is geo_shape 1`] = ` value="INTERSECTS" /> + diff --git a/x-pack/plugins/maps/public/components/_action_select.scss b/x-pack/plugins/maps/public/components/_action_select.scss new file mode 100644 index 0000000000000..be903ad7d6962 --- /dev/null +++ b/x-pack/plugins/maps/public/components/_action_select.scss @@ -0,0 +1,3 @@ +.mapActionSelectIcon { + margin-right: $euiSizeS; +} diff --git a/x-pack/plugins/maps/public/components/_index.scss b/x-pack/plugins/maps/public/components/_index.scss index 76e27338bdcd4..76ce9f1bc79e3 100644 --- a/x-pack/plugins/maps/public/components/_index.scss +++ b/x-pack/plugins/maps/public/components/_index.scss @@ -1,3 +1,4 @@ +@import 'action_select'; @import 'metric_editors'; @import './geometry_filter'; @import 'tooltip_selector/tooltip_selector'; diff --git a/x-pack/plugins/maps/public/components/action_select.tsx b/x-pack/plugins/maps/public/components/action_select.tsx new file mode 100644 index 0000000000000..ad61a6a129974 --- /dev/null +++ b/x-pack/plugins/maps/public/components/action_select.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component } from 'react'; +import { EuiFormRow, EuiSuperSelect, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; + +interface Props { + value?: string; + onChange: (value: string) => void; + getFilterActions?: () => Promise; + getActionContext?: () => ActionExecutionContext; +} + +interface State { + actions: Action[]; +} + +export class ActionSelect extends Component { + private _isMounted = false; + state: State = { + actions: [], + }; + + componentDidMount() { + this._isMounted = true; + this._loadActions(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadActions() { + if (!this.props.getFilterActions || !this.props.getActionContext) { + return; + } + const actions = await this.props.getFilterActions(); + if (this._isMounted) { + this.setState({ actions }); + } + } + + render() { + if (this.state.actions.length === 0 || !this.props.getActionContext) { + return null; + } + + if (this.state.actions.length === 1 && this.props.value === this.state.actions[0].id) { + return null; + } + + const actionContext = this.props.getActionContext(); + const options = this.state.actions.map((action) => { + const iconType = action.getIconType(actionContext); + return { + value: action.id, + inputDisplay: ( +
+ {iconType ? : null} + {action.getDisplayName(actionContext)} +
+ ), + }; + }); + + return ( + + + + ); + } +} diff --git a/x-pack/plugins/maps/public/components/distance_filter_form.tsx b/x-pack/plugins/maps/public/components/distance_filter_form.tsx index 768be1558bd69..24d9aec5b77b4 100644 --- a/x-pack/plugins/maps/public/components/distance_filter_form.tsx +++ b/x-pack/plugins/maps/public/components/distance_filter_form.tsx @@ -14,18 +14,25 @@ import { EuiTextAlign, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; import { MultiIndexGeoFieldSelect } from './multi_index_geo_field_select'; import { GeoFieldWithIndex } from './geo_field_with_index'; +import { ActionSelect } from './action_select'; +import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../src/plugins/data/public'; interface Props { className?: string; buttonLabel: string; geoFields: GeoFieldWithIndex[]; + getFilterActions?: () => Promise; + getActionContext?: () => ActionExecutionContext; onSubmit: ({ + actionId, filterLabel, indexPatternId, geoFieldName, }: { + actionId: string; filterLabel: string; indexPatternId: string; geoFieldName: string; @@ -33,12 +40,14 @@ interface Props { } interface State { + actionId: string; selectedField: GeoFieldWithIndex | undefined; filterLabel: string; } export class DistanceFilterForm extends Component { - state = { + state: State = { + actionId: ACTION_GLOBAL_APPLY_FILTER, selectedField: this.props.geoFields.length ? this.props.geoFields[0] : undefined, filterLabel: '', }; @@ -53,11 +62,16 @@ export class DistanceFilterForm extends Component { }); }; + _onActionIdChange = (value: string) => { + this.setState({ actionId: value }); + }; + _onSubmit = () => { if (!this.state.selectedField) { return; } this.props.onSubmit({ + actionId: this.state.actionId, filterLabel: this.state.filterLabel, indexPatternId: this.state.selectedField.indexPatternId, geoFieldName: this.state.selectedField.geoFieldName, @@ -86,6 +100,13 @@ export class DistanceFilterForm extends Component { onChange={this._onGeoFieldChange} /> + + diff --git a/x-pack/plugins/maps/public/components/geometry_filter_form.js b/x-pack/plugins/maps/public/components/geometry_filter_form.js index d5cdda3c1c324..fde07e8c16bc5 100644 --- a/x-pack/plugins/maps/public/components/geometry_filter_form.js +++ b/x-pack/plugins/maps/public/components/geometry_filter_form.js @@ -20,11 +20,15 @@ import { i18n } from '@kbn/i18n'; import { ES_GEO_FIELD_TYPE, ES_SPATIAL_RELATIONS } from '../../common/constants'; import { getEsSpatialRelationLabel } from '../../common/i18n_getters'; import { MultiIndexGeoFieldSelect } from './multi_index_geo_field_select'; +import { ActionSelect } from './action_select'; +import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../src/plugins/data/public'; export class GeometryFilterForm extends Component { static propTypes = { buttonLabel: PropTypes.string.isRequired, geoFields: PropTypes.array.isRequired, + getFilterActions: PropTypes.func, + getActionContext: PropTypes.func, intitialGeometryLabel: PropTypes.string.isRequired, onSubmit: PropTypes.func.isRequired, isFilterGeometryClosed: PropTypes.bool, @@ -36,6 +40,7 @@ export class GeometryFilterForm extends Component { }; state = { + actionId: ACTION_GLOBAL_APPLY_FILTER, selectedField: this.props.geoFields.length ? this.props.geoFields[0] : undefined, geometryLabel: this.props.intitialGeometryLabel, relation: ES_SPATIAL_RELATIONS.INTERSECTS, @@ -57,8 +62,13 @@ export class GeometryFilterForm extends Component { }); }; + _onActionIdChange = (value) => { + this.setState({ actionId: value }); + }; + _onSubmit = () => { this.props.onSubmit({ + actionId: this.state.actionId, geometryLabel: this.state.geometryLabel, indexPatternId: this.state.selectedField.indexPatternId, geoFieldName: this.state.selectedField.geoFieldName, @@ -134,6 +144,13 @@ export class GeometryFilterForm extends Component { {this._renderRelationInput()} + + {error} diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/feature_properties.test.js.snap b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/feature_properties.test.js.snap index 3b3d82c92fbb7..29df06a64a3f2 100644 --- a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/feature_properties.test.js.snap +++ b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/feature_properties.test.js.snap @@ -1,11 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`FeatureProperties should not show filter button 1`] = ` +exports[`FeatureProperties should render 1`] = `
+
+
@@ -56,12 +60,13 @@ exports[`FeatureProperties should show error message if unable to load tooltip c `; -exports[`FeatureProperties should show only filter button for filterable properties 1`] = ` +exports[`FeatureProperties should show filter button for filterable properties 1`] = ` + +
- + > + +
+ +
+`; + +exports[`FeatureProperties should show view actions button when there are available actions 1`] = ` + + + + + + + + +
+ prop1 + + + + + + + + + + +
+ prop2 + +
diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/_index.scss b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/_index.scss index fb75cc1e2db69..abd747c8fa47a 100644 --- a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/_index.scss +++ b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/_index.scss @@ -1,6 +1,5 @@ .mapFeatureTooltip_table { width: 100%; - display: block; max-height: calc(49vh - #{$euiSizeXL * 2}); td { @@ -8,6 +7,10 @@ } } +.mapFeatureTooltip_row { + border-bottom: 1px solid $euiColorLightestShade; +} + .mapFeatureTooltip_actionLinks { padding: $euiSizeXS; } @@ -20,3 +23,10 @@ max-width: $euiSizeXL * 4; font-weight: $euiFontWeightSemiBold; } + +.mapFeatureTooltip_actionsRow { + > span { + display: flex; + justify-content: flex-end; + } +} diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_geometry_filter_form.js b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_geometry_filter_form.js index b0ce52b4db7ab..98267965fd30f 100644 --- a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_geometry_filter_form.js +++ b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_geometry_filter_form.js @@ -4,9 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component, Fragment } from 'react'; -import { EuiIcon } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import React, { Component } from 'react'; import { i18n } from '@kbn/i18n'; import { URL_MAX_LENGTH } from '../../../../../../../src/core/public'; @@ -95,28 +93,7 @@ export class FeatureGeometryFilterForm extends Component { this.props.onClose(); }; - _renderHeader() { - return ( - - ); - } - - _renderForm() { + render() { return ( ); } - - render() { - return ( - - {this._renderHeader()} - {this._renderForm()} - - ); - } } diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.js b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.js index 5e2a153b2ccbf..edd501f266690 100644 --- a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.js +++ b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.js @@ -5,12 +5,21 @@ */ import React from 'react'; -import { EuiCallOut, EuiLoadingSpinner, EuiTextAlign, EuiButtonIcon } from '@elastic/eui'; +import { + EuiCallOut, + EuiLoadingSpinner, + EuiTextAlign, + EuiButtonEmpty, + EuiIcon, + EuiContextMenu, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../../../src/plugins/data/public'; export class FeatureProperties extends React.Component { state = { properties: null, + actions: [], loadPropertiesErrorMsg: null, prevWidth: null, prevHeight: null, @@ -21,6 +30,7 @@ export class FeatureProperties extends React.Component { this.prevLayerId = undefined; this.prevFeatureId = undefined; this._loadProperties(); + this._loadActions(); } componentDidUpdate() { @@ -31,6 +41,16 @@ export class FeatureProperties extends React.Component { this._isMounted = false; } + async _loadActions() { + if (!this.props.getFilterActions) { + return; + } + const actions = await this.props.getFilterActions(); + if (this._isMounted) { + this.setState({ actions }); + } + } + _loadProperties = async () => { this._fetchProperties({ nextFeatureId: this.props.featureId, @@ -39,6 +59,10 @@ export class FeatureProperties extends React.Component { }); }; + _showFilterActions = (tooltipProperty) => { + this.props.showFilterActions(this._renderFilterActions(tooltipProperty)); + }; + _fetchProperties = async ({ nextLayerId, nextFeatureId, mbProperties }) => { if (this.prevLayerId === nextLayerId && this.prevFeatureId === nextFeatureId) { // do not reload same feature properties @@ -83,35 +107,108 @@ export class FeatureProperties extends React.Component { } if (this._isMounted) { - this.setState({ - properties, - }); + this.setState({ properties }); } }; + _renderFilterActions(tooltipProperty) { + const panel = { + id: 0, + items: this.state.actions.map((action) => { + const actionContext = this.props.getActionContext(); + const iconType = action.getIconType(actionContext); + const name = action.getDisplayName(actionContext); + return { + name, + icon: iconType ? : null, + onClick: async () => { + this.props.onCloseTooltip(); + const filters = await tooltipProperty.getESFilters(); + this.props.addFilters(filters, action.id); + }, + ['data-test-subj']: `mapFilterActionButton__${name}`, + }; + }), + }; + + return ( +
+ (this._node = node)} + > + + + + + +
+ {tooltipProperty.getPropertyName()} + +
+ +
+ ); + } + _renderFilterCell(tooltipProperty) { if (!this.props.showFilterButtons || !tooltipProperty.isFilterable()) { - return null; + return ; } - return ( - - { - this.props.onCloseTooltip(); - const filters = await tooltipProperty.getESFilters(); - this.props.addFilters(filters); - }} - aria-label={i18n.translate('xpack.maps.tooltip.filterOnPropertyAriaLabel', { - defaultMessage: 'Filter on property', - })} - data-test-subj="mapTooltipCreateFilterButton" - /> + const applyFilterButton = ( + { + this.props.onCloseTooltip(); + const filters = await tooltipProperty.getESFilters(); + this.props.addFilters(filters); + }} + aria-label={i18n.translate('xpack.maps.tooltip.filterOnPropertyAriaLabel', { + defaultMessage: 'Filter on property', + })} + data-test-subj="mapTooltipCreateFilterButton" + > + + + ); + + return this.state.actions.length === 0 || + (this.state.actions.length === 1 && + this.state.actions[0].id === ACTION_GLOBAL_APPLY_FILTER) ? ( + {applyFilterButton} + ) : ( + + + {applyFilterButton} + { + this._showFilterActions(tooltipProperty); + }} + aria-label={i18n.translate('xpack.maps.tooltip.viewActionsTitle', { + defaultMessage: 'View filter actions', + })} + data-test-subj="mapTooltipMoreActionsButton" + > + + + ); } @@ -154,7 +251,7 @@ export class FeatureProperties extends React.Component { const rows = this.state.properties.map((tooltipProperty) => { const label = tooltipProperty.getPropertyName(); return ( - + {label} {}, showFilterButtons: false, + getFilterActions: () => { + return [{ id: ACTION_GLOBAL_APPLY_FILTER }]; + }, }; const mockTooltipProperties = [ @@ -44,10 +48,29 @@ const mockTooltipProperties = [ ]; describe('FeatureProperties', () => { - test('should not show filter button', async () => { + test('should render', async () => { + const component = shallow( + { + return mockTooltipProperties; + }} + /> + ); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + + test('should show filter button for filterable properties', async () => { const component = shallow( { return mockTooltipProperties; }} @@ -62,7 +85,7 @@ describe('FeatureProperties', () => { expect(component).toMatchSnapshot(); }); - test('should show only filter button for filterable properties', async () => { + test('should show view actions button when there are available actions', async () => { const component = shallow( { loadFeatureProperties={() => { return mockTooltipProperties; }} + getFilterActions={() => { + return [{ id: 'drilldown1' }]; + }} /> ); diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/features_tooltip.js b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/features_tooltip.js index d91bc8e803ab9..8547219b42e30 100644 --- a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/features_tooltip.js +++ b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/features_tooltip.js @@ -4,20 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; -import { EuiLink } from '@elastic/eui'; +import React, { Component, Fragment } from 'react'; +import { EuiIcon, EuiLink } from '@elastic/eui'; import { FeatureProperties } from './feature_properties'; -import { FormattedMessage } from '@kbn/i18n/react'; import { GEO_JSON_TYPE, ES_GEO_FIELD_TYPE } from '../../../../common/constants'; import { FeatureGeometryFilterForm } from './feature_geometry_filter_form'; import { TooltipHeader } from './tooltip_header'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; const VIEWS = { PROPERTIES_VIEW: 'PROPERTIES_VIEW', GEOMETRY_FILTER_VIEW: 'GEOMETRY_FILTER_VIEW', + FILTER_ACTIONS_VIEW: 'FILTER_ACTIONS_VIEW', }; -export class FeaturesTooltip extends React.Component { +export class FeaturesTooltip extends Component { state = {}; static getDerivedStateFromProps(nextProps, prevState) { @@ -41,7 +43,11 @@ export class FeaturesTooltip extends React.Component { }; _showPropertiesView = () => { - this.setState({ view: VIEWS.PROPERTIES_VIEW }); + this.setState({ view: VIEWS.PROPERTIES_VIEW, filterView: null }); + }; + + _showFilterActionsView = (filterView) => { + this.setState({ view: VIEWS.FILTER_ACTIONS_VIEW, filterView }); }; _renderActions(geoFields) { @@ -96,6 +102,22 @@ export class FeaturesTooltip extends React.Component { }); }; + _renderBackButton(label) { + return ( + + ); + } + render() { if (!this.state.currentFeature) { return null; @@ -109,14 +131,36 @@ export class FeaturesTooltip extends React.Component { if (this.state.view === VIEWS.GEOMETRY_FILTER_VIEW && currentFeatureGeometry) { return ( - + + {this._renderBackButton( + i18n.translate('xpack.maps.tooltip.showGeometryFilterViewLinkLabel', { + defaultMessage: 'Filter by geometry', + }) + )} + + + ); + } + + if (this.state.view === VIEWS.FILTER_ACTIONS_VIEW) { + return ( + + {this._renderBackButton( + i18n.translate('xpack.maps.tooltip.showAddFilterActionsViewLabel', { + defaultMessage: 'Filter actions', + }) + )} + {this.state.filterView} + ); } @@ -137,6 +181,9 @@ export class FeaturesTooltip extends React.Component { showFilterButtons={!!this.props.addFilters && this.props.isLocked} onCloseTooltip={this.props.closeTooltip} addFilters={this.props.addFilters} + getFilterActions={this.props.getFilterActions} + getActionContext={this.props.getActionContext} + showFilterActions={this._showFilterActionsView} /> {this._renderActions(geoFields)} diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js b/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js index 6de936fa4a8f1..49675ac6a3924 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js +++ b/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js @@ -62,11 +62,12 @@ export class DrawControl extends React.Component { } }, 0); - _onDraw = (e) => { + _onDraw = async (e) => { if (!e.features.length) { return; } + let filter; if (this.props.drawState.drawType === DRAW_TYPE.DISTANCE) { const circle = e.features[0]; const distanceKm = _.round( @@ -82,7 +83,7 @@ export class DrawControl extends React.Component { } else if (distanceKm <= 100) { precision = 3; } - const filter = createDistanceFilterWithMeta({ + filter = createDistanceFilterWithMeta({ alias: this.props.drawState.filterLabel, distanceKm, geoFieldName: this.props.drawState.geoFieldName, @@ -92,17 +93,12 @@ export class DrawControl extends React.Component { _.round(circle.properties.center[1], precision), ], }); - this.props.addFilters([filter]); - this.props.disableDrawState(); - return; - } - - const geometry = e.features[0].geometry; - // MapboxDraw returns coordinates with 12 decimals. Round to a more reasonable number - roundCoordinates(geometry.coordinates); + } else { + const geometry = e.features[0].geometry; + // MapboxDraw returns coordinates with 12 decimals. Round to a more reasonable number + roundCoordinates(geometry.coordinates); - try { - const filter = createSpatialFilterWithGeometry({ + filter = createSpatialFilterWithGeometry({ geometry: this.props.drawState.drawType === DRAW_TYPE.BOUNDS ? getBoundingBoxGeometry(geometry) @@ -113,7 +109,10 @@ export class DrawControl extends React.Component { geometryLabel: this.props.drawState.geometryLabel, relation: this.props.drawState.relation, }); - this.props.addFilters([filter]); + } + + try { + await this.props.addFilters([filter], this.props.drawState.actionId); } catch (error) { // TODO notify user why filter was not created console.error(error); diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js b/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js index 84a29db852539..87d6f8e1d8e71 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js +++ b/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js @@ -195,6 +195,8 @@ export class TooltipControl extends React.Component { mbMap={this.props.mbMap} layerList={this.props.layerList} addFilters={this.props.addFilters} + getFilterActions={this.props.getFilterActions} + getActionContext={this.props.getActionContext} renderTooltipContent={this.props.renderTooltipContent} geoFields={this.props.geoFields} features={features} diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.js b/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.js index 6c42057680408..4cfddf0034039 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.js +++ b/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.js @@ -117,6 +117,8 @@ export class TooltipPopover extends Component { _renderTooltipContent = () => { const publicProps = { addFilters: this.props.addFilters, + getFilterActions: this.props.getFilterActions, + getActionContext: this.props.getActionContext, closeTooltip: this.props.closeTooltip, features: this.props.features, isLocked: this.props.isLocked, diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/view.js b/x-pack/plugins/maps/public/connected_components/map/mb/view.js index 5a38f6039ae4b..22c374aceedd5 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/view.js +++ b/x-pack/plugins/maps/public/connected_components/map/mb/view.js @@ -309,6 +309,8 @@ export class MBMap extends React.Component { diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx index beb1eb0947c50..bf75c86ac249d 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx @@ -11,6 +11,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import uuid from 'uuid/v4'; import { Filter } from 'src/plugins/data/public'; +import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; // @ts-expect-error import { MBMap } from '../map/mb'; // @ts-expect-error @@ -35,7 +36,9 @@ import 'mapbox-gl/dist/mapbox-gl.css'; const RENDER_COMPLETE_EVENT = 'renderComplete'; interface Props { - addFilters: ((filters: Filter[]) => void) | null; + addFilters: ((filters: Filter[]) => Promise) | null; + getFilterActions?: () => Promise; + getActionContext?: () => ActionExecutionContext; areLayersLoaded: boolean; cancelAllInFlightRequests: () => void; exitFullScreen: () => void; @@ -183,6 +186,8 @@ export class MapContainer extends Component { render() { const { addFilters, + getFilterActions, + getActionContext, flyoutDisplay, isFullScreen, exitFullScreen, @@ -230,11 +235,18 @@ export class MapContainer extends Component { {!this.props.hideToolbarOverlay && ( - + )} diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js index a4f85163512f7..a9dc3f822060c 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js @@ -12,14 +12,18 @@ import { FitToData } from './fit_to_data'; export class ToolbarOverlay extends React.Component { _renderToolsControl() { - const { addFilters, geoFields } = this.props; + const { addFilters, geoFields, getFilterActions, getActionContext } = this.props; if (!addFilters || !geoFields.length) { return null; } return ( - + ); } diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js index a06def086b861..017f0369e0b73 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js @@ -123,6 +123,8 @@ export class ToolsControl extends Component { className="mapDrawControl__geometryFilterForm" buttonLabel={DRAW_SHAPE_LABEL_SHORT} geoFields={this.props.geoFields} + getFilterActions={this.props.getFilterActions} + getActionContext={this.props.getActionContext} intitialGeometryLabel={i18n.translate( 'xpack.maps.toolbarOverlay.drawShape.initialGeometryLabel', { @@ -141,6 +143,8 @@ export class ToolsControl extends Component { className="mapDrawControl__geometryFilterForm" buttonLabel={DRAW_BOUNDS_LABEL_SHORT} geoFields={this.props.geoFields} + getFilterActions={this.props.getFilterActions} + getActionContext={this.props.getActionContext} intitialGeometryLabel={i18n.translate( 'xpack.maps.toolbarOverlay.drawBounds.initialGeometryLabel', { @@ -161,6 +165,8 @@ export class ToolsControl extends Component { geoFields={this.props.geoFields.filter(({ geoFieldType }) => { return geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT; })} + getFilterActions={this.props.getFilterActions} + getActionContext={this.props.getActionContext} onSubmit={this._initiateDistanceDraw} /> ), diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index 43ff274b1353f..1cb393bede956 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -11,7 +11,12 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { Subscription } from 'rxjs'; import { Unsubscribe } from 'redux'; import { Embeddable, IContainer } from '../../../../../src/plugins/embeddable/public'; -import { APPLY_FILTER_TRIGGER } from '../../../../../src/plugins/ui_actions/public'; +import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../src/plugins/data/public'; +import { + APPLY_FILTER_TRIGGER, + ActionExecutionContext, + TriggerContextMapping, +} from '../../../../../src/plugins/ui_actions/public'; import { esFilters, TimeRange, @@ -99,6 +104,10 @@ export class MapEmbeddable extends Embeddable this.onContainerStateChanged(input)); } + supportedTriggers(): Array { + return [APPLY_FILTER_TRIGGER]; + } + setRenderTooltipContent = (renderTooltipContent: RenderToolTipContent) => { this._renderTooltipContent = renderTooltipContent; }; @@ -226,6 +235,8 @@ export class MapEmbeddable extends Embeddable @@ -243,13 +254,36 @@ export class MapEmbeddable extends Embeddable(replaceLayerList(this._layerList)); } - addFilters = (filters: Filter[]) => { - getUiActions().executeTriggerActions(APPLY_FILTER_TRIGGER, { - embeddable: this, + addFilters = async (filters: Filter[], actionId: string = ACTION_GLOBAL_APPLY_FILTER) => { + const executeContext = { + ...this.getActionContext(), filters, + }; + const action = getUiActions().getAction(actionId); + if (!action) { + throw new Error('Unable to apply filter, could not locate action'); + } + action.execute(executeContext); + }; + + getFilterActions = async () => { + return await getUiActions().getTriggerCompatibleActions(APPLY_FILTER_TRIGGER, { + embeddable: this, + filters: [], }); }; + getActionContext = () => { + const trigger = getUiActions().getTrigger(APPLY_FILTER_TRIGGER); + if (!trigger) { + throw new Error('Unable to get context, could not locate trigger'); + } + return { + embeddable: this, + trigger, + } as ActionExecutionContext; + }; + destroy() { super.destroy(); if (this._unsubscribeFromStore) { diff --git a/x-pack/test/functional/apps/maps/embeddable/tooltip_filter_actions.js b/x-pack/test/functional/apps/maps/embeddable/tooltip_filter_actions.js index a996910d4787a..10754d20118e9 100644 --- a/x-pack/test/functional/apps/maps/embeddable/tooltip_filter_actions.js +++ b/x-pack/test/functional/apps/maps/embeddable/tooltip_filter_actions.js @@ -13,31 +13,62 @@ export default function ({ getPageObjects, getService }) { const filterBar = getService('filterBar'); describe('tooltip filter actions', () => { - before(async () => { + async function loadDashboardAndOpenTooltip() { await kibanaServer.uiSettings.replace({ defaultIndex: 'c698b940-e149-11e8-a35a-370a8516603a', }); await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); await PageObjects.dashboard.loadSavedDashboard('dash for tooltip filter action test'); await PageObjects.maps.lockTooltipAtPosition(200, -200); - }); + } + + describe('apply filter to current view', () => { + before(async () => { + await loadDashboardAndOpenTooltip(); + }); + + it('should display create filter button when tooltip is locked', async () => { + const exists = await testSubjects.exists('mapTooltipCreateFilterButton'); + expect(exists).to.be(true); + }); + + it('should create filters when create filter button is clicked', async () => { + await testSubjects.click('mapTooltipCreateFilterButton'); + await testSubjects.click('applyFiltersPopoverButton'); + + // TODO: Fix me #64861 + // const hasSourceFilter = await filterBar.hasFilter('name', 'charlie'); + // expect(hasSourceFilter).to.be(true); - it('should display create filter button when tooltip is locked', async () => { - const exists = await testSubjects.exists('mapTooltipCreateFilterButton'); - expect(exists).to.be(true); + const hasJoinFilter = await filterBar.hasFilter('shape_name', 'charlie'); + expect(hasJoinFilter).to.be(true); + }); }); - it('should create filters when create filter button is clicked', async () => { - await testSubjects.click('mapTooltipCreateFilterButton'); - await testSubjects.click('applyFiltersPopoverButton'); + describe('panel actions', () => { + before(async () => { + await loadDashboardAndOpenTooltip(); + }); + + it('should display more actions button when tooltip is locked', async () => { + const exists = await testSubjects.exists('mapTooltipMoreActionsButton'); + expect(exists).to.be(true); + }); + + it('should trigger drilldown action when clicked', async () => { + await testSubjects.click('mapTooltipMoreActionsButton'); + await testSubjects.click('mapFilterActionButton__drilldown1'); - // TODO: Fix me #64861 - // const hasSourceFilter = await filterBar.hasFilter('name', 'charlie'); - // expect(hasSourceFilter).to.be(true); + // Assert on new dashboard with filter from action + await PageObjects.dashboard.waitForRenderComplete(); + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.equal(2); - const hasJoinFilter = await filterBar.hasFilter('shape_name', 'charlie'); - expect(hasJoinFilter).to.be(true); + const hasJoinFilter = await filterBar.hasFilter('shape_name', 'charlie'); + expect(hasJoinFilter).to.be(true); + }); }); }); } diff --git a/x-pack/test/functional/es_archives/maps/kibana/data.json b/x-pack/test/functional/es_archives/maps/kibana/data.json index 198174bccb286..0f1fd3c09d706 100644 --- a/x-pack/test/functional/es_archives/maps/kibana/data.json +++ b/x-pack/test/functional/es_archives/maps/kibana/data.json @@ -1048,7 +1048,7 @@ "title" : "dash for tooltip filter action test", "hits" : 0, "description" : "Zoomed in so entire screen is covered by filter so click to open tooltip can not miss.", - "panelsJSON" : "[{\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":26,\"i\":\"1\"},\"version\":\"8.0.0\",\"panelIndex\":\"1\",\"embeddableConfig\":{\"mapCenter\":{\"lat\":-1.31919,\"lon\":59.53306,\"zoom\":9.67},\"isLayerTOCOpen\":false,\"openTOCDetails\":[\"n1t6f\"]},\"panelRefName\":\"panel_0\"}]", + "panelsJSON" : "[{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":26,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"mapCenter\":{\"lat\":-1.31919,\"lon\":59.53306,\"zoom\":9.67},\"isLayerTOCOpen\":false,\"openTOCDetails\":[\"n1t6f\"],\"hiddenLayers\":[],\"enhancements\":{\"dynamicActions\":{\"events\":[{\"eventId\":\"669a3521-1215-4228-9ced-77e2edf5ad17\",\"triggers\":[\"FILTER_TRIGGER\"],\"action\":{\"name\":\"drilldown1\",\"config\":{\"dashboardId\":\"19906970-2e40-11e9-85cb-6965aae20f13\",\"useCurrentFilters\":true,\"useCurrentDateRange\":true},\"factoryId\":\"DASHBOARD_TO_DASHBOARD_DRILLDOWN\"}}]}}},\"panelRefName\":\"panel_0\"}]", "optionsJSON" : "{\"useMargins\":true,\"hidePanelTitles\":false}", "version" : 1, "timeRestore" : true, @@ -1071,9 +1071,9 @@ } ], "migrationVersion" : { - "dashboard" : "7.0.0" + "dashboard" : "7.3.0" }, - "updated_at" : "2019-06-14T14:09:25.039Z" + "updated_at" : "2020-08-26T14:32:27.854Z" } } } From 5f781dcfecc9bcd96d9fa5ecfe5cd3e1cdedf094 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Mon, 31 Aug 2020 08:44:27 +0200 Subject: [PATCH 03/72] add client-side feature usage API (#75486) * add client-side feature_usage API * use route context for notify feature usage route --- x-pack/plugins/licensing/public/mocks.ts | 3 + x-pack/plugins/licensing/public/plugin.ts | 5 +- .../services/feature_usage_service.mock.ts | 45 ++++++++++ .../services/feature_usage_service.test.ts | 69 ++++++++++++++++ .../public/services/feature_usage_service.ts | 68 +++++++++++++++ .../licensing/public/services/index.ts | 11 +++ x-pack/plugins/licensing/public/types.ts | 9 ++ x-pack/plugins/licensing/server/plugin.ts | 6 +- .../plugins/licensing/server/routes/index.ts | 5 ++ .../licensing/server/routes/internal/index.ts | 8 ++ .../routes/internal/notify_feature_usage.ts | 32 ++++++++ .../routes/internal/register_feature.ts | 43 ++++++++++ .../licensing_plugin/public/feature_usage.ts | 82 +++++++++++++++++++ x-pack/test/licensing_plugin/public/index.ts | 1 + 14 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/licensing/public/services/feature_usage_service.mock.ts create mode 100644 x-pack/plugins/licensing/public/services/feature_usage_service.test.ts create mode 100644 x-pack/plugins/licensing/public/services/feature_usage_service.ts create mode 100644 x-pack/plugins/licensing/public/services/index.ts create mode 100644 x-pack/plugins/licensing/server/routes/internal/index.ts create mode 100644 x-pack/plugins/licensing/server/routes/internal/notify_feature_usage.ts create mode 100644 x-pack/plugins/licensing/server/routes/internal/register_feature.ts create mode 100644 x-pack/test/licensing_plugin/public/feature_usage.ts diff --git a/x-pack/plugins/licensing/public/mocks.ts b/x-pack/plugins/licensing/public/mocks.ts index 8421a343d91ca..1ddde892de0d9 100644 --- a/x-pack/plugins/licensing/public/mocks.ts +++ b/x-pack/plugins/licensing/public/mocks.ts @@ -6,12 +6,14 @@ import { BehaviorSubject } from 'rxjs'; import { LicensingPluginSetup, LicensingPluginStart } from './types'; import { licenseMock } from '../common/licensing.mock'; +import { featureUsageMock } from './services/feature_usage_service.mock'; const createSetupMock = () => { const license = licenseMock.createLicense(); const mock: jest.Mocked = { license$: new BehaviorSubject(license), refresh: jest.fn(), + featureUsage: featureUsageMock.createSetup(), }; mock.refresh.mockResolvedValue(license); @@ -23,6 +25,7 @@ const createStartMock = () => { const mock: jest.Mocked = { license$: new BehaviorSubject(license), refresh: jest.fn(), + featureUsage: featureUsageMock.createStart(), }; mock.refresh.mockResolvedValue(license); diff --git a/x-pack/plugins/licensing/public/plugin.ts b/x-pack/plugins/licensing/public/plugin.ts index ec42a73f610c0..aa0c25364f2c7 100644 --- a/x-pack/plugins/licensing/public/plugin.ts +++ b/x-pack/plugins/licensing/public/plugin.ts @@ -6,12 +6,12 @@ import { Observable, Subject, Subscription } from 'rxjs'; import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; - import { ILicense } from '../common/types'; import { LicensingPluginSetup, LicensingPluginStart } from './types'; import { createLicenseUpdate } from '../common/license_update'; import { License } from '../common/license'; import { mountExpiredBanner } from './expired_banner'; +import { FeatureUsageService } from './services'; export const licensingSessionStorageKey = 'xpack.licensing'; @@ -39,6 +39,7 @@ export class LicensingPlugin implements Plugin Promise; private license$?: Observable; + private featureUsage = new FeatureUsageService(); constructor( context: PluginInitializerContext, @@ -116,6 +117,7 @@ export class LicensingPlugin implements Plugin => { + const mock = { + register: jest.fn(), + }; + + return mock; +}; + +const createStartMock = (): jest.Mocked => { + const mock = { + notifyUsage: jest.fn(), + }; + + return mock; +}; + +const createServiceMock = (): jest.Mocked> => { + const mock = { + setup: jest.fn(), + start: jest.fn(), + }; + + mock.setup.mockImplementation(() => createSetupMock()); + mock.start.mockImplementation(() => createStartMock()); + + return mock; +}; + +export const featureUsageMock = { + create: createServiceMock, + createSetup: createSetupMock, + createStart: createStartMock, +}; diff --git a/x-pack/plugins/licensing/public/services/feature_usage_service.test.ts b/x-pack/plugins/licensing/public/services/feature_usage_service.test.ts new file mode 100644 index 0000000000000..eba2d1e67b509 --- /dev/null +++ b/x-pack/plugins/licensing/public/services/feature_usage_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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from '../../../../../src/core/public/mocks'; +import { FeatureUsageService } from './feature_usage_service'; + +describe('FeatureUsageService', () => { + let http: ReturnType; + let service: FeatureUsageService; + + beforeEach(() => { + http = httpServiceMock.createSetupContract(); + service = new FeatureUsageService(); + }); + + describe('#setup', () => { + describe('#register', () => { + it('calls the endpoint with the correct parameters', async () => { + const setup = service.setup({ http }); + await setup.register('my-feature', 'platinum'); + expect(http.post).toHaveBeenCalledTimes(1); + expect(http.post).toHaveBeenCalledWith('/internal/licensing/feature_usage/register', { + body: JSON.stringify({ + featureName: 'my-feature', + licenseType: 'platinum', + }), + }); + }); + }); + }); + + describe('#start', () => { + describe('#notifyUsage', () => { + it('calls the endpoint with the correct parameters', async () => { + service.setup({ http }); + const start = service.start({ http }); + await start.notifyUsage('my-feature', 42); + + expect(http.post).toHaveBeenCalledTimes(1); + expect(http.post).toHaveBeenCalledWith('/internal/licensing/feature_usage/notify', { + body: JSON.stringify({ + featureName: 'my-feature', + lastUsed: 42, + }), + }); + }); + + it('correctly convert dates', async () => { + service.setup({ http }); + const start = service.start({ http }); + + const now = new Date(); + + await start.notifyUsage('my-feature', now); + + expect(http.post).toHaveBeenCalledTimes(1); + expect(http.post).toHaveBeenCalledWith('/internal/licensing/feature_usage/notify', { + body: JSON.stringify({ + featureName: 'my-feature', + lastUsed: now.getTime(), + }), + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/licensing/public/services/feature_usage_service.ts b/x-pack/plugins/licensing/public/services/feature_usage_service.ts new file mode 100644 index 0000000000000..588d22eeb818d --- /dev/null +++ b/x-pack/plugins/licensing/public/services/feature_usage_service.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import isDate from 'lodash/isDate'; +import type { HttpSetup, HttpStart } from 'src/core/public'; +import { LicenseType } from '../../common/types'; + +/** @public */ +export interface FeatureUsageServiceSetup { + /** + * Register a feature to be able to notify of it's usages using the {@link FeatureUsageServiceStart | service start contract}. + */ + register(featureName: string, licenseType: LicenseType): Promise; +} + +/** @public */ +export interface FeatureUsageServiceStart { + /** + * Notify of a registered feature usage at given time. + * + * @param featureName - the name of the feature to notify usage of + * @param usedAt - Either a `Date` or an unix timestamp with ms. If not specified, it will be set to the current time. + */ + notifyUsage(featureName: string, usedAt?: Date | number): Promise; +} + +interface SetupDeps { + http: HttpSetup; +} + +interface StartDeps { + http: HttpStart; +} + +/** + * @internal + */ +export class FeatureUsageService { + public setup({ http }: SetupDeps): FeatureUsageServiceSetup { + return { + register: async (featureName, licenseType) => { + await http.post('/internal/licensing/feature_usage/register', { + body: JSON.stringify({ + featureName, + licenseType, + }), + }); + }, + }; + } + + public start({ http }: StartDeps): FeatureUsageServiceStart { + return { + notifyUsage: async (featureName, usedAt = Date.now()) => { + const lastUsed = isDate(usedAt) ? usedAt.getTime() : usedAt; + await http.post('/internal/licensing/feature_usage/notify', { + body: JSON.stringify({ + featureName, + lastUsed, + }), + }); + }, + }; + } +} diff --git a/x-pack/plugins/licensing/public/services/index.ts b/x-pack/plugins/licensing/public/services/index.ts new file mode 100644 index 0000000000000..fc890dd3c927d --- /dev/null +++ b/x-pack/plugins/licensing/public/services/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + FeatureUsageService, + FeatureUsageServiceSetup, + FeatureUsageServiceStart, +} from './feature_usage_service'; diff --git a/x-pack/plugins/licensing/public/types.ts b/x-pack/plugins/licensing/public/types.ts index 71a4a452d163d..43b146c51d9a8 100644 --- a/x-pack/plugins/licensing/public/types.ts +++ b/x-pack/plugins/licensing/public/types.ts @@ -6,6 +6,7 @@ import { Observable } from 'rxjs'; import { ILicense } from '../common/types'; +import { FeatureUsageServiceSetup, FeatureUsageServiceStart } from './services'; /** @public */ export interface LicensingPluginSetup { @@ -19,6 +20,10 @@ export interface LicensingPluginSetup { * @deprecated in favour of the counterpart provided from start contract */ refresh(): Promise; + /** + * APIs to register licensed feature usage. + */ + featureUsage: FeatureUsageServiceSetup; } /** @public */ @@ -31,4 +36,8 @@ export interface LicensingPluginStart { * Triggers licensing information re-fetch. */ refresh(): Promise; + /** + * APIs to manage licensed feature usage. + */ + featureUsage: FeatureUsageServiceStart; } diff --git a/x-pack/plugins/licensing/server/plugin.ts b/x-pack/plugins/licensing/server/plugin.ts index 6cdba0ac46644..2ee8d26419571 100644 --- a/x-pack/plugins/licensing/server/plugin.ts +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -133,7 +133,9 @@ export class LicensingPlugin implements Plugin ) { registerInfoRoute(router); registerFeatureUsageRoute(router, getStartServices); + registerRegisterFeatureRoute(router, featureUsageSetup); + registerNotifyFeatureUsageRoute(router); } diff --git a/x-pack/plugins/licensing/server/routes/internal/index.ts b/x-pack/plugins/licensing/server/routes/internal/index.ts new file mode 100644 index 0000000000000..a3b06c223fc12 --- /dev/null +++ b/x-pack/plugins/licensing/server/routes/internal/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { registerNotifyFeatureUsageRoute } from './notify_feature_usage'; +export { registerRegisterFeatureRoute } from './register_feature'; diff --git a/x-pack/plugins/licensing/server/routes/internal/notify_feature_usage.ts b/x-pack/plugins/licensing/server/routes/internal/notify_feature_usage.ts new file mode 100644 index 0000000000000..ec70472574be3 --- /dev/null +++ b/x-pack/plugins/licensing/server/routes/internal/notify_feature_usage.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; + +export function registerNotifyFeatureUsageRoute(router: IRouter) { + router.post( + { + path: '/internal/licensing/feature_usage/notify', + validate: { + body: schema.object({ + featureName: schema.string(), + lastUsed: schema.number(), + }), + }, + }, + async (context, request, response) => { + const { featureName, lastUsed } = request.body; + + context.licensing.featureUsage.notifyUsage(featureName, lastUsed); + + return response.ok({ + body: { + success: true, + }, + }); + } + ); +} diff --git a/x-pack/plugins/licensing/server/routes/internal/register_feature.ts b/x-pack/plugins/licensing/server/routes/internal/register_feature.ts new file mode 100644 index 0000000000000..14f7952f86f5a --- /dev/null +++ b/x-pack/plugins/licensing/server/routes/internal/register_feature.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { LicenseType, LICENSE_TYPE } from '../../../common/types'; +import { FeatureUsageServiceSetup } from '../../services'; + +export function registerRegisterFeatureRoute( + router: IRouter, + featureUsageSetup: FeatureUsageServiceSetup +) { + router.post( + { + path: '/internal/licensing/feature_usage/register', + validate: { + body: schema.object({ + featureName: schema.string(), + licenseType: schema.string({ + validate: (value) => { + if (!(value in LICENSE_TYPE)) { + return `Invalid license type: ${value}`; + } + }, + }), + }), + }, + }, + async (context, request, response) => { + const { featureName, licenseType } = request.body; + + featureUsageSetup.register(featureName, licenseType as LicenseType); + + return response.ok({ + body: { + success: true, + }, + }); + } + ); +} diff --git a/x-pack/test/licensing_plugin/public/feature_usage.ts b/x-pack/test/licensing_plugin/public/feature_usage.ts new file mode 100644 index 0000000000000..15d302d71bfab --- /dev/null +++ b/x-pack/test/licensing_plugin/public/feature_usage.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../services'; +import { + LicensingPluginSetup, + LicensingPluginStart, + LicenseType, +} from '../../../plugins/licensing/public'; +import '../../../../test/plugin_functional/plugins/core_provider_plugin/types'; + +interface FeatureUsage { + last_used?: number; + license_level: LicenseType; + name: string; +} + +// eslint-disable-next-line import/no-default-export +export default function (ftrContext: FtrProviderContext) { + const { getService, getPageObjects } = ftrContext; + const supertest = getService('supertest'); + const browser = getService('browser'); + const PageObjects = getPageObjects(['common', 'security']); + + const registerFeature = async (featureName: string, licenseType: LicenseType) => { + await browser.executeAsync( + async (feature, type, cb) => { + const { setup } = window._coreProvider; + const licensing: LicensingPluginSetup = setup.plugins.licensing; + await licensing.featureUsage.register(feature, type); + cb(); + }, + featureName, + licenseType + ); + }; + + const notifyFeatureUsage = async (featureName: string, lastUsed: number) => { + await browser.executeAsync( + async (feature, time, cb) => { + const { start } = window._coreProvider; + const licensing: LicensingPluginStart = start.plugins.licensing; + await licensing.featureUsage.notifyUsage(feature, time); + cb(); + }, + featureName, + lastUsed + ); + }; + + describe('feature_usage API', () => { + before(async () => { + await PageObjects.security.login(); + }); + + it('allows to register features to the server', async () => { + await registerFeature('test-client-A', 'gold'); + await registerFeature('test-client-B', 'enterprise'); + + const response = await supertest.get('/api/licensing/feature_usage').expect(200); + const features = response.body.features.map(({ name }: FeatureUsage) => name); + + expect(features).to.contain('test-client-A'); + expect(features).to.contain('test-client-B'); + }); + + it('allows to notify feature usage', async () => { + const now = new Date(); + + await notifyFeatureUsage('test-client-A', now.getTime()); + + const response = await supertest.get('/api/licensing/feature_usage').expect(200); + const features = response.body.features as FeatureUsage[]; + + expect(features.find((f) => f.name === 'test-client-A')?.last_used).to.be(now.toISOString()); + expect(features.find((f) => f.name === 'test-client-B')?.last_used).to.be(null); + }); + }); +} diff --git a/x-pack/test/licensing_plugin/public/index.ts b/x-pack/test/licensing_plugin/public/index.ts index 86a3c21cfdb39..268a74c56bd72 100644 --- a/x-pack/test/licensing_plugin/public/index.ts +++ b/x-pack/test/licensing_plugin/public/index.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../services'; export default function ({ loadTestFile }: FtrProviderContext) { describe('Licensing plugin public client', function () { this.tags('ciGroup2'); + loadTestFile(require.resolve('./feature_usage')); // MUST BE LAST! CHANGES LICENSE TYPE! loadTestFile(require.resolve('./updates')); }); From 9ddd49a9f0d080dda5161eab0dd4f48d7baa9ebd Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 31 Aug 2020 12:40:44 +0300 Subject: [PATCH 04/72] Hides advanced json for count metric (#74636) * remove advanced json for count agg * Remove only advanced json from count agg * use Constant from data plugin * add the logic to data plugin * remove json arg from function definition * remove unecessary translations Co-authored-by: Elastic Machine --- .../data/common/search/aggs/agg_type.test.ts | 11 +++++++++++ src/plugins/data/common/search/aggs/agg_type.ts | 17 +++++++++++------ .../data/common/search/aggs/metrics/count.ts | 1 + .../common/search/aggs/metrics/count_fn.test.ts | 14 -------------- .../data/common/search/aggs/metrics/count_fn.ts | 12 +----------- .../public/components/agg_params_helper.ts | 2 +- .../apps/visualize/_point_series_options.js | 5 +++++ .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 9 files changed, 30 insertions(+), 34 deletions(-) diff --git a/src/plugins/data/common/search/aggs/agg_type.test.ts b/src/plugins/data/common/search/aggs/agg_type.test.ts index 2fcc6b97b1cc6..bf1136159dfe8 100644 --- a/src/plugins/data/common/search/aggs/agg_type.test.ts +++ b/src/plugins/data/common/search/aggs/agg_type.test.ts @@ -99,6 +99,17 @@ describe('AggType Class', () => { expect(aggType.params[1].name).toBe('customLabel'); }); + test('disables json param', () => { + const aggType = new AggType({ + name: 'name', + title: 'title', + json: false, + }); + + expect(aggType.params.length).toBe(1); + expect(aggType.params[0].name).toBe('customLabel'); + }); + test('can disable customLabel', () => { const aggType = new AggType({ name: 'smart agg', diff --git a/src/plugins/data/common/search/aggs/agg_type.ts b/src/plugins/data/common/search/aggs/agg_type.ts index 0ba2bb66e7758..2ee604c1bf25d 100644 --- a/src/plugins/data/common/search/aggs/agg_type.ts +++ b/src/plugins/data/common/search/aggs/agg_type.ts @@ -47,6 +47,7 @@ export interface AggTypeConfig< getRequestAggs?: ((aggConfig: TAggConfig) => TAggConfig[]) | (() => TAggConfig[] | void); getResponseAggs?: ((aggConfig: TAggConfig) => TAggConfig[]) | (() => TAggConfig[] | void); customLabels?: boolean; + json?: boolean; decorateAggConfig?: () => any; postFlightRequest?: ( resp: any, @@ -235,13 +236,17 @@ export class AggType< if (config.params && config.params.length && config.params[0] instanceof BaseParamType) { this.params = config.params as TParam[]; } else { - // always append the raw JSON param + // always append the raw JSON param unless it is configured to false const params: any[] = config.params ? [...config.params] : []; - params.push({ - name: 'json', - type: 'json', - advanced: true, - }); + + if (config.json !== false) { + params.push({ + name: 'json', + type: 'json', + advanced: true, + }); + } + // always append custom label if (config.customLabels !== false) { diff --git a/src/plugins/data/common/search/aggs/metrics/count.ts b/src/plugins/data/common/search/aggs/metrics/count.ts index d990599586e81..9c9f36651f4d2 100644 --- a/src/plugins/data/common/search/aggs/metrics/count.ts +++ b/src/plugins/data/common/search/aggs/metrics/count.ts @@ -28,6 +28,7 @@ export const getCountMetricAgg = () => defaultMessage: 'Count', }), hasNoDsl: true, + json: false, makeLabel() { return i18n.translate('data.search.aggs.metrics.countLabel', { defaultMessage: 'Count', diff --git a/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts index 846feb9296fca..32189f07581e6 100644 --- a/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts @@ -34,7 +34,6 @@ describe('agg_expression_functions', () => { "id": undefined, "params": Object { "customLabel": undefined, - "json": undefined, }, "schema": undefined, "type": "count", @@ -42,18 +41,5 @@ describe('agg_expression_functions', () => { } `); }); - - test('correctly parses json string argument', () => { - const actual = fn({ - json: '{ "foo": true }', - }); - - expect(actual.value.params.json).toEqual({ foo: true }); - expect(() => { - fn({ - json: '/// intentionally malformed json ///', - }); - }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); - }); }); }); diff --git a/src/plugins/data/common/search/aggs/metrics/count_fn.ts b/src/plugins/data/common/search/aggs/metrics/count_fn.ts index 338ca18209299..7d4616ffdc619 100644 --- a/src/plugins/data/common/search/aggs/metrics/count_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/count_fn.ts @@ -20,7 +20,6 @@ import { i18n } from '@kbn/i18n'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; -import { getParsedValue } from '../utils/get_parsed_value'; const fnName = 'aggCount'; @@ -55,12 +54,6 @@ export const aggCount = (): FunctionDefinition => ({ defaultMessage: 'Schema to use for this aggregation', }), }, - json: { - types: ['string'], - help: i18n.translate('data.search.aggs.metrics.count.json.help', { - defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', - }), - }, customLabel: { types: ['string'], help: i18n.translate('data.search.aggs.metrics.count.customLabel.help', { @@ -78,10 +71,7 @@ export const aggCount = (): FunctionDefinition => ({ enabled, schema, type: METRIC_TYPES.COUNT, - params: { - ...rest, - json: getParsedValue(args, 'json'), - }, + params: rest, }, }; }, diff --git a/src/plugins/vis_default_editor/public/components/agg_params_helper.ts b/src/plugins/vis_default_editor/public/components/agg_params_helper.ts index ef2f937c8547c..b13ca32601aa9 100644 --- a/src/plugins/vis_default_editor/public/components/agg_params_helper.ts +++ b/src/plugins/vis_default_editor/public/components/agg_params_helper.ts @@ -26,7 +26,7 @@ import { IAggType, IndexPattern, IndexPatternField, -} from 'src/plugins/data/public'; +} from '../../../data/public'; import { filterAggTypes, filterAggTypeFields } from '../agg_filters'; import { groupAndSortBy, ComboBoxGroupedOptions } from '../utils'; import { AggTypeState, AggParamsState } from './agg_params_state'; diff --git a/test/functional/apps/visualize/_point_series_options.js b/test/functional/apps/visualize/_point_series_options.js index d08bfe3b90913..c88670ee8b741 100644 --- a/test/functional/apps/visualize/_point_series_options.js +++ b/test/functional/apps/visualize/_point_series_options.js @@ -24,6 +24,7 @@ export default function ({ getService, getPageObjects }) { const retry = getService('retry'); const kibanaServer = getService('kibanaServer'); const browser = getService('browser'); + const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects([ 'visualize', 'header', @@ -148,6 +149,10 @@ export default function ({ getService, getPageObjects }) { }); }); + it('should not show advanced json for count agg', async function () { + await testSubjects.missingOrFail('advancedParams-1'); + }); + it('should put secondary axis on the right', async function () { const length = await PageObjects.visChart.getRightValueAxes(); expect(length).to.be(1); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index dc07044ce8ed7..07b646df74b9f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1115,7 +1115,6 @@ "data.search.aggs.metrics.count.customLabel.help": "このアグリゲーションのカスタムラベルを表します", "data.search.aggs.metrics.count.enabled.help": "このアグリゲーションが有効かどうかを指定します", "data.search.aggs.metrics.count.id.help": "このアグリゲーションのID", - "data.search.aggs.metrics.count.json.help": "アグリゲーションがElasticsearchに送信されるときに含める高度なJSON", "data.search.aggs.metrics.count.schema.help": "このアグリゲーションで使用するスキーマ", "data.search.aggs.metrics.countLabel": "カウント", "data.search.aggs.metrics.countTitle": "カウント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 144bc1cac1852..ffd7d0cfb0f87 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1116,7 +1116,6 @@ "data.search.aggs.metrics.count.customLabel.help": "表示此聚合的定制标签", "data.search.aggs.metrics.count.enabled.help": "指定是否启用此聚合", "data.search.aggs.metrics.count.id.help": "此聚合的 ID", - "data.search.aggs.metrics.count.json.help": "聚合发送至 Elasticsearch 时要包括的高级 json", "data.search.aggs.metrics.count.schema.help": "要用于此聚合的方案", "data.search.aggs.metrics.countLabel": "计数", "data.search.aggs.metrics.countTitle": "计数", From aac57fb1a8494d386b37bdfe21ada3cd9c8d7494 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 31 Aug 2020 11:45:57 +0200 Subject: [PATCH 05/72] [ILM] Add support for frozen phase in UI (#75968) * ILM public side changes to support frozen phase This is a very faithful duplication of the cold phase. We are also excluding the snapshot action as well as the unfollow action as these are features that require higher than basic license privilege. * added "frozen" to the server side schema * add frozen phases component * fix i18n and update jest tests * Slight restructuring to phase types and fix copy paste issues. - also fixed TS error introduced after TS v4 upgrade (delete) * fix hot phase type and remove type constraint from error messages * update validation logic Co-authored-by: Elastic Machine --- .../extend_index_management.test.js.snap | 4 + .../public/application/constants/policy.ts | 11 + .../edit_policy/components/min_age_input.tsx | 11 +- .../components/node_allocation.tsx | 6 +- .../components/set_priority_input.tsx | 6 +- .../sections/edit_policy/edit_policy.tsx | 16 +- .../edit_policy/phases/cold_phase.tsx | 7 +- .../edit_policy/phases/delete_phase.tsx | 7 +- .../edit_policy/phases/frozen_phase.tsx | 210 ++++++++++++++++++ .../sections/edit_policy/phases/hot_phase.tsx | 7 +- .../sections/edit_policy/phases/index.ts | 1 + .../edit_policy/phases/warm_phase.tsx | 7 +- .../services/policies/cold_phase.ts | 4 +- .../services/policies/frozen_phase.ts | 161 ++++++++++++++ .../services/policies/policy_serialization.ts | 11 + .../services/policies/policy_validation.ts | 18 +- .../application/services/policies/types.ts | 66 ++++-- .../public/extend_index_management/index.js | 6 + .../api/policies/register_create_route.ts | 18 ++ 19 files changed, 527 insertions(+), 50 deletions(-) create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/policies/frozen_phase.ts diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.js.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.js.snap index 38dd49a286b58..39eb54b941ac4 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.js.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.js.snap @@ -76,6 +76,10 @@ Array [ "value": "warm", "view": "Warm", }, + Object { + "value": "frozen", + "view": "Frozen", + }, Object { "value": "cold", "view": "Cold", diff --git a/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts index 3a19f03547b5b..fb626e7d7fe76 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts @@ -10,6 +10,7 @@ import { DeletePhase, HotPhase, WarmPhase, + FrozenPhase, } from '../services/policies/types'; export const defaultNewHotPhase: HotPhase = { @@ -47,6 +48,16 @@ export const defaultNewColdPhase: ColdPhase = { phaseIndexPriority: '0', }; +export const defaultNewFrozenPhase: FrozenPhase = { + phaseEnabled: false, + selectedMinimumAge: '0', + selectedMinimumAgeUnits: 'd', + selectedNodeAttrs: '', + selectedReplicaCount: '', + freezeEnabled: false, + phaseIndexPriority: '0', +}; + export const defaultNewDeletePhase: DeletePhase = { phaseEnabled: false, selectedMinimumAge: '0', diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx index 11b743ecc4bb6..5128ba1c881a0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx @@ -12,7 +12,7 @@ import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from import { LearnMoreLink } from './learn_more_link'; import { ErrableFormRow } from './form_errors'; import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; -import { ColdPhase, DeletePhase, Phase, Phases, WarmPhase } from '../../../services/policies/types'; +import { PhaseWithMinAge, Phases } from '../../../services/policies/types'; function getTimingLabelForPhase(phase: keyof Phases) { // NOTE: Hot phase isn't necessary, because indices begin in the hot phase. @@ -27,6 +27,11 @@ function getTimingLabelForPhase(phase: keyof Phases) { defaultMessage: 'Timing for cold phase', }); + case 'frozen': + return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseFrozen.minimumAgeLabel', { + defaultMessage: 'Timing for frozen phase', + }); + case 'delete': return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeLabel', { defaultMessage: 'Timing for delete phase', @@ -63,7 +68,7 @@ function getUnitsAriaLabelForPhase(phase: keyof Phases) { } } -interface Props { +interface Props { rolloverEnabled: boolean; errors?: PhaseValidationErrors; phase: keyof Phases & string; @@ -72,7 +77,7 @@ interface Props { isShowingErrors: boolean; } -export const MinAgeInput = ({ +export const MinAgeInput = ({ rolloverEnabled, errors, phaseData, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx index 0ce2c0d7ea566..b4ff62bfb03dc 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx @@ -20,7 +20,7 @@ import { LearnMoreLink } from './learn_more_link'; import { ErrableFormRow } from './form_errors'; import { useLoadNodes } from '../../../services/api'; import { NodeAttrsDetails } from './node_attrs_details'; -import { ColdPhase, Phase, Phases, WarmPhase } from '../../../services/policies/types'; +import { PhaseWithAllocationAction, Phases } from '../../../services/policies/types'; import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; const learnMoreLink = ( @@ -38,14 +38,14 @@ const learnMoreLink = ( ); -interface Props { +interface Props { phase: keyof Phases & string; errors?: PhaseValidationErrors; phaseData: T; setPhaseData: (dataKey: keyof T & string, value: string) => void; isShowingErrors: boolean; } -export const NodeAllocation = ({ +export const NodeAllocation = ({ phase, setPhaseData, errors, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/set_priority_input.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/set_priority_input.tsx index 1da7508049f24..1505532a2b16e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/set_priority_input.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/set_priority_input.tsx @@ -10,17 +10,17 @@ import { EuiFieldNumber, EuiTextColor, EuiDescribedFormGroup } from '@elastic/eu import { LearnMoreLink } from './'; import { OptionalLabel } from './'; import { ErrableFormRow } from './'; -import { ColdPhase, HotPhase, Phase, Phases, WarmPhase } from '../../../services/policies/types'; +import { PhaseWithIndexPriority, Phases } from '../../../services/policies/types'; import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; -interface Props { +interface Props { errors?: PhaseValidationErrors; phase: keyof Phases & string; phaseData: T; setPhaseData: (dataKey: keyof T & string, value: any) => void; isShowingErrors: boolean; } -export const SetPriorityInput = ({ +export const SetPriorityInput = ({ errors, phaseData, phase, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index c99d01b546679..db58c64a8ae8c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -28,7 +28,7 @@ import { import { toasts } from '../../services/notification'; -import { Policy, PolicyFromES } from '../../services/policies/types'; +import { Phases, Policy, PolicyFromES } from '../../services/policies/types'; import { validatePolicy, ValidationErrors, @@ -42,7 +42,7 @@ import { } from '../../services/policies/policy_serialization'; import { ErrableFormRow, LearnMoreLink, PolicyJsonFlyout } from './components'; -import { ColdPhase, DeletePhase, HotPhase, WarmPhase } from './phases'; +import { ColdPhase, DeletePhase, FrozenPhase, HotPhase, WarmPhase } from './phases'; interface Props { policies: PolicyFromES[]; @@ -118,7 +118,7 @@ export const EditPolicy: React.FunctionComponent = ({ setIsShowingPolicyJsonFlyout(!isShowingPolicyJsonFlyout); }; - const setPhaseData = (phase: 'hot' | 'warm' | 'cold' | 'delete', key: string, value: any) => { + const setPhaseData = (phase: keyof Phases, key: string, value: any) => { setPolicy({ ...policy, phases: { @@ -303,6 +303,16 @@ export const EditPolicy: React.FunctionComponent = ({ + 0} + setPhaseData={(key, value) => setPhaseData('frozen', key, value)} + phaseData={policy.phases.frozen} + hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} + /> + + + 0} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx index fb32752fe24ea..9df6da7a88b2f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx @@ -19,7 +19,7 @@ import { } from '@elastic/eui'; import { ColdPhase as ColdPhaseInterface, Phases } from '../../../services/policies/types'; -import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; +import { PhaseValidationErrors } from '../../../services/policies/policy_validation'; import { LearnMoreLink, @@ -36,9 +36,8 @@ const freezeLabel = i18n.translate('xpack.indexLifecycleMgmt.coldPhase.freezeInd defaultMessage: 'Freeze index', }); -const coldProperty = propertyof('cold'); -const phaseProperty = (propertyName: keyof ColdPhaseInterface) => - propertyof(propertyName); +const coldProperty: keyof Phases = 'cold'; +const phaseProperty = (propertyName: keyof ColdPhaseInterface) => propertyName; interface Props { setPhaseData: (key: keyof ColdPhaseInterface & string, value: string | boolean) => void; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/delete_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/delete_phase.tsx index d3c73090f25f2..eab93777a72bd 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/delete_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/delete_phase.tsx @@ -9,7 +9,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiDescribedFormGroup, EuiSwitch, EuiTextColor, EuiFormRow } from '@elastic/eui'; import { DeletePhase as DeletePhaseInterface, Phases } from '../../../services/policies/types'; -import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; +import { PhaseValidationErrors } from '../../../services/policies/policy_validation'; import { ActiveBadge, @@ -20,9 +20,8 @@ import { SnapshotPolicies, } from '../components'; -const deleteProperty = propertyof('delete'); -const phaseProperty = (propertyName: keyof DeletePhaseInterface) => - propertyof(propertyName); +const deleteProperty: keyof Phases = 'delete'; +const phaseProperty = (propertyName: keyof DeletePhaseInterface) => propertyName; interface Props { setPhaseData: (key: keyof DeletePhaseInterface & string, value: string | boolean) => void; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx new file mode 100644 index 0000000000000..782906a56a9ba --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx @@ -0,0 +1,210 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { PureComponent, Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiFieldNumber, + EuiDescribedFormGroup, + EuiSwitch, + EuiTextColor, +} from '@elastic/eui'; + +import { FrozenPhase as FrozenPhaseInterface, Phases } from '../../../services/policies/types'; +import { PhaseValidationErrors } from '../../../services/policies/policy_validation'; + +import { + LearnMoreLink, + ActiveBadge, + PhaseErrorMessage, + OptionalLabel, + ErrableFormRow, + MinAgeInput, + NodeAllocation, + SetPriorityInput, +} from '../components'; + +const freezeLabel = i18n.translate('xpack.indexLifecycleMgmt.frozenPhase.freezeIndexLabel', { + defaultMessage: 'Freeze index', +}); + +const frozenProperty: keyof Phases = 'frozen'; +const phaseProperty = (propertyName: keyof FrozenPhaseInterface) => propertyName; + +interface Props { + setPhaseData: (key: keyof FrozenPhaseInterface & string, value: string | boolean) => void; + phaseData: FrozenPhaseInterface; + isShowingErrors: boolean; + errors?: PhaseValidationErrors; + hotPhaseRolloverEnabled: boolean; +} +export class FrozenPhase extends PureComponent { + render() { + const { + setPhaseData, + phaseData, + errors, + isShowingErrors, + hotPhaseRolloverEnabled, + } = this.props; + + return ( +
+ +

+ +

{' '} + {phaseData.phaseEnabled && !isShowingErrors ? : null} + +
+ } + titleSize="s" + description={ + +

+ +

+ + } + id={`${frozenProperty}-${phaseProperty('phaseEnabled')}`} + checked={phaseData.phaseEnabled} + onChange={(e) => { + setPhaseData(phaseProperty('phaseEnabled'), e.target.checked); + }} + aria-controls="frozenPhaseContent" + /> +
+ } + fullWidth + > + + {phaseData.phaseEnabled ? ( + + + errors={errors} + phaseData={phaseData} + phase={frozenProperty} + isShowingErrors={isShowingErrors} + setPhaseData={setPhaseData} + rolloverEnabled={hotPhaseRolloverEnabled} + /> + + + + phase={frozenProperty} + setPhaseData={setPhaseData} + errors={errors} + phaseData={phaseData} + isShowingErrors={isShowingErrors} + /> + + + + + + + + } + isShowingErrors={isShowingErrors} + errors={errors?.freezeEnabled} + helpText={i18n.translate( + 'xpack.indexLifecycleMgmt.frozenPhase.replicaCountHelpText', + { + defaultMessage: 'By default, the number of replicas remains the same.', + } + )} + > + { + setPhaseData(phaseProperty('selectedReplicaCount'), e.target.value); + }} + min={0} + /> + + + + + ) : ( +
+ )} + + + {phaseData.phaseEnabled ? ( + + + + + } + description={ + + {' '} + + + } + fullWidth + titleSize="xs" + > + { + setPhaseData(phaseProperty('freezeEnabled'), e.target.checked); + }} + label={freezeLabel} + aria-label={freezeLabel} + /> + + + errors={errors} + phaseData={phaseData} + phase={frozenProperty} + isShowingErrors={isShowingErrors} + setPhaseData={setPhaseData} + /> + + ) : null} +
+ ); + } +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx index 22f0114d16afe..106e3b9139a9b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx @@ -19,7 +19,7 @@ import { } from '@elastic/eui'; import { HotPhase as HotPhaseInterface, Phases } from '../../../services/policies/types'; -import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; +import { PhaseValidationErrors } from '../../../services/policies/policy_validation'; import { LearnMoreLink, @@ -112,9 +112,8 @@ const maxAgeUnits = [ }), }, ]; -const hotProperty = propertyof('hot'); -const phaseProperty = (propertyName: keyof HotPhaseInterface) => - propertyof(propertyName); +const hotProperty: keyof Phases = 'hot'; +const phaseProperty = (propertyName: keyof HotPhaseInterface) => propertyName; interface Props { errors?: PhaseValidationErrors; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/index.ts index 8d1ace5950497..d59f2ff6413fd 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/index.ts @@ -7,4 +7,5 @@ export { HotPhase } from './hot_phase'; export { WarmPhase } from './warm_phase'; export { ColdPhase } from './cold_phase'; +export { FrozenPhase } from './frozen_phase'; export { DeletePhase } from './delete_phase'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx index f7b8c60a5c71f..2733d01ac222d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx @@ -30,7 +30,7 @@ import { } from '../components'; import { Phases, WarmPhase as WarmPhaseInterface } from '../../../services/policies/types'; -import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; +import { PhaseValidationErrors } from '../../../services/policies/policy_validation'; const shrinkLabel = i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { defaultMessage: 'Shrink index', @@ -47,9 +47,8 @@ const forcemergeLabel = i18n.translate('xpack.indexLifecycleMgmt.warmPhase.force defaultMessage: 'Force merge data', }); -const warmProperty = propertyof('warm'); -const phaseProperty = (propertyName: keyof WarmPhaseInterface) => - propertyof(propertyName); +const warmProperty: keyof Phases = 'warm'; +const phaseProperty = (propertyName: keyof WarmPhaseInterface) => propertyName; interface Props { setPhaseData: (key: keyof WarmPhaseInterface & string, value: boolean | string) => void; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts index 6cc43042ed4ff..7fa82a004b872 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts @@ -152,9 +152,9 @@ export const validateColdPhase = (phase: ColdPhase): PhaseValidationErrors { + const phase = { ...frozenPhaseInitialization }; + if (phaseSerialized === undefined || phaseSerialized === null) { + return phase; + } + + phase.phaseEnabled = true; + + if (phaseSerialized.min_age) { + const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); + phase.selectedMinimumAge = minAge; + phase.selectedMinimumAgeUnits = minAgeUnits; + } + + if (phaseSerialized.actions) { + const actions = phaseSerialized.actions; + if (actions.allocate) { + const allocate = actions.allocate; + if (allocate.require) { + Object.entries(allocate.require).forEach((entry) => { + phase.selectedNodeAttrs = entry.join(':'); + }); + if (allocate.number_of_replicas) { + phase.selectedReplicaCount = allocate.number_of_replicas.toString(); + } + } + } + + if (actions.freeze) { + phase.freezeEnabled = true; + } + + if (actions.set_priority) { + phase.phaseIndexPriority = actions.set_priority.priority + ? actions.set_priority.priority.toString() + : ''; + } + } + + return phase; +}; + +export const frozenPhaseToES = ( + phase: FrozenPhase, + originalPhase?: SerializedFrozenPhase +): SerializedFrozenPhase => { + if (!originalPhase) { + originalPhase = { ...serializedPhaseInitialization }; + } + + const esPhase = { ...originalPhase }; + + if (isNumber(phase.selectedMinimumAge)) { + esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; + } + + esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; + + if (phase.selectedNodeAttrs) { + const [name, value] = phase.selectedNodeAttrs.split(':'); + esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); + esPhase.actions.allocate.require = { + [name]: value, + }; + } else { + if (esPhase.actions.allocate) { + // @ts-expect-error + delete esPhase.actions.allocate.require; + } + } + + if (isNumber(phase.selectedReplicaCount)) { + esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); + esPhase.actions.allocate.number_of_replicas = parseInt(phase.selectedReplicaCount, 10); + } else { + if (esPhase.actions.allocate) { + // @ts-expect-error + delete esPhase.actions.allocate.number_of_replicas; + } + } + + if ( + esPhase.actions.allocate && + !esPhase.actions.allocate.require && + !isNumber(esPhase.actions.allocate.number_of_replicas) && + isEmpty(esPhase.actions.allocate.include) && + isEmpty(esPhase.actions.allocate.exclude) + ) { + // remove allocate action if it does not define require or number of nodes + // and both include and exclude are empty objects (ES will fail to parse if we don't) + delete esPhase.actions.allocate; + } + + if (phase.freezeEnabled) { + esPhase.actions.freeze = {}; + } else { + delete esPhase.actions.freeze; + } + + if (isNumber(phase.phaseIndexPriority)) { + esPhase.actions.set_priority = { + priority: parseInt(phase.phaseIndexPriority, 10), + }; + } else { + delete esPhase.actions.set_priority; + } + + return esPhase; +}; + +export const validateFrozenPhase = (phase: FrozenPhase): PhaseValidationErrors => { + if (!phase.phaseEnabled) { + return {}; + } + + const phaseErrors = {} as PhaseValidationErrors; + + // index priority is optional, but if it's set, it needs to be a positive number + if (phase.phaseIndexPriority) { + if (!isNumber(phase.phaseIndexPriority)) { + phaseErrors.phaseIndexPriority = [numberRequiredMessage]; + } else if (parseInt(phase.phaseIndexPriority, 10) < 0) { + phaseErrors.phaseIndexPriority = [positiveNumberRequiredMessage]; + } + } + + // min age needs to be a positive number + if (!isNumber(phase.selectedMinimumAge)) { + phaseErrors.selectedMinimumAge = [numberRequiredMessage]; + } else if (parseInt(phase.selectedMinimumAge, 10) < 0) { + phaseErrors.selectedMinimumAge = [positiveNumberRequiredMessage]; + } + + return { ...phaseErrors }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts index 3953521df1817..807a6fe8ec395 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts @@ -9,6 +9,7 @@ import { defaultNewDeletePhase, defaultNewHotPhase, defaultNewWarmPhase, + defaultNewFrozenPhase, serializedPhaseInitialization, } from '../../constants'; @@ -17,6 +18,7 @@ import { Policy, PolicyFromES, SerializedPolicy } from './types'; import { hotPhaseFromES, hotPhaseToES } from './hot_phase'; import { warmPhaseFromES, warmPhaseToES } from './warm_phase'; import { coldPhaseFromES, coldPhaseToES } from './cold_phase'; +import { frozenPhaseFromES, frozenPhaseToES } from './frozen_phase'; import { deletePhaseFromES, deletePhaseToES } from './delete_phase'; export const splitSizeAndUnits = (field: string): { size: string; units: string } => { @@ -53,6 +55,7 @@ export const initializeNewPolicy = (newPolicyName: string = ''): Policy => { hot: { ...defaultNewHotPhase }, warm: { ...defaultNewWarmPhase }, cold: { ...defaultNewColdPhase }, + frozen: { ...defaultNewFrozenPhase }, delete: { ...defaultNewDeletePhase }, }, }; @@ -70,6 +73,7 @@ export const deserializePolicy = (policy: PolicyFromES): Policy => { hot: hotPhaseFromES(phases.hot), warm: warmPhaseFromES(phases.warm), cold: coldPhaseFromES(phases.cold), + frozen: frozenPhaseFromES(phases.frozen), delete: deletePhaseFromES(phases.delete), }, }; @@ -94,6 +98,13 @@ export const serializePolicy = ( serializedPolicy.phases.cold = coldPhaseToES(policy.phases.cold, originalEsPolicy.phases.cold); } + if (policy.phases.frozen.phaseEnabled) { + serializedPolicy.phases.frozen = frozenPhaseToES( + policy.phases.frozen, + originalEsPolicy.phases.frozen + ); + } + if (policy.phases.delete.phaseEnabled) { serializedPolicy.phases.delete = deletePhaseToES( policy.phases.delete, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts index 545488be2cd5e..6fdbc4babd3f3 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts @@ -9,7 +9,17 @@ import { validateHotPhase } from './hot_phase'; import { validateWarmPhase } from './warm_phase'; import { validateColdPhase } from './cold_phase'; import { validateDeletePhase } from './delete_phase'; -import { ColdPhase, DeletePhase, HotPhase, Phase, Policy, PolicyFromES, WarmPhase } from './types'; +import { validateFrozenPhase } from './frozen_phase'; + +import { + ColdPhase, + DeletePhase, + FrozenPhase, + HotPhase, + Policy, + PolicyFromES, + WarmPhase, +} from './types'; export const propertyof = (propertyName: keyof T & string) => propertyName; @@ -100,7 +110,7 @@ export const policyNameAlreadyUsedErrorMessage = i18n.translate( defaultMessage: 'That policy name is already used.', } ); -export type PhaseValidationErrors = { +export type PhaseValidationErrors = { [P in keyof Partial]: string[]; }; @@ -108,6 +118,7 @@ export interface ValidationErrors { hot: PhaseValidationErrors; warm: PhaseValidationErrors; cold: PhaseValidationErrors; + frozen: PhaseValidationErrors; delete: PhaseValidationErrors; policyName: string[]; } @@ -148,12 +159,14 @@ export const validatePolicy = ( const hotPhaseErrors = validateHotPhase(policy.phases.hot); const warmPhaseErrors = validateWarmPhase(policy.phases.warm); const coldPhaseErrors = validateColdPhase(policy.phases.cold); + const frozenPhaseErrors = validateFrozenPhase(policy.phases.frozen); const deletePhaseErrors = validateDeletePhase(policy.phases.delete); const isValid = policyNameErrors.length === 0 && Object.keys(hotPhaseErrors).length === 0 && Object.keys(warmPhaseErrors).length === 0 && Object.keys(coldPhaseErrors).length === 0 && + Object.keys(frozenPhaseErrors).length === 0 && Object.keys(deletePhaseErrors).length === 0; return [ isValid, @@ -162,6 +175,7 @@ export const validatePolicy = ( hot: hotPhaseErrors, warm: warmPhaseErrors, cold: coldPhaseErrors, + frozen: frozenPhaseErrors, delete: deletePhaseErrors, }, ]; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts index 2e2ed5b38bb87..3d4c73cf4a82c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts @@ -13,6 +13,7 @@ export interface Phases { hot?: SerializedHotPhase; warm?: SerializedWarmPhase; cold?: SerializedColdPhase; + frozen?: SerializedFrozenPhase; delete?: SerializedDeletePhase; } @@ -68,6 +69,16 @@ export interface SerializedColdPhase extends SerializedPhase { }; } +export interface SerializedFrozenPhase extends SerializedPhase { + actions: { + freeze?: {}; + allocate?: AllocateAction; + set_priority?: { + priority: number | null; + }; + }; +} + export interface SerializedDeletePhase extends SerializedPhase { actions: { wait_for_snapshot?: { @@ -94,47 +105,66 @@ export interface Policy { hot: HotPhase; warm: WarmPhase; cold: ColdPhase; + frozen: FrozenPhase; delete: DeletePhase; }; } -export interface Phase { +export interface CommonPhaseSettings { phaseEnabled: boolean; } -export interface HotPhase extends Phase { + +export interface PhaseWithMinAge { + selectedMinimumAge: string; + selectedMinimumAgeUnits: string; +} + +export interface PhaseWithAllocationAction { + selectedNodeAttrs: string; + selectedReplicaCount: string; +} + +export interface PhaseWithIndexPriority { + phaseIndexPriority: string; +} + +export interface HotPhase extends CommonPhaseSettings, PhaseWithIndexPriority { rolloverEnabled: boolean; selectedMaxSizeStored: string; selectedMaxSizeStoredUnits: string; selectedMaxDocuments: string; selectedMaxAge: string; selectedMaxAgeUnits: string; - phaseIndexPriority: string; } -export interface WarmPhase extends Phase { +export interface WarmPhase + extends CommonPhaseSettings, + PhaseWithMinAge, + PhaseWithAllocationAction, + PhaseWithIndexPriority { warmPhaseOnRollover: boolean; - selectedMinimumAge: string; - selectedMinimumAgeUnits: string; - selectedNodeAttrs: string; - selectedReplicaCount: string; shrinkEnabled: boolean; selectedPrimaryShardCount: string; forceMergeEnabled: boolean; selectedForceMergeSegments: string; - phaseIndexPriority: string; } -export interface ColdPhase extends Phase { - selectedMinimumAge: string; - selectedMinimumAgeUnits: string; - selectedNodeAttrs: string; - selectedReplicaCount: string; +export interface ColdPhase + extends CommonPhaseSettings, + PhaseWithMinAge, + PhaseWithAllocationAction, + PhaseWithIndexPriority { freezeEnabled: boolean; - phaseIndexPriority: string; } -export interface DeletePhase extends Phase { - selectedMinimumAge: string; - selectedMinimumAgeUnits: string; +export interface FrozenPhase + extends CommonPhaseSettings, + PhaseWithMinAge, + PhaseWithAllocationAction, + PhaseWithIndexPriority { + freezeEnabled: boolean; +} + +export interface DeletePhase extends CommonPhaseSettings, PhaseWithMinAge { waitForSnapshotPolicy: string; } diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js index a1eac5264bb6a..8d01f4a4c200e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js @@ -176,6 +176,12 @@ export const ilmFilterExtension = (indices) => { defaultMessage: 'Warm', }), }, + { + value: 'frozen', + view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.frozenLabel', { + defaultMessage: 'Frozen', + }), + }, { value: 'cold', view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.coldLabel', { diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts index 2d02802119e47..9b51164fd4c28 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts @@ -104,6 +104,23 @@ const coldPhaseSchema = schema.maybe( }) ); +const frozenPhaseSchema = schema.maybe( + schema.object({ + min_age: minAgeSchema, + actions: schema.object({ + set_priority: setPrioritySchema, + unfollow: unfollowSchema, + allocate: allocateSchema, + freeze: schema.maybe(schema.object({})), // Freeze has no options + searchable_snapshot: schema.maybe( + schema.object({ + snapshot_repository: schema.string(), + }) + ), + }), + }) +); + const deletePhaseSchema = schema.maybe( schema.object({ min_age: minAgeSchema, @@ -129,6 +146,7 @@ const bodySchema = schema.object({ hot: hotPhaseSchema, warm: warmPhaseSchema, cold: coldPhaseSchema, + frozen: frozenPhaseSchema, delete: deletePhaseSchema, }), }); From 647f397c50a74cf72c268a432e01311745a5b303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Mon, 31 Aug 2020 10:46:29 +0100 Subject: [PATCH 06/72] [APM] Chart units don't update when toggling the chart legends (#74931) * changing unit when legend is disabled * changing unit when legend is disabled * show individual units in the tooltip * addressing PR comment * increasing duration threshold * change formatter based on available legends * addressing PR comment Co-authored-by: Elastic Machine --- .../shared/charts/CustomPlot/index.js | 5 + .../TransactionCharts/BrowserLineChart.tsx | 14 +- .../TransactionLineChart/index.tsx | 16 +- .../charts/TransactionCharts/helper.test.ts | 69 +++++ .../charts/TransactionCharts/helper.tsx | 35 +++ .../shared/charts/TransactionCharts/index.tsx | 277 ++++++------------ .../charts/TransactionCharts/ml_header.tsx | 96 ++++++ .../TransactionCharts/use_formatter.test.tsx | 109 +++++++ .../charts/TransactionCharts/use_formatter.ts | 30 ++ .../formatters/__test__/duration.test.ts | 7 +- .../apm/public/utils/formatters/duration.ts | 4 +- 11 files changed, 457 insertions(+), 205 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts create mode 100644 x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ml_header.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js index 7e74961e57ea1..41925d651e361 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js @@ -79,6 +79,10 @@ export class InnerCustomPlot extends PureComponent { return i === _i ? !disabledValue : !!disabledValue; }); + if (typeof this.props.onToggleLegend === 'function') { + this.props.onToggleLegend(nextSeriesEnabledState); + } + return { seriesEnabledState: nextSeriesEnabledState, }; @@ -235,6 +239,7 @@ InnerCustomPlot.propTypes = { }) ), noHits: PropTypes.bool, + onToggleLegend: PropTypes.func, }; InnerCustomPlot.defaultProps = { diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx index 0e19c63775d31..40caf35155918 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx @@ -4,17 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; import { EuiTitle } from '@elastic/eui'; -import { TransactionLineChart } from './TransactionLineChart'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useAvgDurationByBrowser } from '../../../../hooks/useAvgDurationByBrowser'; +import { getDurationFormatter } from '../../../../utils/formatters'; import { - getMaxY, getResponseTimeTickFormatter, getResponseTimeTooltipFormatter, -} from '.'; -import { getDurationFormatter } from '../../../../utils/formatters'; -import { useAvgDurationByBrowser } from '../../../../hooks/useAvgDurationByBrowser'; + getMaxY, +} from './helper'; +import { TransactionLineChart } from './TransactionLineChart'; export function BrowserLineChart() { const { data } = useAvgDurationByBrowser(); diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx index eaad883d2f9f6..07b7f01194d5c 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx @@ -5,22 +5,13 @@ */ import React, { useCallback } from 'react'; -import { - Coordinate, - RectCoordinate, -} from '../../../../../../typings/timeseries'; +import { Coordinate, TimeSeries } from '../../../../../../typings/timeseries'; import { useChartsSync } from '../../../../../hooks/useChartsSync'; // @ts-ignore import CustomPlot from '../../CustomPlot'; interface Props { - series: Array<{ - color: string; - title: React.ReactNode; - titleShort?: React.ReactNode; - data: Array; - type: string; - }>; + series: TimeSeries[]; truncateLegends?: boolean; tickFormatY: (y: number) => React.ReactNode; formatTooltipValue: (c: Coordinate) => React.ReactNode; @@ -28,6 +19,7 @@ interface Props { height?: number; stacked?: boolean; onHover?: () => void; + onToggleLegend?: (disabledSeriesState: boolean[]) => void; } function TransactionLineChart(props: Props) { @@ -40,6 +32,7 @@ function TransactionLineChart(props: Props) { truncateLegends, stacked = false, onHover, + onToggleLegend, } = props; const syncedChartsProps = useChartsSync(); @@ -66,6 +59,7 @@ function TransactionLineChart(props: Props) { height={height} truncateLegends={truncateLegends} {...(stacked ? { stackBy: 'y' } : {})} + onToggleLegend={onToggleLegend} /> ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts new file mode 100644 index 0000000000000..a476892fa4a3f --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + getResponseTimeTickFormatter, + getResponseTimeTooltipFormatter, + getMaxY, +} from './helper'; +import { + getDurationFormatter, + toMicroseconds, +} from '../../../../utils/formatters'; +import { TimeSeries } from '../../../../../typings/timeseries'; + +describe('transaction chart helper', () => { + describe('getResponseTimeTickFormatter', () => { + it('formattes time tick in minutes', () => { + const formatter = getDurationFormatter(toMicroseconds(11, 'minutes')); + const timeTickFormatter = getResponseTimeTickFormatter(formatter); + expect(timeTickFormatter(toMicroseconds(60, 'seconds'))).toEqual( + '1.0 min' + ); + }); + it('formattes time tick in seconds', () => { + const formatter = getDurationFormatter(toMicroseconds(11, 'seconds')); + const timeTickFormatter = getResponseTimeTickFormatter(formatter); + expect(timeTickFormatter(toMicroseconds(6, 'seconds'))).toEqual('6.0 s'); + }); + }); + describe('getResponseTimeTooltipFormatter', () => { + const formatter = getDurationFormatter(toMicroseconds(11, 'minutes')); + const tooltipFormatter = getResponseTimeTooltipFormatter(formatter); + it("doesn't format invalid y coordinate", () => { + expect(tooltipFormatter({ x: 1, y: undefined })).toEqual('N/A'); + expect(tooltipFormatter({ x: 1, y: null })).toEqual('N/A'); + }); + it('formattes tooltip in minutes', () => { + expect( + tooltipFormatter({ x: 1, y: toMicroseconds(60, 'seconds') }) + ).toEqual('1.0 min'); + }); + }); + describe('getMaxY', () => { + it('returns zero when empty time series', () => { + expect(getMaxY([])).toEqual(0); + }); + it('returns zero for invalid y coordinate', () => { + const timeSeries = ([ + { data: [{ x: 1 }, { x: 2 }, { x: 3, y: -1 }] }, + ] as unknown) as TimeSeries[]; + expect(getMaxY(timeSeries)).toEqual(0); + }); + it('returns the max y coordinate', () => { + const timeSeries = ([ + { + data: [ + { x: 1, y: 10 }, + { x: 2, y: 5 }, + { x: 3, y: 1 }, + ], + }, + ] as unknown) as TimeSeries[]; + expect(getMaxY(timeSeries)).toEqual(10); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx new file mode 100644 index 0000000000000..f11a33f932553 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { flatten } from 'lodash'; +import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; +import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; +import { TimeSeries, Coordinate } from '../../../../../typings/timeseries'; +import { TimeFormatter } from '../../../../utils/formatters'; + +export function getResponseTimeTickFormatter(formatter: TimeFormatter) { + return (t: number) => { + return formatter(t).formatted; + }; +} + +export function getResponseTimeTooltipFormatter(formatter: TimeFormatter) { + return (coordinate: Coordinate) => { + return isValidCoordinateValue(coordinate.y) + ? formatter(coordinate.y).formatted + : NOT_AVAILABLE_LABEL; + }; +} + +export function getMaxY(timeSeries: TimeSeries[]) { + const coordinates = flatten( + timeSeries.map((serie: TimeSeries) => serie.data as Coordinate[]) + ); + + const numbers: number[] = coordinates.map((c: Coordinate) => (c.y ? c.y : 0)); + + return Math.max(...numbers, 0); +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx index 1f80dbf5f4d95..d11925dc0303d 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx @@ -8,38 +8,34 @@ import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, - EuiIconTip, EuiPanel, - EuiText, - EuiTitle, EuiSpacer, + EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Location } from 'history'; -import React, { Component } from 'react'; -import { isEmpty, flatten } from 'lodash'; -import styled from 'styled-components'; +import React from 'react'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; -import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; -import { ITransactionChartData } from '../../../../selectors/chartSelectors'; -import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { - tpmUnit, - TimeFormatter, - getDurationFormatter, - asDecimal, -} from '../../../../utils/formatters'; -import { MLJobLink } from '../../Links/MachineLearningLinks/MLJobLink'; + TRANSACTION_PAGE_LOAD, + TRANSACTION_REQUEST, + TRANSACTION_ROUTE_CHANGE, +} from '../../../../../common/transaction_types'; +import { Coordinate } from '../../../../../typings/timeseries'; import { LicenseContext } from '../../../../context/LicenseContext'; -import { TransactionLineChart } from './TransactionLineChart'; +import { IUrlParams } from '../../../../context/UrlParamsContext/types'; +import { ITransactionChartData } from '../../../../selectors/chartSelectors'; +import { asDecimal, tpmUnit } from '../../../../utils/formatters'; import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; import { BrowserLineChart } from './BrowserLineChart'; import { DurationByCountryMap } from './DurationByCountryMap'; import { - TRANSACTION_PAGE_LOAD, - TRANSACTION_ROUTE_CHANGE, - TRANSACTION_REQUEST, -} from '../../../../../common/transaction_types'; + getResponseTimeTickFormatter, + getResponseTimeTooltipFormatter, +} from './helper'; +import { MLHeader } from './ml_header'; +import { TransactionLineChart } from './TransactionLineChart'; +import { useFormatter } from './use_formatter'; interface TransactionChartProps { charts: ITransactionChartData; @@ -47,181 +43,96 @@ interface TransactionChartProps { urlParams: IUrlParams; } -const ShiftedIconWrapper = styled.span` - padding-right: 5px; - position: relative; - top: -1px; - display: inline-block; -`; - -const ShiftedEuiText = styled(EuiText)` - position: relative; - top: 5px; -`; - -export function getResponseTimeTickFormatter(formatter: TimeFormatter) { - return (t: number) => formatter(t).formatted; -} - -export function getResponseTimeTooltipFormatter(formatter: TimeFormatter) { - return (p: Coordinate) => { - return isValidCoordinateValue(p.y) - ? formatter(p.y).formatted - : NOT_AVAILABLE_LABEL; - }; -} - -export function getMaxY(responseTimeSeries: TimeSeries[]) { - const coordinates = flatten( - responseTimeSeries.map((serie: TimeSeries) => serie.data as Coordinate[]) - ); - - const numbers: number[] = coordinates.map((c: Coordinate) => (c.y ? c.y : 0)); - - return Math.max(...numbers, 0); -} - -export class TransactionCharts extends Component { - public getTPMFormatter = (t: number) => { - const { urlParams } = this.props; +export function TransactionCharts({ + charts, + location, + urlParams, +}: TransactionChartProps) { + const getTPMFormatter = (t: number) => { const unit = tpmUnit(urlParams.transactionType); return `${asDecimal(t)} ${unit}`; }; - public getTPMTooltipFormatter = (p: Coordinate) => { + const getTPMTooltipFormatter = (p: Coordinate) => { return isValidCoordinateValue(p.y) - ? this.getTPMFormatter(p.y) + ? getTPMFormatter(p.y) : NOT_AVAILABLE_LABEL; }; - public renderMLHeader(hasValidMlLicense: boolean | undefined) { - const { mlJobId } = this.props.charts; - - if (!hasValidMlLicense || !mlJobId) { - return null; - } - - const { serviceName, kuery, transactionType } = this.props.urlParams; - if (!serviceName) { - return null; - } + const { transactionType } = urlParams; - const hasKuery = !isEmpty(kuery); - const icon = hasKuery ? ( - - ) : ( - - ); - - return ( - - - {icon} - - {i18n.translate( - 'xpack.apm.metrics.transactionChart.machineLearningLabel', - { - defaultMessage: 'Machine learning:', - } - )}{' '} - - - View Job - - - - ); - } + const { responseTimeSeries, tpmSeries } = charts; - public render() { - const { charts, urlParams } = this.props; - const { responseTimeSeries, tpmSeries } = charts; - const { transactionType } = urlParams; - const maxY = getMaxY(responseTimeSeries); - const formatter = getDurationFormatter(maxY); + const { formatter, setDisabledSeriesState } = useFormatter( + responseTimeSeries + ); - return ( - <> - - - - - - - - {responseTimeLabel(transactionType)} - - - - {(license) => - this.renderMLHeader(license?.getFeature('ml').isAvailable) - } - - - + + + + + + + + {responseTimeLabel(transactionType)} + + + + {(license) => ( + )} - /> - - - - - - - - - {tpmLabel(transactionType)} - - - - - - - {transactionType === TRANSACTION_PAGE_LOAD && ( - <> - - - - - - - - - - - - - - - )} - - ); - } + + + + + + + + + + + + {tpmLabel(transactionType)} + + + + + + + {transactionType === TRANSACTION_PAGE_LOAD && ( + <> + + + + + + + + + + + + + + + )} + + ); } function tpmLabel(type?: string) { diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ml_header.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ml_header.tsx new file mode 100644 index 0000000000000..f829b5841efa9 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ml_header.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiIconTip } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import React from 'react'; +import { EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { EuiText } from '@elastic/eui'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { MLJobLink } from '../../Links/MachineLearningLinks/MLJobLink'; + +interface Props { + hasValidMlLicense?: boolean; + mlJobId?: string; +} + +const ShiftedIconWrapper = styled.span` + padding-right: 5px; + position: relative; + top: -1px; + display: inline-block; +`; + +const ShiftedEuiText = styled(EuiText)` + position: relative; + top: 5px; +`; + +export function MLHeader({ hasValidMlLicense, mlJobId }: Props) { + const { urlParams } = useUrlParams(); + + if (!hasValidMlLicense || !mlJobId) { + return null; + } + + const { serviceName, kuery, transactionType } = urlParams; + if (!serviceName) { + return null; + } + + const hasKuery = !isEmpty(kuery); + const icon = hasKuery ? ( +