diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 1a0ad67c7b696..85e3761129018 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -15,7 +15,8 @@ "usageCollection", "taskManager", "actions", - "alerting" + "alerting", + "security" ], "server": true, "ui": true, diff --git a/x-pack/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx b/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.test.tsx similarity index 63% rename from x-pack/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx rename to x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.test.tsx index 68acaee4abe5d..b3f90fd9aee34 100644 --- a/x-pack/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx +++ b/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.test.tsx @@ -4,16 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, act } from '@testing-library/react'; import { shallow } from 'enzyme'; -import { APMIndicesPermission } from '../'; +import { APMIndicesPermission } from './'; -import * as hooks from '../../../../hooks/useFetcher'; +import * as hooks from '../../../hooks/useFetcher'; import { expectTextsInDocument, expectTextsNotInDocument -} from '../../../../utils/testHelpers'; -import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; +} from '../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; describe('APMIndicesPermission', () => { it('returns empty component when api status is loading', () => { @@ -34,7 +34,10 @@ describe('APMIndicesPermission', () => { spyOn(hooks, 'useFetcher').and.returnValue({ status: hooks.FETCH_STATUS.SUCCESS, data: { - 'apm-*': { read: false } + has_all_requested: false, + index: { + 'apm-*': { read: false } + } } }); const component = render( @@ -48,39 +51,32 @@ describe('APMIndicesPermission', () => { 'apm-*' ]); }); - it('shows escape hatch button when at least one indice has read privileges', () => { + + it('shows children component when no index is returned', () => { spyOn(hooks, 'useFetcher').and.returnValue({ status: hooks.FETCH_STATUS.SUCCESS, data: { - 'apm-7.5.1-error-*': { read: false }, - 'apm-7.5.1-metric-*': { read: false }, - 'apm-7.5.1-transaction-*': { read: false }, - 'apm-7.5.1-span-*': { read: true } + has_all_requested: false, + index: {} } }); const component = render( - + +

My amazing component

+
); - expectTextsInDocument(component, [ - 'Missing permissions to access APM', - 'apm-7.5.1-error-*', - 'apm-7.5.1-metric-*', - 'apm-7.5.1-transaction-*', - 'Dismiss' - ]); - expectTextsNotInDocument(component, ['apm-7.5.1-span-*']); + expectTextsNotInDocument(component, ['Missing permissions to access APM']); + expectTextsInDocument(component, ['My amazing component']); }); it('shows children component when indices have read privileges', () => { spyOn(hooks, 'useFetcher').and.returnValue({ status: hooks.FETCH_STATUS.SUCCESS, data: { - 'apm-7.5.1-error-*': { read: true }, - 'apm-7.5.1-metric-*': { read: true }, - 'apm-7.5.1-transaction-*': { read: true }, - 'apm-7.5.1-span-*': { read: true } + has_all_requested: true, + index: {} } }); const component = render( @@ -90,13 +86,7 @@ describe('APMIndicesPermission', () => { ); - expectTextsNotInDocument(component, [ - 'Missing permissions to access APM', - 'apm-7.5.1-error-*', - 'apm-7.5.1-metric-*', - 'apm-7.5.1-transaction-*', - 'apm-7.5.1-span-*' - ]); + expectTextsNotInDocument(component, ['Missing permissions to access APM']); expectTextsInDocument(component, ['My amazing component']); }); @@ -104,10 +94,13 @@ describe('APMIndicesPermission', () => { spyOn(hooks, 'useFetcher').and.returnValue({ status: hooks.FETCH_STATUS.SUCCESS, data: { - 'apm-7.5.1-error-*': { read: false }, - 'apm-7.5.1-metric-*': { read: false }, - 'apm-7.5.1-transaction-*': { read: false }, - 'apm-7.5.1-span-*': { read: true } + has_all_requested: false, + index: { + 'apm-error-*': { read: false }, + 'apm-trasanction-*': { read: false }, + 'apm-metrics-*': { read: true }, + 'apm-span-*': { read: true } + } } }); const component = render( @@ -117,8 +110,33 @@ describe('APMIndicesPermission', () => { ); - expectTextsInDocument(component, ['Dismiss']); - fireEvent.click(component.getByText('Dismiss')); + expectTextsInDocument(component, [ + 'Dismiss', + 'apm-error-*', + 'apm-trasanction-*' + ]); + act(() => { + fireEvent.click(component.getByText('Dismiss')); + }); + expectTextsInDocument(component, ['My amazing component']); + }); + + it("shows children component when api doesn't return value", () => { + spyOn(hooks, 'useFetcher').and.returnValue({}); + const component = render( + + +

My amazing component

+
+
+ ); + expectTextsNotInDocument(component, [ + 'Missing permissions to access APM', + 'apm-7.5.1-error-*', + 'apm-7.5.1-metric-*', + 'apm-7.5.1-transaction-*', + 'apm-7.5.1-span-*' + ]); expectTextsInDocument(component, ['My amazing component']); }); }); diff --git a/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.tsx b/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.tsx index 40e039dcd40c5..9074726f76e6d 100644 --- a/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.tsx +++ b/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.tsx @@ -15,9 +15,9 @@ import { EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { isEmpty } from 'lodash'; import React, { useState } from 'react'; import styled from 'styled-components'; +import { isEmpty } from 'lodash'; import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher'; import { fontSize, pct, px, units } from '../../../style/variables'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; @@ -29,7 +29,7 @@ export const APMIndicesPermission: React.FC = ({ children }) => { setIsPermissionWarningDismissed ] = useState(false); - const { data: indicesPrivileges = {}, status } = useFetcher(callApmApi => { + const { data: indicesPrivileges, status } = useFetcher(callApmApi => { return callApmApi({ pathname: '/api/apm/security/indices_privileges' }); @@ -40,13 +40,17 @@ export const APMIndicesPermission: React.FC = ({ children }) => { return null; } - const indicesWithoutPermission = Object.keys(indicesPrivileges).filter( - index => !indicesPrivileges[index].read - ); - // Show permission warning when a user has at least one index without Read privilege, - // and he has not manually dismissed the warning - if (!isEmpty(indicesWithoutPermission) && !isPermissionWarningDismissed) { + // and they have not manually dismissed the warning + if ( + indicesPrivileges && + !indicesPrivileges.has_all_requested && + !isEmpty(indicesPrivileges.index) && + !isPermissionWarningDismissed + ) { + const indicesWithoutPermission = Object.keys( + indicesPrivileges.index + ).filter(index => !indicesPrivileges.index[index].read); return ( { + let callApmApiSpy: jasmine.Spy; + beforeAll(() => { + callApmApiSpy = spyOn(apmApi, 'callApmApi').and.returnValue({ + transaction: { id: 'foo' } + }); + }); + afterAll(() => { + jest.clearAllMocks(); + }); const getElementValue = (container: HTMLElement, id: string) => getNodeText( ((getByTestId(container, id) as HTMLDivElement) @@ -42,7 +58,7 @@ describe('LinkPreview', () => { }); }); - it('shows warning when couldnt replace context variables', () => { + it("shows warning when couldn't replace context variables", () => { act(() => { const { container } = render( { expect(getByTestId(container, 'preview-warning')).toBeInTheDocument(); }); }); + it('replaces url with transaction id', async () => { + const { container } = render( + + ); + await wait(() => expect(callApmApiSpy).toHaveBeenCalled()); + expect(getElementValue(container, 'preview-label')).toEqual('foo'); + expect( + (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + ).toEqual('https://baz.co?transaction=foo'); + }); }); diff --git a/x-pack/plugins/apm/server/lib/helpers/es_client.ts b/x-pack/plugins/apm/server/lib/helpers/es_client.ts index de9ab87b69fc8..45a7eca46caba 100644 --- a/x-pack/plugins/apm/server/lib/helpers/es_client.ts +++ b/x-pack/plugins/apm/server/lib/helpers/es_client.ts @@ -27,9 +27,8 @@ import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; // `type` was deprecated in 7.0 export type APMIndexDocumentParams = Omit, 'type'>; -interface IndexPrivileges { +export interface IndexPrivileges { has_all_requested: boolean; - username: string; index: Record; } diff --git a/x-pack/plugins/apm/server/lib/security/get_indices_privileges.test.ts b/x-pack/plugins/apm/server/lib/security/get_indices_privileges.test.ts new file mode 100644 index 0000000000000..c1bc48f4ed1fd --- /dev/null +++ b/x-pack/plugins/apm/server/lib/security/get_indices_privileges.test.ts @@ -0,0 +1,148 @@ +/* + * 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 { Setup } from '../helpers/setup_request'; +import { getIndicesPrivileges } from './get_indices_privileges'; + +describe('getIndicesPrivileges', () => { + const indices = { + apm_oss: { + errorIndices: 'apm-*', + metricsIndices: 'apm-*', + transactionIndices: 'apm-*', + spanIndices: 'apm-*' + } + }; + it('return that the user has privileges when security plugin is disabled', async () => { + const setup = ({ + indices, + client: { + hasPrivileges: () => { + const error = { + message: + 'no handler found for uri [/_security/user/_has_privileges]', + statusCode: 400 + }; + throw error; + } + } + } as unknown) as Setup; + const privileges = await getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: false + }); + expect(privileges).toEqual({ + has_all_requested: true, + index: {} + }); + }); + it('throws when an error happens while fetching indices privileges', async () => { + const setup = ({ + indices, + client: { + hasPrivileges: () => { + throw new Error('unknow error'); + } + } + } as unknown) as Setup; + await expect( + getIndicesPrivileges({ setup, isSecurityPluginEnabled: true }) + ).rejects.toThrowError('unknow error'); + }); + it("has privileges to read from 'apm-*'", async () => { + const setup = ({ + indices, + client: { + hasPrivileges: () => { + return Promise.resolve({ + has_all_requested: true, + index: { 'apm-*': { read: true } } + }); + } + } + } as unknown) as Setup; + const privileges = await getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: true + }); + + expect(privileges).toEqual({ + has_all_requested: true, + index: { + 'apm-*': { + read: true + } + } + }); + }); + + it("doesn't have privileges to read from 'apm-*'", async () => { + const setup = ({ + indices, + client: { + hasPrivileges: () => { + return Promise.resolve({ + has_all_requested: false, + index: { 'apm-*': { read: false } } + }); + } + } + } as unknown) as Setup; + + const privileges = await getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: true + }); + + expect(privileges).toEqual({ + has_all_requested: false, + index: { + 'apm-*': { + read: false + } + } + }); + }); + it("doesn't have privileges on multiple indices", async () => { + const setup = ({ + indices: { + apm_oss: { + errorIndices: 'apm-error-*', + metricsIndices: 'apm-metrics-*', + transactionIndices: 'apm-trasanction-*', + spanIndices: 'apm-span-*' + } + }, + client: { + hasPrivileges: () => { + return Promise.resolve({ + has_all_requested: false, + index: { + 'apm-error-*': { read: false }, + 'apm-trasanction-*': { read: false }, + 'apm-metrics-*': { read: true }, + 'apm-span-*': { read: true } + } + }); + } + } + } as unknown) as Setup; + + const privileges = await getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: true + }); + + expect(privileges).toEqual({ + has_all_requested: false, + index: { + 'apm-error-*': { read: false }, + 'apm-trasanction-*': { read: false }, + 'apm-metrics-*': { read: true }, + 'apm-span-*': { read: true } + } + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/security/get_indices_privileges.ts b/x-pack/plugins/apm/server/lib/security/get_indices_privileges.ts index 1a80a13b2ad19..46ed64f518bb8 100644 --- a/x-pack/plugins/apm/server/lib/security/get_indices_privileges.ts +++ b/x-pack/plugins/apm/server/lib/security/get_indices_privileges.ts @@ -4,8 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ import { Setup } from '../helpers/setup_request'; +import { IndexPrivileges } from '../helpers/es_client'; + +export async function getIndicesPrivileges({ + setup, + isSecurityPluginEnabled +}: { + setup: Setup; + isSecurityPluginEnabled: boolean; +}): Promise { + // When security plugin is not enabled, returns that the user has all requested privileges. + if (!isSecurityPluginEnabled) { + return { has_all_requested: true, index: {} }; + } -export async function getIndicesPrivileges(setup: Setup) { const { client, indices } = setup; const response = await client.hasPrivileges({ index: [ @@ -20,5 +32,5 @@ export async function getIndicesPrivileges(setup: Setup) { } ] }); - return response.index; + return response; } diff --git a/x-pack/plugins/apm/server/lib/security/get_permissions.ts b/x-pack/plugins/apm/server/lib/security/get_permissions.ts deleted file mode 100644 index ed2a1f64e7f84..0000000000000 --- a/x-pack/plugins/apm/server/lib/security/get_permissions.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { Setup } from '../helpers/setup_request'; - -export async function getPermissions(setup: Setup) { - const { client, indices } = setup; - - const params = { - index: Object.values(indices), - body: { - size: 0, - query: { - match_all: {} - } - } - }; - - try { - await client.search(params); - return { hasPermission: true }; - } catch (e) { - // If 403, it means the user doesnt have permission. - if (e.status === 403) { - return { hasPermission: false }; - } - // if any other error happens, throw it. - throw e; - } -} diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 8cf29de5b8b73..29ab618cbdd0a 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -12,6 +12,7 @@ import { } from 'src/core/server'; import { Observable, combineLatest } from 'rxjs'; import { map, take } from 'rxjs/operators'; +import { SecurityPluginSetup } from '../../security/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { TaskManagerSetupContract } from '../../task_manager/server'; import { AlertingPlugin } from '../../alerting/server'; @@ -57,6 +58,7 @@ export class APMPlugin implements Plugin { alerting?: AlertingPlugin['setup']; actions?: ActionsPlugin['setup']; features: FeaturesPluginSetup; + security?: SecurityPluginSetup; } ) { this.logger = this.initContext.logger.get(); @@ -110,7 +112,10 @@ export class APMPlugin implements Plugin { createApmApi().init(core, { config$: mergedConfig$, - logger: this.logger! + logger: this.logger!, + plugins: { + security: plugins.security + } }); return { diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index 20c586868a979..6236fcb0a6942 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -39,7 +39,8 @@ const getCoreMock = () => { config$: new BehaviorSubject({} as APMConfig), logger: ({ error: jest.fn() - } as unknown) as Logger + } as unknown) as Logger, + plugins: {} } }; }; diff --git a/x-pack/plugins/apm/server/routes/create_api/index.ts b/x-pack/plugins/apm/server/routes/create_api/index.ts index a97e2f30fc2b6..9b611a0bbd6bc 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.ts @@ -30,7 +30,7 @@ export function createApi() { factoryFns.push(fn); return this as any; }, - init(core, { config$, logger }) { + init(core, { config$, logger, plugins }) { const router = core.http.createRouter(); let config = {} as APMConfig; @@ -141,7 +141,8 @@ export function createApi() { // it's not defined in the route. params: pick(parsedParams, ...Object.keys(params), 'query'), config, - logger + logger, + plugins } }); diff --git a/x-pack/plugins/apm/server/routes/security.ts b/x-pack/plugins/apm/server/routes/security.ts index 0a8222b665d83..1e2a302ab9a4a 100644 --- a/x-pack/plugins/apm/server/routes/security.ts +++ b/x-pack/plugins/apm/server/routes/security.ts @@ -12,6 +12,10 @@ export const indicesPrivilegesRoute = createRoute(() => ({ path: '/api/apm/security/indices_privileges', handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - return getIndicesPrivileges(setup); + return getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: + context.plugins.security?.license.isEnabled() ?? false + }); } })); diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 6543f2015599b..e049255eb8ec8 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -16,6 +16,7 @@ import { Observable } from 'rxjs'; import { Server } from 'hapi'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FetchOptions } from '../../public/services/rest/callApi'; +import { SecurityPluginSetup } from '../../../security/public'; import { APMConfig } from '..'; export interface Params { @@ -62,6 +63,9 @@ export type APMRequestHandlerContext< params: { query: { _debug: boolean } } & TDecodedParams; config: APMConfig; logger: Logger; + plugins: { + security?: SecurityPluginSetup; + }; }; export type RouteFactoryFn< @@ -105,6 +109,9 @@ export interface ServerAPI { context: { config$: Observable; logger: Logger; + plugins: { + security?: SecurityPluginSetup; + }; } ) => void; } diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index fd972219563a8..b1a613e789e85 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -217,3 +217,9 @@ export enum SCALING_TYPES { export const RGBA_0000 = 'rgba(0,0,0,0)'; export const SPATIAL_FILTERS_LAYER_ID = 'SPATIAL_FILTERS_LAYER_ID'; + +export enum INITIAL_LOCATION { + LAST_SAVED_LOCATION = 'LAST_SAVED_LOCATION', + FIXED_LOCATION = 'FIXED_LOCATION', + BROWSER_LOCATION = 'BROWSER_LOCATION', +} diff --git a/x-pack/plugins/maps/public/actions/map_actions.d.ts b/x-pack/plugins/maps/public/actions/map_actions.d.ts index 38c56405787eb..413b440279d77 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.d.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.d.ts @@ -72,7 +72,7 @@ export function trackMapSettings(): AnyAction; export function updateMapSetting( settingKey: string, - settingValue: string | boolean | number + settingValue: string | boolean | number | object ): AnyAction; export function cloneLayer(layerId: string): AnyAction; diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/get_initial_view.ts b/x-pack/plugins/maps/public/connected_components/map/mb/get_initial_view.ts new file mode 100644 index 0000000000000..30e3b9b46916b --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/get_initial_view.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { INITIAL_LOCATION } from '../../../../common/constants'; +import { Goto, MapCenterAndZoom } from '../../../../common/descriptor_types'; +import { MapSettings } from '../../../reducers/map'; + +export async function getInitialView( + goto: Goto | null, + settings: MapSettings +): Promise { + if (settings.initialLocation === INITIAL_LOCATION.FIXED_LOCATION) { + return { + lat: settings.fixedLocation.lat, + lon: settings.fixedLocation.lon, + zoom: settings.fixedLocation.zoom, + }; + } + + if (settings.initialLocation === INITIAL_LOCATION.BROWSER_LOCATION) { + return await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition( + // success callback + pos => { + resolve({ + lat: pos.coords.latitude, + lon: pos.coords.longitude, + zoom: settings.browserLocation.zoom, + }); + }, + // error callback + () => { + // eslint-disable-next-line no-console + console.warn('Unable to fetch browser location for initial map location'); + resolve(null); + } + ); + }); + } + + return goto && goto.center ? goto.center : null; +} 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 6bb5a4fed6e52..7afb326f42e02 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 @@ -24,6 +24,7 @@ import sprites2 from '@elastic/maki/dist/sprite@2.png'; import { DrawControl } from './draw_control'; import { TooltipControl } from './tooltip_control'; import { clampToLatBounds, clampToLonBounds } from '../../../elasticsearch_geo_utils'; +import { getInitialView } from './get_initial_view'; import { getInjectedVarFunc } from '../../../kibana_services'; @@ -112,6 +113,7 @@ export class MBMapContainer extends React.Component { } async _createMbMapInstance() { + const initialView = await getInitialView(this.props.goto, this.props.settings); return new Promise(resolve => { const mbStyle = { version: 8, @@ -133,7 +135,6 @@ export class MBMapContainer extends React.Component { maxZoom: this.props.settings.maxZoom, minZoom: this.props.settings.minZoom, }; - const initialView = _.get(this.props.goto, 'center'); if (initialView) { options.zoom = initialView.zoom; options.center = { diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap new file mode 100644 index 0000000000000..641dd20a1a44a --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap @@ -0,0 +1,292 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render 1`] = ` + + +
+ +
+
+ + + + + +
+`; + +exports[`should render browser location form when initialLocation is BROWSER_LOCATION 1`] = ` + + +
+ +
+
+ + + + + + + + +
+`; + +exports[`should render fixed location form when initialLocation is FIXED_LOCATION 1`] = ` + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + +
+`; diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts b/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts index 329fac28d7d2e..eaa49719059c5 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts @@ -10,13 +10,20 @@ import { FLYOUT_STATE } from '../../reducers/ui'; import { MapStoreState } from '../../reducers/store'; import { MapSettingsPanel } from './map_settings_panel'; import { rollbackMapSettings, updateMapSetting } from '../../actions/map_actions'; -import { getMapSettings, hasMapSettingsChanges } from '../../selectors/map_selectors'; +import { + getMapCenter, + getMapSettings, + getMapZoom, + hasMapSettingsChanges, +} from '../../selectors/map_selectors'; import { updateFlyout } from '../../actions/ui_actions'; function mapStateToProps(state: MapStoreState) { return { - settings: getMapSettings(state), + center: getMapCenter(state), hasMapSettingsChanges: hasMapSettingsChanges(state), + settings: getMapSettings(state), + zoom: getMapZoom(state), }; } @@ -29,7 +36,7 @@ function mapDispatchToProps(dispatch: Dispatch) { keepChanges: () => { dispatch(updateFlyout(FLYOUT_STATE.NONE)); }, - updateMapSetting: (settingKey: string, settingValue: string | number | boolean) => { + updateMapSetting: (settingKey: string, settingValue: string | number | boolean | object) => { dispatch(updateMapSetting(settingKey, settingValue)); }, }; diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx index a89f4461fff06..66b979869416d 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx @@ -20,21 +20,26 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { MapSettings } from '../../reducers/map'; import { NavigationPanel } from './navigation_panel'; import { SpatialFiltersPanel } from './spatial_filters_panel'; +import { MapCenter } from '../../../common/descriptor_types'; interface Props { cancelChanges: () => void; + center: MapCenter; hasMapSettingsChanges: boolean; keepChanges: () => void; settings: MapSettings; - updateMapSetting: (settingKey: string, settingValue: string | number | boolean) => void; + updateMapSetting: (settingKey: string, settingValue: string | number | boolean | object) => void; + zoom: number; } export function MapSettingsPanel({ cancelChanges, + center, hasMapSettingsChanges, keepChanges, settings, updateMapSetting, + zoom, }: Props) { // TODO move common text like Cancel and Close to common i18n translation const closeBtnLabel = hasMapSettingsChanges @@ -60,7 +65,12 @@ export function MapSettingsPanel({
- +
diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.test.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.test.tsx new file mode 100644 index 0000000000000..d785a30324e4e --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { NavigationPanel } from './navigation_panel'; +import { getDefaultMapSettings } from '../../reducers/default_map_settings'; +import { INITIAL_LOCATION } from '../../../common/constants'; + +const defaultProps = { + center: { lat: 0, lon: 0 }, + settings: getDefaultMapSettings(), + updateMapSetting: () => {}, + zoom: 0, +}; + +test('should render', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should render fixed location form when initialLocation is FIXED_LOCATION', async () => { + const settings = { + ...defaultProps.settings, + initialLocation: INITIAL_LOCATION.FIXED_LOCATION, + }; + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should render browser location form when initialLocation is BROWSER_LOCATION', async () => { + const settings = { + ...defaultProps.settings, + initialLocation: INITIAL_LOCATION.BROWSER_LOCATION, + }; + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx index ed83e838f44f6..0e12f20dd9a7a 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx @@ -4,25 +4,198 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import React, { ChangeEvent } from 'react'; +import { + EuiButtonEmpty, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPanel, + EuiRadioGroup, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { MapSettings } from '../../reducers/map'; import { ValidatedDualRange, Value } from '../../../../../../src/plugins/kibana_react/public'; -import { MAX_ZOOM, MIN_ZOOM } from '../../../common/constants'; +import { INITIAL_LOCATION, MAX_ZOOM, MIN_ZOOM } from '../../../common/constants'; +import { MapCenter } from '../../../common/descriptor_types'; +// @ts-ignore +import { ValidatedRange } from '../../components/validated_range'; interface Props { + center: MapCenter; settings: MapSettings; - updateMapSetting: (settingKey: string, settingValue: string | number | boolean) => void; + updateMapSetting: (settingKey: string, settingValue: string | number | boolean | object) => void; + zoom: number; } -export function NavigationPanel({ settings, updateMapSetting }: Props) { +const initialLocationOptions = [ + { + id: INITIAL_LOCATION.LAST_SAVED_LOCATION, + label: i18n.translate('xpack.maps.mapSettingsPanel.lastSavedLocationLabel', { + defaultMessage: 'Map location at save', + }), + }, + { + id: INITIAL_LOCATION.FIXED_LOCATION, + label: i18n.translate('xpack.maps.mapSettingsPanel.fixedLocationLabel', { + defaultMessage: 'Fixed location', + }), + }, + { + id: INITIAL_LOCATION.BROWSER_LOCATION, + label: i18n.translate('xpack.maps.mapSettingsPanel.browserLocationLabel', { + defaultMessage: 'Browser location', + }), + }, +]; + +export function NavigationPanel({ center, settings, updateMapSetting, zoom }: Props) { const onZoomChange = (value: Value) => { - updateMapSetting('minZoom', Math.max(MIN_ZOOM, parseInt(value[0] as string, 10))); - updateMapSetting('maxZoom', Math.min(MAX_ZOOM, parseInt(value[1] as string, 10))); + const minZoom = Math.max(MIN_ZOOM, parseInt(value[0] as string, 10)); + const maxZoom = Math.min(MAX_ZOOM, parseInt(value[1] as string, 10)); + updateMapSetting('minZoom', minZoom); + updateMapSetting('maxZoom', maxZoom); + + // ensure fixed zoom and browser zoom stay within defined min/max + if (settings.fixedLocation.zoom < minZoom) { + onFixedZoomChange(minZoom); + } else if (settings.fixedLocation.zoom > maxZoom) { + onFixedZoomChange(maxZoom); + } + + if (settings.browserLocation.zoom < minZoom) { + onBrowserZoomChange(minZoom); + } else if (settings.browserLocation.zoom > maxZoom) { + onBrowserZoomChange(maxZoom); + } + }; + + const onInitialLocationChange = (optionId: string): void => { + updateMapSetting('initialLocation', optionId); + }; + + const onFixedLatChange = (event: ChangeEvent) => { + let value = parseFloat(event.target.value); + if (isNaN(value)) { + value = 0; + } else if (value < -90) { + value = -90; + } else if (value > 90) { + value = 90; + } + updateMapSetting('fixedLocation', { ...settings.fixedLocation, lat: value }); + }; + + const onFixedLonChange = (event: ChangeEvent) => { + let value = parseFloat(event.target.value); + if (isNaN(value)) { + value = 0; + } else if (value < -180) { + value = -180; + } else if (value > 180) { + value = 180; + } + updateMapSetting('fixedLocation', { ...settings.fixedLocation, lon: value }); + }; + + const onFixedZoomChange = (value: number) => { + updateMapSetting('fixedLocation', { ...settings.fixedLocation, zoom: value }); + }; + + const onBrowserZoomChange = (value: number) => { + updateMapSetting('browserLocation', { zoom: value }); + }; + + const useCurrentView = () => { + updateMapSetting('fixedLocation', { + lat: center.lat, + lon: center.lon, + zoom: Math.round(zoom), + }); }; + function renderInitialLocationInputs() { + if (settings.initialLocation === INITIAL_LOCATION.LAST_SAVED_LOCATION) { + return null; + } + + const zoomFormRow = ( + + + + ); + + if (settings.initialLocation === INITIAL_LOCATION.BROWSER_LOCATION) { + return zoomFormRow; + } + + return ( + <> + + + + + + + {zoomFormRow} + + + + + + + + + ); + } + return ( @@ -50,6 +223,19 @@ export function NavigationPanel({ settings, updateMapSetting }: Props) { allowEmptyRange={false} compressed /> + + + + + {renderInitialLocationInputs()} ); } diff --git a/x-pack/plugins/maps/public/reducers/default_map_settings.ts b/x-pack/plugins/maps/public/reducers/default_map_settings.ts index fe21b37434edd..9c9b814ae6add 100644 --- a/x-pack/plugins/maps/public/reducers/default_map_settings.ts +++ b/x-pack/plugins/maps/public/reducers/default_map_settings.ts @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MAX_ZOOM, MIN_ZOOM } from '../../common/constants'; +import { INITIAL_LOCATION, MAX_ZOOM, MIN_ZOOM } from '../../common/constants'; import { MapSettings } from './map'; export function getDefaultMapSettings(): MapSettings { return { + initialLocation: INITIAL_LOCATION.LAST_SAVED_LOCATION, + fixedLocation: { lat: 0, lon: 0, zoom: 2 }, + browserLocation: { zoom: 2 }, maxZoom: MAX_ZOOM, minZoom: MIN_ZOOM, showSpatialFilters: true, diff --git a/x-pack/plugins/maps/public/reducers/map.d.ts b/x-pack/plugins/maps/public/reducers/map.d.ts index be0700d4bdd6d..20e1dc1035e19 100644 --- a/x-pack/plugins/maps/public/reducers/map.d.ts +++ b/x-pack/plugins/maps/public/reducers/map.d.ts @@ -15,6 +15,7 @@ import { MapRefreshConfig, TooltipState, } from '../../common/descriptor_types'; +import { INITIAL_LOCATION } from '../../common/constants'; import { Filter, TimeRange } from '../../../../../src/plugins/data/public'; export type MapContext = { @@ -40,6 +41,15 @@ export type MapContext = { }; export type MapSettings = { + initialLocation: INITIAL_LOCATION; + fixedLocation: { + lat: number; + lon: number; + zoom: number; + }; + browserLocation: { + zoom: number; + }; maxZoom: number; minZoom: number; showSpatialFilters: boolean; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index e9d4aff3484b1..038f61b3a33b7 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -13,9 +13,7 @@ "home", "licensing", "usageCollection", - "share", - "embeddable", - "uiActions" + "share" ], "optionalPlugins": [ "security", diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx index decd1275fe884..9cc42a4df2f66 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx @@ -4,15 +4,56 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; -import TooltipTrigger from 'react-popper-tooltip'; +import React, { useRef, FC } from 'react'; import { TooltipValueFormatter } from '@elastic/charts'; +import useObservable from 'react-use/lib/useObservable'; -import './_index.scss'; +import { chartTooltip$, ChartTooltipState, ChartTooltipValue } from './chart_tooltip_service'; -import { ChildrenArg, TooltipTriggerProps } from 'react-popper-tooltip/dist/types'; -import { ChartTooltipService, ChartTooltipValue, TooltipData } from './chart_tooltip_service'; +type RefValue = HTMLElement | null; + +function useRefWithCallback(chartTooltipState?: ChartTooltipState) { + const ref = useRef(null); + + return (node: RefValue) => { + ref.current = node; + + if ( + node !== null && + node.parentElement !== null && + chartTooltipState !== undefined && + chartTooltipState.isTooltipVisible + ) { + const parentBounding = node.parentElement.getBoundingClientRect(); + + const { targetPosition, offset } = chartTooltipState; + + const contentWidth = document.body.clientWidth - parentBounding.left; + const tooltipWidth = node.clientWidth; + + let left = targetPosition.left + offset.x - parentBounding.left; + if (left + tooltipWidth > contentWidth) { + // the tooltip is hanging off the side of the page, + // so move it to the other side of the target + left = left - (tooltipWidth + offset.x); + } + + const top = targetPosition.top + offset.y - parentBounding.top; + + if ( + chartTooltipState.tooltipPosition.left !== left || + chartTooltipState.tooltipPosition.top !== top + ) { + // render the tooltip with adjusted position. + chartTooltip$.next({ + ...chartTooltipState, + tooltipPosition: { left, top }, + }); + } + } + }; +} const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFormatter) => { if (!headerData) { @@ -22,101 +63,48 @@ const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFo return formatter ? formatter(headerData) : headerData.label; }; -const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) => { - const [tooltipData, setData] = useState([]); - const refCallback = useRef(); +export const ChartTooltip: FC = () => { + const chartTooltipState = useObservable(chartTooltip$); + const chartTooltipElement = useRefWithCallback(chartTooltipState); - useEffect(() => { - const subscription = service.tooltipState$.subscribe(tooltipState => { - if (refCallback.current) { - // update trigger - refCallback.current(tooltipState.target); - } - setData(tooltipState.tooltipData); - }); - return () => { - subscription.unsubscribe(); - }; - }, []); - - const triggerCallback = useCallback( - (({ triggerRef }) => { - // obtain the reference to the trigger setter callback - // to update the target based on changes from the service. - refCallback.current = triggerRef; - // actual trigger is resolved by the service, hence don't render - return null; - }) as TooltipTriggerProps['children'], - [] - ); - - const tooltipCallback = useCallback( - (({ tooltipRef, getTooltipProps }) => { - return ( -
- {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && ( -
{renderHeader(tooltipData[0])}
- )} - {tooltipData.length > 1 && ( -
- {tooltipData - .slice(1) - .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => { - const classes = classNames('mlChartTooltip__item', { - /* eslint @typescript-eslint/camelcase:0 */ - echTooltip__rowHighlighted: isHighlighted, - }); - return ( -
- {label} - {value} -
- ); - })} -
- )} -
- ); - }) as TooltipTriggerProps['tooltip'], - [tooltipData] - ); - - const isTooltipShown = tooltipData.length > 0; - - return ( - - {triggerCallback} - - ); -}); - -interface MlTooltipComponentProps { - children: (tooltipService: ChartTooltipService) => React.ReactElement; -} + if (chartTooltipState === undefined || !chartTooltipState.isTooltipVisible) { + return
; + } -export const MlTooltipComponent: FC = ({ children }) => { - const service = useMemo(() => new ChartTooltipService(), []); + const { tooltipData, tooltipHeaderFormatter, tooltipPosition } = chartTooltipState; + const transform = `translate(${tooltipPosition.left}px, ${tooltipPosition.top}px)`; return ( - <> - - {children(service)} - +
+ {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && ( +
+ {renderHeader(tooltipData[0], tooltipHeaderFormatter)} +
+ )} + {tooltipData.length > 1 && ( +
+ {tooltipData + .slice(1) + .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => { + const classes = classNames('mlChartTooltip__item', { + /* eslint @typescript-eslint/camelcase:0 */ + echTooltip__rowHighlighted: isHighlighted, + }); + return ( +
+ {label} + {value} +
+ ); + })} +
+ )} +
); }; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts new file mode 100644 index 0000000000000..e6b0b6b4270bd --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BehaviorSubject } from 'rxjs'; + +import { TooltipValue, TooltipValueFormatter } from '@elastic/charts'; + +export declare const getChartTooltipDefaultState: () => ChartTooltipState; + +export interface ChartTooltipValue extends TooltipValue { + skipHeader?: boolean; +} + +interface ChartTooltipState { + isTooltipVisible: boolean; + offset: ToolTipOffset; + targetPosition: ClientRect; + tooltipData: ChartTooltipValue[]; + tooltipHeaderFormatter?: TooltipValueFormatter; + tooltipPosition: { left: number; top: number }; +} + +export declare const chartTooltip$: BehaviorSubject; + +interface ToolTipOffset { + x: number; + y: number; +} + +interface MlChartTooltipService { + show: ( + tooltipData: ChartTooltipValue[], + target?: HTMLElement | null, + offset?: ToolTipOffset + ) => void; + hide: () => void; +} + +export declare const mlChartTooltipService: MlChartTooltipService; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js new file mode 100644 index 0000000000000..59cf98e5ffd71 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BehaviorSubject } from 'rxjs'; + +export const getChartTooltipDefaultState = () => ({ + isTooltipVisible: false, + tooltipData: [], + offset: { x: 0, y: 0 }, + targetPosition: { left: 0, top: 0 }, + tooltipPosition: { left: 0, top: 0 }, +}); + +export const chartTooltip$ = new BehaviorSubject(getChartTooltipDefaultState()); + +export const mlChartTooltipService = { + show: (tooltipData, target, offset = { x: 0, y: 0 }) => { + if (typeof target !== 'undefined' && target !== null) { + chartTooltip$.next({ + ...chartTooltip$.getValue(), + isTooltipVisible: true, + offset, + targetPosition: target.getBoundingClientRect(), + tooltipData, + }); + } + }, + hide: () => { + chartTooltip$.next({ + ...getChartTooltipDefaultState(), + isTooltipVisible: false, + }); + }, +}; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts index 231854cd264c2..aa1dbf92b0677 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts @@ -4,61 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - ChartTooltipService, - getChartTooltipDefaultState, - TooltipData, -} from './chart_tooltip_service'; +import { getChartTooltipDefaultState, mlChartTooltipService } from './chart_tooltip_service'; -describe('ChartTooltipService', () => { - let service: ChartTooltipService; - - beforeEach(() => { - service = new ChartTooltipService(); - }); - - test('should update the tooltip state on show and hide', () => { - const spy = jest.fn(); - - service.tooltipState$.subscribe(spy); - - expect(spy).toHaveBeenCalledWith(getChartTooltipDefaultState()); - - const update = [ - { - label: 'new tooltip', - }, - ] as TooltipData; - const mockEl = document.createElement('div'); - - service.show(update, mockEl); - - expect(spy).toHaveBeenCalledWith({ - isTooltipVisible: true, - tooltipData: update, - offset: { x: 0, y: 0 }, - target: mockEl, - }); - - service.hide(); - - expect(spy).toHaveBeenCalledWith({ - isTooltipVisible: false, - tooltipData: ([] as unknown) as TooltipData, - offset: { x: 0, y: 0 }, - target: null, - }); +describe('ML - mlChartTooltipService', () => { + it('service API duck typing', () => { + expect(typeof mlChartTooltipService).toBe('object'); + expect(typeof mlChartTooltipService.show).toBe('function'); + expect(typeof mlChartTooltipService.hide).toBe('function'); }); - test('update the tooltip state only on a new value', () => { - const spy = jest.fn(); - - service.tooltipState$.subscribe(spy); - - expect(spy).toHaveBeenCalledWith(getChartTooltipDefaultState()); - - service.hide(); - - expect(spy).toHaveBeenCalledTimes(1); + it('should fail silently when target is not defined', () => { + expect(() => { + mlChartTooltipService.show(getChartTooltipDefaultState().tooltipData, null); + }).not.toThrow('Call to show() should fail silently.'); }); }); diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts deleted file mode 100644 index b524e18102a95..0000000000000 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { BehaviorSubject, Observable } from 'rxjs'; -import { isEqual } from 'lodash'; -import { TooltipValue, TooltipValueFormatter } from '@elastic/charts'; -import { distinctUntilChanged } from 'rxjs/operators'; - -export interface ChartTooltipValue extends TooltipValue { - skipHeader?: boolean; -} - -export interface TooltipHeader { - skipHeader: boolean; -} - -export type TooltipData = ChartTooltipValue[]; - -export interface ChartTooltipState { - isTooltipVisible: boolean; - offset: TooltipOffset; - tooltipData: TooltipData; - tooltipHeaderFormatter?: TooltipValueFormatter; - target: HTMLElement | null; -} - -interface TooltipOffset { - x: number; - y: number; -} - -export const getChartTooltipDefaultState = (): ChartTooltipState => ({ - isTooltipVisible: false, - tooltipData: ([] as unknown) as TooltipData, - offset: { x: 0, y: 0 }, - target: null, -}); - -export class ChartTooltipService { - private chartTooltip$ = new BehaviorSubject(getChartTooltipDefaultState()); - - public tooltipState$: Observable = this.chartTooltip$ - .asObservable() - .pipe(distinctUntilChanged(isEqual)); - - public show( - tooltipData: TooltipData, - target: HTMLElement, - offset: TooltipOffset = { x: 0, y: 0 } - ) { - if (!target) { - throw new Error('target is required for the tooltip positioning'); - } - - this.chartTooltip$.next({ - ...this.chartTooltip$.getValue(), - isTooltipVisible: true, - offset, - tooltipData, - target, - }); - } - - public hide() { - this.chartTooltip$.next({ - ...getChartTooltipDefaultState(), - isTooltipVisible: false, - }); - } -} diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts index ec19fe18bd324..75c65ebaa0f50 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ChartTooltipService } from './chart_tooltip_service'; -export { MlTooltipComponent } from './chart_tooltip'; +export { mlChartTooltipService } from './chart_tooltip_service'; +export { ChartTooltip } from './chart_tooltip'; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx index f709c161bef17..381e5e75356c1 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx @@ -4,23 +4,45 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import PropTypes from 'prop-types'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexItem, + EuiFlexGroup, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiSwitch, + EuiTitle, +} from '@elastic/eui'; -import { EuiButtonEmpty, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { useMlKibana } from '../../contexts/kibana'; import { Dictionary } from '../../../../common/types/common'; +import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; +import { ml } from '../../services/ml_api_service'; import { useUrlState } from '../../util/url_state'; // @ts-ignore +import { JobSelectorTable } from './job_selector_table/index'; +// @ts-ignore import { IdBadges } from './id_badges/index'; -import { BADGE_LIMIT, JobSelectorFlyout, JobSelectorFlyoutProps } from './job_selector_flyout'; -import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; +// @ts-ignore +import { NewSelectionIdBadges } from './new_selection_id_badges/index'; +import { + getGroupsFromJobs, + getTimeRangeFromSelection, + normalizeTimes, +} from './job_select_service_utils'; interface GroupObj { groupId: string; jobIds: string[]; } - function mergeSelection( jobIds: string[], groupObjs: GroupObj[], @@ -49,7 +71,7 @@ function mergeSelection( } type GroupsMap = Dictionary; -export function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { +function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { const map: GroupsMap = {}; if (selectedGroups.length) { @@ -61,38 +83,81 @@ export function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { return map; } +const BADGE_LIMIT = 10; +const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels + interface JobSelectorProps { dateFormatTz: string; singleSelection: boolean; timeseriesOnly: boolean; } -export interface JobSelectionMaps { - jobsMap: Dictionary; - groupsMap: Dictionary; -} - export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: JobSelectorProps) { const [globalState, setGlobalState] = useUrlState('_g'); const selectedJobIds = globalState?.ml?.jobIds ?? []; const selectedGroups = globalState?.ml?.groups ?? []; - const [maps, setMaps] = useState({ - groupsMap: getInitialGroupsMap(selectedGroups), - jobsMap: {}, - }); + const [jobs, setJobs] = useState([]); + const [groups, setGroups] = useState([]); + const [maps, setMaps] = useState({ groupsMap: getInitialGroupsMap(selectedGroups), jobsMap: {} }); const [selectedIds, setSelectedIds] = useState( mergeSelection(selectedJobIds, selectedGroups, singleSelection) ); + const [newSelection, setNewSelection] = useState( + mergeSelection(selectedJobIds, selectedGroups, singleSelection) + ); + const [showAllBadges, setShowAllBadges] = useState(false); const [showAllBarBadges, setShowAllBarBadges] = useState(false); + const [applyTimeRange, setApplyTimeRange] = useState(true); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); + const flyoutEl = useRef<{ flyout: HTMLElement }>(null); + const { + services: { notifications }, + } = useMlKibana(); // Ensure JobSelectionBar gets updated when selection via globalState changes. useEffect(() => { setSelectedIds(mergeSelection(selectedJobIds, selectedGroups, singleSelection)); }, [JSON.stringify([selectedJobIds, selectedGroups])]); + // Ensure current selected ids always show up in flyout + useEffect(() => { + setNewSelection(selectedIds); + }, [isFlyoutVisible]); // eslint-disable-line + + // Wrap handleResize in useCallback as it is a dependency for useEffect on line 131 below. + // Not wrapping it would cause this dependency to change on every render + const handleResize = useCallback(() => { + if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) { + // get all cols in flyout table + const tableHeaderCols: NodeListOf = flyoutEl.current.flyout.querySelectorAll( + 'table thead th' + ); + // get the width of the last col + const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16; + const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth); + setJobs(normalizedJobs); + const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs); + setGroups(updatedGroups); + setGanttBarWidth(derivedWidth); + } + }, [dateFormatTz, jobs]); + + useEffect(() => { + // Ensure ganttBar width gets calculated on resize + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [handleResize]); + + useEffect(() => { + handleResize(); + }, [handleResize, jobs]); + function closeFlyout() { setIsFlyoutVisible(false); } @@ -103,26 +168,78 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J function handleJobSelectionClick() { showFlyout(); + + ml.jobs + .jobsWithTimerange(dateFormatTz) + .then(resp => { + const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH); + const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs); + setJobs(normalizedJobs); + setGroups(groupsWithTimerange); + setMaps({ groupsMap, jobsMap: resp.jobsMap }); + }) + .catch((err: any) => { + console.error('Error fetching jobs with time range', err); // eslint-disable-line + const { toasts } = notifications; + toasts.addDanger({ + title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', { + defaultMessage: 'An error occurred fetching jobs. Refresh and try again.', + }), + }); + }); + } + + function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) { + setNewSelection(selectionFromTable); } - const applySelection: JobSelectorFlyoutProps['onSelectionConfirmed'] = ({ - newSelection, - jobIds, - groups: newGroups, - time, - }) => { + function applySelection() { + // allNewSelection will be a list of all job ids (including those from groups) selected from the table + const allNewSelection: string[] = []; + const groupSelection: Array<{ groupId: string; jobIds: string[] }> = []; + + newSelection.forEach(id => { + if (maps.groupsMap[id] !== undefined) { + // Push all jobs from selected groups into the newSelection list + allNewSelection.push(...maps.groupsMap[id]); + // if it's a group - push group obj to set in global state + groupSelection.push({ groupId: id, jobIds: maps.groupsMap[id] }); + } else { + allNewSelection.push(id); + } + }); + // create a Set to remove duplicate values + const allNewSelectionUnique = Array.from(new Set(allNewSelection)); + setSelectedIds(newSelection); + setNewSelection([]); + + closeFlyout(); + + const time = applyTimeRange + ? getTimeRangeFromSelection(jobs, allNewSelectionUnique) + : undefined; setGlobalState({ ml: { - jobIds, - groups: newGroups, + jobIds: allNewSelectionUnique, + groups: groupSelection, }, ...(time !== undefined ? { time } : {}), }); + } - closeFlyout(); - }; + function toggleTimerangeSwitch() { + setApplyTimeRange(!applyTimeRange); + } + + function removeId(id: string) { + setNewSelection(newSelection.filter(item => item !== id)); + } + + function clearSelection() { + setNewSelection([]); + } function renderJobSelectionBar() { return ( @@ -163,16 +280,103 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J function renderFlyout() { if (isFlyoutVisible) { return ( - + + + +

+ {i18n.translate('xpack.ml.jobSelector.flyoutTitle', { + defaultMessage: 'Job selection', + })} +

+
+
+ + + + + setShowAllBadges(!showAllBadges)} + showAllBadges={showAllBadges} + /> + + + + + + {!singleSelection && newSelection.length > 0 && ( + + {i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', { + defaultMessage: 'Clear all', + })} + + )} + + + + + + + + + + + + + + {i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', { + defaultMessage: 'Apply', + })} + + + + + {i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', { + defaultMessage: 'Close', + })} + + + + +
); } } @@ -184,3 +388,9 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
); } + +JobSelector.propTypes = { + selectedJobIds: PropTypes.array, + singleSelection: PropTypes.bool, + timeseriesOnly: PropTypes.bool, +}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.ts b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.js similarity index 100% rename from x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.ts rename to x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.js diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js similarity index 68% rename from x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx rename to x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js index b2cae278c0e77..4d2ab01e2a054 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js @@ -4,32 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC } from 'react'; -import { EuiBadge, EuiBadgeProps } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { PropTypes } from 'prop-types'; +import { EuiBadge } from '@elastic/eui'; import { tabColor } from '../../../../../common/util/group_color_utils'; +import { i18n } from '@kbn/i18n'; -interface JobSelectorBadgeProps { - icon?: boolean; - id: string; - isGroup?: boolean; - numJobs?: number; - removeId?: Function; -} - -export const JobSelectorBadge: FC = ({ - icon, - id, - isGroup = false, - numJobs, - removeId, -}) => { +export function JobSelectorBadge({ icon, id, isGroup = false, numJobs, removeId }) { const color = isGroup ? tabColor(id) : 'hollow'; - let props = { color } as EuiBadgeProps; + let props = { color }; let jobCount; - if (icon === true && removeId) { - // @ts-ignore + if (icon === true) { props = { ...props, iconType: 'cross', @@ -51,4 +37,11 @@ export const JobSelectorBadge: FC = ({ {`${id}${jobCount ? jobCount : ''}`} ); +} +JobSelectorBadge.propTypes = { + icon: PropTypes.bool, + id: PropTypes.string.isRequired, + isGroup: PropTypes.bool, + numJobs: PropTypes.number, + removeId: PropTypes.func, }; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx deleted file mode 100644 index 66aa05d2aaa97..0000000000000 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx +++ /dev/null @@ -1,289 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiButton, - EuiButtonEmpty, - EuiFlexItem, - EuiFlexGroup, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiSwitch, - EuiTitle, -} from '@elastic/eui'; -import { NewSelectionIdBadges } from './new_selection_id_badges'; -// @ts-ignore -import { JobSelectorTable } from './job_selector_table'; -import { - getGroupsFromJobs, - getTimeRangeFromSelection, - normalizeTimes, -} from './job_select_service_utils'; -import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; -import { ml } from '../../services/ml_api_service'; -import { useMlKibana } from '../../contexts/kibana'; -import { JobSelectionMaps } from './job_selector'; - -export const BADGE_LIMIT = 10; -export const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels - -export interface JobSelectorFlyoutProps { - dateFormatTz: string; - selectedIds?: string[]; - newSelection?: string[]; - onFlyoutClose: () => void; - onJobsFetched?: (maps: JobSelectionMaps) => void; - onSelectionChange?: (newSelection: string[]) => void; - onSelectionConfirmed: (payload: { - newSelection: string[]; - jobIds: string[]; - groups: Array<{ groupId: string; jobIds: string[] }>; - time: any; - }) => void; - singleSelection: boolean; - timeseriesOnly: boolean; - maps: JobSelectionMaps; - withTimeRangeSelector?: boolean; -} - -export const JobSelectorFlyout: FC = ({ - dateFormatTz, - selectedIds = [], - singleSelection, - timeseriesOnly, - onJobsFetched, - onSelectionChange, - onSelectionConfirmed, - onFlyoutClose, - maps, - withTimeRangeSelector = true, -}) => { - const { - services: { notifications }, - } = useMlKibana(); - - const [newSelection, setNewSelection] = useState(selectedIds); - - const [showAllBadges, setShowAllBadges] = useState(false); - const [applyTimeRange, setApplyTimeRange] = useState(true); - const [jobs, setJobs] = useState([]); - const [groups, setGroups] = useState([]); - const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); - const [jobGroupsMaps, setJobGroupsMaps] = useState(maps); - - const flyoutEl = useRef<{ flyout: HTMLElement }>(null); - - function applySelection() { - // allNewSelection will be a list of all job ids (including those from groups) selected from the table - const allNewSelection: string[] = []; - const groupSelection: Array<{ groupId: string; jobIds: string[] }> = []; - - newSelection.forEach(id => { - if (jobGroupsMaps.groupsMap[id] !== undefined) { - // Push all jobs from selected groups into the newSelection list - allNewSelection.push(...jobGroupsMaps.groupsMap[id]); - // if it's a group - push group obj to set in global state - groupSelection.push({ groupId: id, jobIds: jobGroupsMaps.groupsMap[id] }); - } else { - allNewSelection.push(id); - } - }); - // create a Set to remove duplicate values - const allNewSelectionUnique = Array.from(new Set(allNewSelection)); - - const time = applyTimeRange - ? getTimeRangeFromSelection(jobs, allNewSelectionUnique) - : undefined; - - onSelectionConfirmed({ - newSelection: allNewSelectionUnique, - jobIds: allNewSelectionUnique, - groups: groupSelection, - time, - }); - } - - function removeId(id: string) { - setNewSelection(newSelection.filter(item => item !== id)); - } - - function toggleTimerangeSwitch() { - setApplyTimeRange(!applyTimeRange); - } - - function clearSelection() { - setNewSelection([]); - } - - function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) { - setNewSelection(selectionFromTable); - } - - // Wrap handleResize in useCallback as it is a dependency for useEffect on line 131 below. - // Not wrapping it would cause this dependency to change on every render - const handleResize = useCallback(() => { - if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) { - // get all cols in flyout table - const tableHeaderCols: NodeListOf = flyoutEl.current.flyout.querySelectorAll( - 'table thead th' - ); - // get the width of the last col - const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16; - const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth); - setJobs(normalizedJobs); - const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs); - setGroups(updatedGroups); - setGanttBarWidth(derivedWidth); - } - }, [dateFormatTz, jobs]); - - // Fetch jobs list on flyout open - useEffect(() => { - fetchJobs(); - }, []); - - async function fetchJobs() { - try { - const resp = await ml.jobs.jobsWithTimerange(dateFormatTz); - const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH); - const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs); - setJobs(normalizedJobs); - setGroups(groupsWithTimerange); - setJobGroupsMaps({ groupsMap, jobsMap: resp.jobsMap }); - - if (onJobsFetched) { - onJobsFetched({ groupsMap, jobsMap: resp.jobsMap }); - } - } catch (e) { - console.error('Error fetching jobs with time range', e); // eslint-disable-line - const { toasts } = notifications; - toasts.addDanger({ - title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', { - defaultMessage: 'An error occurred fetching jobs. Refresh and try again.', - }), - }); - } - } - - useEffect(() => { - // Ensure ganttBar width gets calculated on resize - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - }; - }, [handleResize]); - - useEffect(() => { - handleResize(); - }, [handleResize, jobs]); - - return ( - - - -

- {i18n.translate('xpack.ml.jobSelector.flyoutTitle', { - defaultMessage: 'Job selection', - })} -

-
-
- - - - - setShowAllBadges(!showAllBadges)} - showAllBadges={showAllBadges} - /> - - - - - - {!singleSelection && newSelection.length > 0 && ( - - {i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', { - defaultMessage: 'Clear all', - })} - - )} - - {withTimeRangeSelector && ( - - - - )} - - - - - - - - - - {i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', { - defaultMessage: 'Apply', - })} - - - - - {i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', { - defaultMessage: 'Close', - })} - - - - -
- ); -}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js index c55e03776c09d..64793d15f1e4a 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js @@ -224,7 +224,7 @@ export function JobSelectorTable({ {jobs.length === 0 && } {jobs.length !== 0 && singleSelection === true && renderJobsTable()} - {jobs.length !== 0 && !singleSelection && renderTabs()} + {jobs.length !== 0 && singleSelection === undefined && renderTabs()} ); } diff --git a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.ts b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.js similarity index 100% rename from x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.ts rename to x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.js diff --git a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js similarity index 80% rename from x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx rename to x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js index 4c018e72f3e10..67dce47323889 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js @@ -4,29 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, MouseEventHandler } from 'react'; +import React from 'react'; +import { PropTypes } from 'prop-types'; import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { JobSelectorBadge } from '../job_selector_badge'; -import { JobSelectionMaps } from '../job_selector'; - -interface NewSelectionIdBadgesProps { - limit: number; - maps: JobSelectionMaps; - newSelection: string[]; - onDeleteClick?: Function; - onLinkClick?: MouseEventHandler; - showAllBadges?: boolean; -} +import { i18n } from '@kbn/i18n'; -export const NewSelectionIdBadges: FC = ({ +export function NewSelectionIdBadges({ limit, maps, newSelection, onDeleteClick, onLinkClick, showAllBadges, -}) => { +}) { const badges = []; for (let i = 0; i < newSelection.length; i++) { @@ -69,5 +60,16 @@ export const NewSelectionIdBadges: FC = ({ ); } - return <>{badges}; + return badges; +} +NewSelectionIdBadges.propTypes = { + limit: PropTypes.number, + maps: PropTypes.shape({ + jobsMap: PropTypes.object, + groupsMap: PropTypes.object, + }), + newSelection: PropTypes.array, + onDeleteClick: PropTypes.func, + onLinkClick: PropTypes.func, + showAllBadges: PropTypes.bool, }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index 06d89ab782167..86ffc4a2614b9 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -41,7 +41,7 @@ import { useMlContext } from '../../contexts/ml'; import { kbnTypeToMLJobType } from '../../util/field_types_utils'; import { useTimefilter } from '../../contexts/kibana'; import { timeBasedIndexCheck, getQueryFromSavedSearch } from '../../util/index_utils'; -import { getTimeBucketsFromCache } from '../../util/time_buckets'; +import { TimeBuckets } from '../../util/time_buckets'; import { useUrlState } from '../../util/url_state'; import { FieldRequestConfig, FieldVisConfig } from './common'; import { ActionsPanel } from './components/actions_panel'; @@ -318,7 +318,7 @@ export const Page: FC = () => { // Obtain the interval to use for date histogram aggregations // (such as the document count chart). Aim for 75 bars. - const buckets = getTimeBucketsFromCache(); + const buckets = new TimeBuckets(); const tf = timefilter as any; let earliest: number | undefined; diff --git a/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap b/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.js.snap similarity index 100% rename from x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap rename to x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.js.snap diff --git a/x-pack/plugins/ml/public/application/explorer/_explorer.scss b/x-pack/plugins/ml/public/application/explorer/_explorer.scss index cfcba081983c2..9fb2f0c3bed94 100644 --- a/x-pack/plugins/ml/public/application/explorer/_explorer.scss +++ b/x-pack/plugins/ml/public/application/explorer/_explorer.scss @@ -106,6 +106,164 @@ padding: 0; margin-bottom: $euiSizeS; + div.ml-swimlanes { + margin: 0px 0px 0px 10px; + + div.cells-marker-container { + margin-left: 176px; + height: 22px; + white-space: nowrap; + + // background-color: #CCC; + .sl-cell { + height: 10px; + display: inline-block; + vertical-align: top; + margin-top: 16px; + text-align: center; + visibility: hidden; + cursor: default; + + i { + color: $euiColorDarkShade; + } + } + + .sl-cell-hover { + visibility: visible; + + i { + display: block; + margin-top: -6px; + } + } + + .sl-cell-active-hover { + visibility: visible; + + .floating-time-label { + display: inline-block; + } + } + } + + div.lane { + height: 30px; + border-bottom: 0px; + border-radius: 2px; + margin-top: -1px; + white-space: nowrap; + + div.lane-label { + display: inline-block; + font-size: 13px; + height: 30px; + text-align: right; + vertical-align: middle; + border-radius: 2px; + padding-right: 5px; + margin-right: 5px; + border: 1px solid transparent; + overflow: hidden; + text-overflow: ellipsis; + } + + div.lane-label.lane-label-masked { + opacity: 0.3; + } + + div.cells-container { + border: $euiBorderThin; + border-right: 0px; + display: inline-block; + height: 30px; + vertical-align: middle; + background-color: $euiColorEmptyShade; + + .sl-cell { + color: $euiColorEmptyShade; + cursor: default; + display: inline-block; + height: 29px; + border-right: $euiBorderThin; + vertical-align: top; + position: relative; + + .sl-cell-inner, + .sl-cell-inner-dragselect { + height: 26px; + margin: 1px; + border-radius: 2px; + text-align: center; + } + + .sl-cell-inner.sl-cell-inner-masked { + opacity: 0.2; + } + + .sl-cell-inner.sl-cell-inner-selected, + .sl-cell-inner-dragselect.sl-cell-inner-selected { + border: 2px solid $euiColorDarkShade; + } + + .sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked, + .sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked { + border: 2px solid $euiColorFullShade; + opacity: 0.4; + } + } + + .sl-cell:hover { + .sl-cell-inner { + opacity: 0.8; + cursor: pointer; + } + } + + .sl-cell.ds-selected { + + .sl-cell-inner, + .sl-cell-inner-dragselect { + border: 2px solid $euiColorDarkShade; + border-radius: 2px; + opacity: 1; + } + } + + } + } + + div.lane:last-child { + div.cells-container { + .sl-cell { + border-bottom: $euiBorderThin; + } + } + } + + .time-tick-labels { + height: 25px; + margin-top: $euiSizeXS / 2; + margin-left: 175px; + + /* hide d3's domain line */ + path.domain { + display: none; + } + + /* hide d3's tick line */ + g.tick line { + display: none; + } + + /* override d3's default tick styles */ + g.tick text { + font-size: 11px; + fill: $euiColorMediumShade; + } + } + } + line.gridLine { stroke: $euiBorderColor; fill: none; @@ -170,161 +328,3 @@ } } } - -.ml-swimlanes { - margin: 0px 0px 0px 10px; - - div.cells-marker-container { - margin-left: 176px; - height: 22px; - white-space: nowrap; - - // background-color: #CCC; - .sl-cell { - height: 10px; - display: inline-block; - vertical-align: top; - margin-top: 16px; - text-align: center; - visibility: hidden; - cursor: default; - - i { - color: $euiColorDarkShade; - } - } - - .sl-cell-hover { - visibility: visible; - - i { - display: block; - margin-top: -6px; - } - } - - .sl-cell-active-hover { - visibility: visible; - - .floating-time-label { - display: inline-block; - } - } - } - - div.lane { - height: 30px; - border-bottom: 0px; - border-radius: 2px; - margin-top: -1px; - white-space: nowrap; - - div.lane-label { - display: inline-block; - font-size: 13px; - height: 30px; - text-align: right; - vertical-align: middle; - border-radius: 2px; - padding-right: 5px; - margin-right: 5px; - border: 1px solid transparent; - overflow: hidden; - text-overflow: ellipsis; - } - - div.lane-label.lane-label-masked { - opacity: 0.3; - } - - div.cells-container { - border: $euiBorderThin; - border-right: 0px; - display: inline-block; - height: 30px; - vertical-align: middle; - background-color: $euiColorEmptyShade; - - .sl-cell { - color: $euiColorEmptyShade; - cursor: default; - display: inline-block; - height: 29px; - border-right: $euiBorderThin; - vertical-align: top; - position: relative; - - .sl-cell-inner, - .sl-cell-inner-dragselect { - height: 26px; - margin: 1px; - border-radius: 2px; - text-align: center; - } - - .sl-cell-inner.sl-cell-inner-masked { - opacity: 0.2; - } - - .sl-cell-inner.sl-cell-inner-selected, - .sl-cell-inner-dragselect.sl-cell-inner-selected { - border: 2px solid $euiColorDarkShade; - } - - .sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked, - .sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked { - border: 2px solid $euiColorFullShade; - opacity: 0.4; - } - } - - .sl-cell:hover { - .sl-cell-inner { - opacity: 0.8; - cursor: pointer; - } - } - - .sl-cell.ds-selected { - - .sl-cell-inner, - .sl-cell-inner-dragselect { - border: 2px solid $euiColorDarkShade; - border-radius: 2px; - opacity: 1; - } - } - - } - } - - div.lane:last-child { - div.cells-container { - .sl-cell { - border-bottom: $euiBorderThin; - } - } - } - - .time-tick-labels { - height: 25px; - margin-top: $euiSizeXS / 2; - margin-left: 175px; - - /* hide d3's domain line */ - path.domain { - display: none; - } - - /* hide d3's tick line */ - g.tick line { - display: none; - } - - /* override d3's default tick styles */ - g.tick text { - font-size: 11px; - fill: $euiColorMediumShade; - } - } -} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index 86d16776b68e2..d61d56d07b644 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -36,8 +36,9 @@ import { ExplorerNoJobsFound, ExplorerNoResultsFound, } from './components'; +import { ChartTooltip } from '../components/chart_tooltip'; import { ExplorerSwimlane } from './explorer_swimlane'; -import { getTimeBucketsFromCache } from '../util/time_buckets'; +import { TimeBuckets } from '../util/time_buckets'; import { InfluencersList } from '../components/influencers_list'; import { ALLOW_CELL_RANGE_SELECTION, @@ -80,7 +81,6 @@ import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public'; import { getTimefilter, getToastNotifications } from '../util/dependency_cache'; -import { MlTooltipComponent } from '../components/chart_tooltip'; function mapSwimlaneOptionsToEuiOptions(options) { return options.map(option => ({ @@ -179,8 +179,6 @@ export class Explorer extends React.Component { // Required to redraw the time series chart when the container is resized. this.resizeChecker = new ResizeChecker(this.resizeRef.current); this.resizeChecker.on('resize', this.resizeHandler); - - this.timeBuckets = getTimeBucketsFromCache(); } componentWillUnmount() { @@ -360,6 +358,9 @@ export class Explorer extends React.Component { return (
+ {/* Make sure ChartTooltip is inside wrapping div with 0px left/right padding so positioning can be inferred correctly. */} + + {noInfluencersConfigured === false && influencers !== undefined && (
{showOverallSwimlane && ( - - {tooltipService => ( - - )} - + )}
@@ -498,22 +494,17 @@ export class Explorer extends React.Component { onMouseLeave={this.onSwimlaneLeaveHandler} data-test-subj="mlAnomalyExplorerSwimlaneViewBy" > - - {tooltipService => ( - - )} - +
)} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js index 03426869b0ccf..5fc1160093a49 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js @@ -29,8 +29,9 @@ import { removeLabelOverlap, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { getTimeBucketsFromCache } from '../../util/time_buckets'; +import { TimeBuckets } from '../../util/time_buckets'; import { mlFieldFormatService } from '../../services/field_format_service'; +import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; import { CHART_TYPE } from '../explorer_constants'; @@ -49,7 +50,6 @@ export class ExplorerChartDistribution extends React.Component { static propTypes = { seriesConfig: PropTypes.object, severity: PropTypes.number, - tooltipService: PropTypes.object.isRequired, }; componentDidMount() { @@ -61,7 +61,7 @@ export class ExplorerChartDistribution extends React.Component { } renderChart() { - const { tooManyBuckets, tooltipService } = this.props; + const { tooManyBuckets } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -259,7 +259,7 @@ export class ExplorerChartDistribution extends React.Component { function drawRareChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = getTimeBucketsFromCache(); + const timeBuckets = new TimeBuckets(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); @@ -397,7 +397,7 @@ export class ExplorerChartDistribution extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => tooltipService.hide()); + .on('mouseout', () => mlChartTooltipService.hide()); // Update all dots to new positions. dots @@ -550,7 +550,7 @@ export class ExplorerChartDistribution extends React.Component { }); } - tooltipService.show(tooltipData, circle, { + mlChartTooltipService.show(tooltipData, circle, { x: LINE_CHART_ANOMALY_RADIUS * 3, y: LINE_CHART_ANOMALY_RADIUS * 2, }); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js index 06fd82204c1e1..71d777db5b2ec 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js @@ -10,13 +10,11 @@ import seriesConfig from './__mocks__/mock_series_config_rare.json'; // Mock TimeBuckets and mlFieldFormatService, they don't play well // with the jest based test setup yet. jest.mock('../../util/time_buckets', () => ({ - getTimeBucketsFromCache: jest.fn(() => { - return { - setBounds: jest.fn(), - setInterval: jest.fn(), - getScaledDateFormat: jest.fn(), - }; - }), + TimeBuckets: function() { + this.setBounds = jest.fn(); + this.setInterval = jest.fn(); + this.getScaledDateFormat = jest.fn(); + }, })); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { @@ -45,16 +43,8 @@ describe('ExplorerChart', () => { afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); test('Initialize', () => { - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - const wrapper = mountWithIntl( - + ); // without setting any attributes and corresponding data @@ -69,16 +59,10 @@ describe('ExplorerChart', () => { loading: true, }; - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - const wrapper = mountWithIntl( ); @@ -99,18 +83,12 @@ describe('ExplorerChart', () => { chartLimits: chartLimits(chartData), }; - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - // We create the element including a wrapper which sets the width: return mountWithIntl(
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js index 82041af39ca15..dd9479be931a7 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js @@ -38,9 +38,10 @@ import { showMultiBucketAnomalyTooltip, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { getTimeBucketsFromCache } from '../../util/time_buckets'; +import { TimeBuckets } from '../../util/time_buckets'; import { mlEscape } from '../../util/string_utils'; import { mlFieldFormatService } from '../../services/field_format_service'; +import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; import { i18n } from '@kbn/i18n'; @@ -52,7 +53,6 @@ export class ExplorerChartSingleMetric extends React.Component { tooManyBuckets: PropTypes.bool, seriesConfig: PropTypes.object, severity: PropTypes.number.isRequired, - tooltipService: PropTypes.object.isRequired, }; componentDidMount() { @@ -64,7 +64,7 @@ export class ExplorerChartSingleMetric extends React.Component { } renderChart() { - const { tooManyBuckets, tooltipService } = this.props; + const { tooManyBuckets } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -191,7 +191,7 @@ export class ExplorerChartSingleMetric extends React.Component { function drawLineChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = getTimeBucketsFromCache(); + const timeBuckets = new TimeBuckets(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); @@ -309,7 +309,7 @@ export class ExplorerChartSingleMetric extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => tooltipService.hide()); + .on('mouseout', () => mlChartTooltipService.hide()); const isAnomalyVisible = d => _.has(d, 'anomalyScore') && Number(d.anomalyScore) >= severity; @@ -354,7 +354,7 @@ export class ExplorerChartSingleMetric extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => tooltipService.hide()); + .on('mouseout', () => mlChartTooltipService.hide()); // Add rectangular markers for any scheduled events. const scheduledEventMarkers = lineChartGroup @@ -503,7 +503,7 @@ export class ExplorerChartSingleMetric extends React.Component { }); } - tooltipService.show(tooltipData, circle, { + mlChartTooltipService.show(tooltipData, circle, { x: LINE_CHART_ANOMALY_RADIUS * 3, y: LINE_CHART_ANOMALY_RADIUS * 2, }); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js index 54f541ceb7c3d..ca3e52308a936 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js @@ -10,13 +10,11 @@ import seriesConfig from './__mocks__/mock_series_config_filebeat.json'; // Mock TimeBuckets and mlFieldFormatService, they don't play well // with the jest based test setup yet. jest.mock('../../util/time_buckets', () => ({ - getTimeBucketsFromCache: jest.fn(() => { - return { - setBounds: jest.fn(), - setInterval: jest.fn(), - getScaledDateFormat: jest.fn(), - }; - }), + TimeBuckets: function() { + this.setBounds = jest.fn(); + this.setInterval = jest.fn(); + this.getScaledDateFormat = jest.fn(); + }, })); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { @@ -45,16 +43,8 @@ describe('ExplorerChart', () => { afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); test('Initialize', () => { - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - const wrapper = mountWithIntl( - + ); // without setting any attributes and corresponding data @@ -69,16 +59,10 @@ describe('ExplorerChart', () => { loading: true, }; - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - const wrapper = mountWithIntl( ); @@ -99,18 +83,12 @@ describe('ExplorerChart', () => { chartLimits: chartLimits(chartData), }; - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - // We create the element including a wrapper which sets the width: return mountWithIntl(
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 5b95931d31ab6..99de38c1e0a84 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import $ from 'jquery'; + import React from 'react'; import { @@ -27,7 +29,6 @@ import { ExplorerChartLabel } from './components/explorer_chart_label'; import { CHART_TYPE } from '../explorer_constants'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { MlTooltipComponent } from '../../components/chart_tooltip'; const textTooManyBuckets = i18n.translate('xpack.ml.explorer.charts.tooManyBucketsDescription', { defaultMessage: @@ -120,29 +121,19 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) chartType === CHART_TYPE.POPULATION_DISTRIBUTION ) { return ( - - {tooltipService => ( - - )} - + ); } return ( - - {tooltipService => ( - - )} - + ); })()} @@ -150,36 +141,48 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) } // Flex layout wrapper for all explorer charts -export const ExplorerChartsContainer = ({ - chartsPerRow, - seriesToPlot, - severity, - tooManyBuckets, -}) => { - // doesn't allow a setting of `columns={1}` when chartsPerRow would be 1. - // If that's the case we trick it doing that with the following settings: - const chartsWidth = chartsPerRow === 1 ? 'calc(100% - 20px)' : 'auto'; - const chartsColumns = chartsPerRow === 1 ? 0 : chartsPerRow; - - const wrapLabel = seriesToPlot.some(series => isLabelLengthAboveThreshold(series)); +export class ExplorerChartsContainer extends React.Component { + componentDidMount() { + // Create a div for the tooltip. + $('.ml-explorer-charts-tooltip').remove(); + $('body').append( + '